Compare commits

..

1 Commits

Author SHA1 Message Date
Jens Langhammer
739acf50f4 providers/radius: add logout support
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-04-01 03:34:07 +02:00
397 changed files with 7204 additions and 32654 deletions

View File

@@ -1,5 +1,5 @@
[bumpversion]
current_version = 2024.4.1
current_version = 2024.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*))?
@@ -21,8 +21,6 @@ optional_value = final
[bumpversion:file:schema.yml]
[bumpversion:file:blueprints/schema.json]
[bumpversion:file:authentik/__init__.py]
[bumpversion:file:internal/constants/constants.go]

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
custom: https://goauthentik.io/pricing/
github: [BeryJu]

View File

@@ -54,9 +54,9 @@ image_main_tag = image_tags[0]
image_tags_rendered = ",".join(image_tags)
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
print(f"shouldBuild={should_build}", file=_output)
print(f"sha={sha}", file=_output)
print(f"version={version}", file=_output)
print(f"prerelease={prerelease}", file=_output)
print(f"imageTags={image_tags_rendered}", file=_output)
print(f"imageMainTag={image_main_tag}", file=_output)
print("shouldBuild=%s" % should_build, file=_output)
print("sha=%s" % sha, file=_output)
print("version=%s" % version, file=_output)
print("prerelease=%s" % prerelease, file=_output)
print("imageTags=%s" % image_tags_rendered, file=_output)
print("imageMainTag=%s" % image_main_tag, file=_output)

View File

@@ -16,25 +16,25 @@ runs:
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext
- name: Setup python and restore poetry
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version-file: "pyproject.toml"
cache: "poetry"
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Setup go
uses: actions/setup-go@v5
uses: actions/setup-go@v4
with:
go-version-file: "go.mod"
- name: Setup dependencies
shell: bash
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/docker-compose.yml up -d
docker-compose -f .github/actions/setup/docker-compose.yml up -d
poetry install
cd web && npm ci
- name: Generate config

View File

@@ -1,65 +0,0 @@
name: authentik-api-py-publish
on:
push:
branches: [main]
paths:
- "schema.yml"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Install poetry & deps
shell: bash
run: |
pipx install poetry || true
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext
- name: Setup python and restore poetry
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
working-directory: gen-py-api/
run: |
poetry build
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: gen-py-api/dist/
# We can't easily upgrade the API client being used due to poetry being poetry
# so we'll have to rely on dependabot
# - name: Upgrade /
# run: |
# export VERSION=$(cd gen-py-api && poetry version -s)
# poetry add "authentik_client=$VERSION" --allow-prereleases --lock
# - uses: peter-evans/create-pull-request@v6
# id: cpr
# with:
# token: ${{ steps.generate_token.outputs.token }}
# branch: update-root-api-client
# commit-message: "root: bump API Client version"
# title: "root: bump API Client version"
# body: "root: bump API Client version"
# delete-branch: true
# signoff: true
# # ID from https://api.github.com/users/authentik-automation[bot]
# author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
# - uses: peter-evans/enable-pull-request-automerge@v3
# with:
# token: ${{ steps.generate_token.outputs.token }}
# pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
# merge-method: squash

View File

@@ -130,7 +130,7 @@ jobs:
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Create k8s Kind Cluster
uses: helm/kind-action@v1.10.0
uses: helm/kind-action@v1.9.0
- name: run integration
run: |
poetry run coverage run manage.py test tests/integration
@@ -160,8 +160,6 @@ jobs:
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
- name: radius
glob: tests/e2e/test_provider_radius*
- name: scim
glob: tests/e2e/test_source_scim*
- name: flows
glob: tests/e2e/test_flows*
steps:
@@ -170,7 +168,7 @@ jobs:
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/docker-compose.yml up -d
docker-compose -f tests/e2e/docker-compose.yml up -d
- id: cache-web
uses: actions/cache@v4
with:

View File

@@ -29,7 +29,7 @@ jobs:
- name: Generate API
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@v5
uses: golangci/golangci-lint-action@v4
with:
version: v1.54.2
args: --timeout 5000s --verbose

View File

@@ -34,13 +34,6 @@ jobs:
- name: Eslint
working-directory: ${{ matrix.project }}/
run: npm run lint
lint-lockfile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- working-directory: web/
run: |
[ -z "$(jq -r '.packages | to_entries[] | select((.key | startswith("node_modules")) and (.value | has("resolved") | not)) | .key' < package-lock.json)" ]
lint-build:
runs-on: ubuntu-latest
steps:
@@ -102,7 +95,6 @@ jobs:
run: npm run lit-analyse
ci-web-mark:
needs:
- lint-lockfile
- lint-eslint
- lint-prettier
- lint-lit-analyse

View File

@@ -12,13 +12,6 @@ on:
- version-*
jobs:
lint-lockfile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- working-directory: website/
run: |
[ -z "$(jq -r '.packages | to_entries[] | select((.key | startswith("node_modules")) and (.value | has("resolved") | not)) | .key' < package-lock.json)" ]
lint-prettier:
runs-on: ubuntu-latest
steps:
@@ -69,7 +62,6 @@ jobs:
run: npm run ${{ matrix.job }}
ci-website-mark:
needs:
- lint-lockfile
- lint-prettier
- test
- build

View File

@@ -1,43 +0,0 @@
name: authentik-gen-update-webauthn-mds
on:
workflow_dispatch:
schedule:
- cron: '30 1 1,15 * *'
env:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Setup authentik env
uses: ./.github/actions/setup
- run: poetry run ak update_webauthn_mds
- uses: peter-evans/create-pull-request@v6
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}
branch: update-fido-mds-client
commit-message: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs"
title: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs"
body: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs"
delete-branch: true
signoff: true
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
- uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ steps.generate_token.outputs.token }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
merge-method: squash

View File

@@ -157,10 +157,10 @@ jobs:
run: |
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker compose pull -q
docker compose up --no-start
docker compose start postgresql redis
docker compose run -u root server test-all
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test-all
sentry-release:
needs:
- build-server

View File

@@ -21,9 +21,9 @@ jobs:
docker build -t testing:latest .
echo "AUTHENTIK_IMAGE=testing" >> .env
echo "AUTHENTIK_TAG=latest" >> .env
docker compose up --no-start
docker compose start postgresql redis
docker compose run -u root server test-all
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test-all
- id: generate_token
uses: tibdex/github-app-token@v2
with:

View File

@@ -23,7 +23,7 @@ jobs:
repo-token: ${{ steps.generate_token.outputs.token }}
days-before-stale: 60
days-before-close: 7
exempt-issue-labels: pinned,security,pr_wanted,enhancement,bug/confirmed,enhancement/confirmed,question,status/reviewing
exempt-issue-labels: pinned,security,pr_wanted,enhancement,bug/confirmed,enhancement/confirmed,question
stale-issue-label: wontfix
stale-issue-message: >
This issue has been automatically marked as stale because it has not had

View File

@@ -1,4 +1,4 @@
name: authentik-api-ts-publish
name: authentik-web-api-publish
on:
push:
branches: [main]

View File

@@ -38,7 +38,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/golang:1.22.2-bookworm AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/golang:1.22.1-bookworm AS go-builder
ARG TARGETOS
ARG TARGETARCH
@@ -70,10 +70,10 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 as geoip
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.1 as geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
ENV GEOIPUPDATE_VERBOSE="1"
ENV GEOIPUPDATE_VERBOSE="true"
ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID"
ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY"
@@ -84,7 +84,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Python dependencies
FROM docker.io/python:3.12.3-slim-bookworm AS python-deps
FROM docker.io/python:3.12.2-slim-bookworm AS python-deps
WORKDIR /ak-root/poetry
@@ -110,7 +110,7 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
poetry install --only=main --no-ansi --no-interaction --no-root"
# Stage 6: Run
FROM docker.io/python:3.12.3-slim-bookworm AS final-image
FROM docker.io/python:3.12.2-slim-bookworm AS final-image
ARG GIT_BUILD_HASH
ARG VERSION

View File

@@ -9,7 +9,6 @@ PY_SOURCES = authentik tests scripts lifecycle .github
DOCKER_IMAGE ?= "authentik:test"
GEN_API_TS = "gen-ts-api"
GEN_API_PY = "gen-py-api"
GEN_API_GO = "gen-go-api"
pg_user := $(shell python -m authentik.lib.config postgresql.user 2>/dev/null)
@@ -48,10 +47,10 @@ test-go:
test-docker: ## Run all tests in a docker-compose
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker compose pull -q
docker compose up --no-start
docker compose start postgresql redis
docker compose run -u root server test-all
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test-all
rm -f .env
test: ## Run the server tests and produce a coverage report (locally)
@@ -65,7 +64,7 @@ lint-fix: ## Lint and automatically fix errors in the python source code. Repor
codespell -w $(CODESPELL_ARGS)
lint: ## Lint the python and golang sources
bandit -r $(PY_SOURCES) -x web/node_modules -x tests/wdio/node_modules -x website/node_modules
bandit -r $(PY_SOURCES) -x node_modules
golangci-lint run -v
core-install:
@@ -138,10 +137,7 @@ gen-clean-ts: ## Remove generated API client for Typescript
gen-clean-go: ## Remove generated API client for Go
rm -rf ./${GEN_API_GO}/
gen-clean-py: ## Remove generated API client for Python
rm -rf ./${GEN_API_PY}/
gen-clean: gen-clean-ts gen-clean-go gen-clean-py ## Remove generated API clients
gen-clean: gen-clean-ts gen-clean-go ## Remove generated API clients
gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application
docker run \
@@ -159,20 +155,6 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
cd ./${GEN_API_TS} && npm i
\cp -rf ./${GEN_API_TS}/* web/node_modules/@goauthentik/api
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v7.4.0 generate \
-i /local/schema.yml \
-g python \
-o /local/${GEN_API_PY} \
-c /local/scripts/api-py-config.yaml \
--additional-properties=packageVersion=${NPM_VERSION} \
--git-repo-id authentik \
--git-user-id goauthentik
pip install ./${GEN_API_PY}
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
mkdir -p ./${GEN_API_GO} ./${GEN_API_GO}/templates
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./${GEN_API_GO}/config.yaml

View File

@@ -25,10 +25,10 @@ For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/h
## Screenshots
| Light | Dark |
| ----------------------------------------------------------- | ---------------------------------------------------------- |
| ![](https://docs.goauthentik.io/img/screen_apps_light.jpg) | ![](https://docs.goauthentik.io/img/screen_apps_dark.jpg) |
| ![](https://docs.goauthentik.io/img/screen_admin_light.jpg) | ![](https://docs.goauthentik.io/img/screen_admin_dark.jpg) |
| Light | Dark |
| ------------------------------------------------------ | ----------------------------------------------------- |
| ![](https://goauthentik.io/img/screen_apps_light.jpg) | ![](https://goauthentik.io/img/screen_apps_dark.jpg) |
| ![](https://goauthentik.io/img/screen_admin_light.jpg) | ![](https://goauthentik.io/img/screen_admin_dark.jpg) |
## Development

View File

@@ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
(.x being the latest patch release for each version)
| Version | Supported |
| --------- | --------- |
| 2023.10.x | ✅ |
| 2024.2.x | ✅ |
| Version | Supported |
| --- | --- |
| 2023.6.x | ✅ |
| 2023.8.x | ✅ |
## Reporting a Vulnerability
@@ -31,12 +31,12 @@ To report a vulnerability, send an email to [security@goauthentik.io](mailto:se
authentik reserves the right to reclassify CVSS as necessary. To determine severity, we will use the CVSS calculator from NVD (https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator). The calculated CVSS score will then be translated into one of the following categories:
| Score | Severity |
| ---------- | -------- |
| 0.0 | None |
| 0.1 3.9 | Low |
| 4.0 6.9 | Medium |
| 7.0 8.9 | High |
| Score | Severity |
| --- | --- |
| 0.0 | None |
| 0.1 3.9 | Low |
| 4.0 6.9 | Medium |
| 7.0 8.9 | High |
| 9.0 10.0 | Critical |
## Disclosure process

View File

@@ -2,7 +2,7 @@
from os import environ
__version__ = "2024.4.1"
__version__ = "2024.2.2"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

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

View File

@@ -4,7 +4,6 @@ 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
@@ -73,11 +72,6 @@ def auth_user_lookup(raw_header: bytes) -> User | None:
if user:
CTX_AUTH_VIA.set("secret_key")
return user
# then try to auth via expression JWT
user = token_expression_jwt(auth_credentials)
if user:
CTX_AUTH_VIA.set("expression_jwt")
return user
raise AuthenticationFailed("Token invalid/expired")
@@ -95,13 +89,6 @@ def token_secret_key(value: str) -> User | None:
return outpost.user
def token_expression_jwt(value: str) -> User | None:
"""Authenticate API call made by Expressions"""
from authentik.lib.expression.evaluator import authenticate_token
return authenticate_token(value)
class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""
@@ -115,14 +102,3 @@ class TokenAuthentication(BaseAuthentication):
return None
return (user, None) # pragma: no cover
class TokenSchema(OpenApiAuthenticationExtension):
"""Auth schema"""
target_class = TokenAuthentication
name = "authentik"
def get_security_definition(self, auto_schema):
"""Auth schema"""
return {"type": "http", "scheme": "bearer"}

View File

@@ -12,7 +12,6 @@ from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from rest_framework.settings import api_settings
from authentik.api.apps import AuthentikAPIConfig
from authentik.api.pagination import PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA
@@ -102,12 +101,3 @@ def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs):
comp = result["components"]["schemas"][component]
comp["additionalProperties"] = {}
return result
def preprocess_schema_exclude_non_api(endpoints, **kwargs):
"""Filter out all API Views which are not mounted under /api"""
return [
(path, path_regex, method, callback)
for path, path_regex, method, callback in endpoints
if path.startswith("/" + AuthentikAPIConfig.mountpoint)
]

View File

@@ -8,8 +8,6 @@ from django.apps import AppConfig
from django.db import DatabaseError, InternalError, ProgrammingError
from structlog.stdlib import BoundLogger, get_logger
from authentik.root.signals import startup
class ManagedAppConfig(AppConfig):
"""Basic reconciliation logic for apps"""
@@ -25,12 +23,9 @@ class ManagedAppConfig(AppConfig):
def ready(self) -> None:
self.import_related()
startup.connect(self._on_startup_callback, dispatch_uid=self.label)
return super().ready()
def _on_startup_callback(self, sender, **_):
self._reconcile_global()
self._reconcile_tenant()
return super().ready()
def import_related(self):
"""Automatically import related modules which rely on just being imported

View File

@@ -4,14 +4,12 @@ from json import dumps
from typing import Any
from django.core.management.base import BaseCommand, no_translations
from django.db.models import Model, fields
from drf_jsonschema_serializer.convert import converter, field_to_converter
from django.db.models import Model
from drf_jsonschema_serializer.convert import field_to_converter
from rest_framework.fields import Field, JSONField, UUIDField
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
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
@@ -20,23 +18,6 @@ from authentik.lib.models import SerializerModel
LOGGER = get_logger()
@converter
class PrimaryKeyRelatedFieldConverter:
"""Custom primary key field converter which is aware of non-integer based PKs
This is not an exhaustive fix for other non-int PKs, however in authentik we either
use UUIDs or ints"""
field_class = PrimaryKeyRelatedField
def convert(self, field: PrimaryKeyRelatedField):
model: Model = field.queryset.model
pk_field = model._meta.pk
if isinstance(pk_field, fields.UUIDField):
return {"type": "string", "format": "uuid"}
return {"type": "integer"}
class Command(BaseCommand):
"""Generate JSON Schema for blueprints"""
@@ -48,7 +29,7 @@ class Command(BaseCommand):
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object",
"title": f"authentik {__version__} Blueprint schema",
"title": "authentik Blueprint schema",
"required": ["version", "entries"],
"properties": {
"version": {

View File

@@ -39,7 +39,7 @@ def reconcile_app(app_name: str):
def wrapper(*args, **kwargs):
config = apps.get_app_config(app_name)
if isinstance(config, ManagedAppConfig):
config._on_startup_callback(None)
config.ready()
return func(*args, **kwargs)
return wrapper

View File

@@ -556,11 +556,7 @@ class BlueprintDumper(SafeDumper):
def factory(items):
final_dict = dict(items)
# Remove internal state variables
final_dict.pop("_state", None)
# Future-proof to only remove the ID if we don't set a value
if "id" in final_dict and final_dict.get("id") is None:
final_dict.pop("id")
return final_dict
data = asdict(data, dict_factory=factory)

View File

@@ -51,8 +51,6 @@ from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMGroup, SCIMUser
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
@@ -97,9 +95,6 @@ def excluded_models() -> list[type[Model]]:
AccessToken,
RefreshToken,
Reputation,
WebAuthnDeviceType,
SCIMSourceUser,
SCIMSourceGroup,
)

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.0.4 on 2024-04-18 18:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0005_tenantuuid_to_branduuid"),
]
operations = [
migrations.AddIndex(
model_name="brand",
index=models.Index(fields=["domain"], name="authentik_b_domain_b9b24a_idx"),
),
migrations.AddIndex(
model_name="brand",
index=models.Index(fields=["default"], name="authentik_b_default_3ccf12_idx"),
),
]

View File

@@ -84,7 +84,3 @@ class Brand(SerializerModel):
class Meta:
verbose_name = _("Brand")
verbose_name_plural = _("Brands")
indexes = [
models.Index(fields=["domain"]),
models.Index(fields=["default"]),
]

View File

@@ -22,7 +22,6 @@ from rest_framework.viewsets import ModelViewSet
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.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
@@ -44,12 +43,9 @@ from authentik.rbac.filters import ObjectFilter
LOGGER = get_logger()
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
def user_app_cache_key(user_pk: str) -> str:
"""Cache key where application list for user is saved"""
key = f"{CACHE_PREFIX}/app_access/{user_pk}"
if page_number:
key += f"/{page_number}"
return key
return f"{CACHE_PREFIX}/app_access/{user_pk}"
class ApplicationSerializer(ModelSerializer):
@@ -217,8 +213,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
return super().list(request)
queryset = self._filter_queryset_for_list(self.get_queryset())
paginator: Pagination = self.paginator
paginated_apps = paginator.paginate_queryset(queryset, request)
paginated_apps = self.paginate_queryset(queryset)
if "for_user" in request.query_params:
try:
@@ -240,14 +235,12 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
if not should_cache:
allowed_applications = self._get_allowed_applications(paginated_apps)
if should_cache:
allowed_applications = cache.get(
user_app_cache_key(self.request.user.pk, paginator.page.number)
)
allowed_applications = cache.get(user_app_cache_key(self.request.user.pk))
if not allowed_applications:
LOGGER.debug("Caching allowed application list", page=paginator.page.number)
LOGGER.debug("Caching allowed application list")
allowed_applications = self._get_allowed_applications(paginated_apps)
cache.set(
user_app_cache_key(self.request.user.pk, paginator.page.number),
user_app_cache_key(self.request.user.pk),
allowed_applications,
timeout=86400,
)

View File

@@ -5,15 +5,10 @@ from json import loads
from django.http import Http404
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
extend_schema,
extend_schema_field,
)
from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.fields import CharField, IntegerField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
@@ -50,7 +45,9 @@ class GroupSerializer(ModelSerializer):
"""Group Serializer"""
attributes = JSONDictField(required=False)
users_obj = SerializerMethodField(allow_null=True)
users_obj = ListSerializer(
child=GroupMemberSerializer(), read_only=True, source="users", required=False
)
roles_obj = ListSerializer(
child=RoleSerializer(),
read_only=True,
@@ -61,19 +58,6 @@ class GroupSerializer(ModelSerializer):
num_pk = IntegerField(read_only=True)
@property
def _should_include_users(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_users", "true")).lower() == "true"
@extend_schema_field(GroupMemberSerializer(many=True))
def get_users_obj(self, instance: Group) -> list[GroupMemberSerializer] | None:
if not self._should_include_users:
return None
return GroupMemberSerializer(instance.users, many=True).data
def validate_parent(self, parent: Group | None):
"""Validate group parent (if set), ensuring the parent isn't itself"""
if not self.instance or not parent:
@@ -146,35 +130,22 @@ class GroupFilter(FilterSet):
fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"]
class UserAccountSerializer(PassiveSerializer):
"""Account adding/removing operations"""
pk = IntegerField(required=True)
class GroupViewSet(UsedByMixin, ModelViewSet):
"""Group Viewset"""
class UserAccountSerializer(PassiveSerializer):
"""Account adding/removing operations"""
pk = IntegerField(required=True)
queryset = Group.objects.none()
queryset = Group.objects.all().select_related("parent").prefetch_related("users")
serializer_class = GroupSerializer
search_fields = ["name", "is_superuser"]
filterset_class = GroupFilter
ordering = ["name"]
def get_queryset(self):
base_qs = Group.objects.all().select_related("parent").prefetch_related("roles")
if self.serializer_class(context={"request": self.request})._should_include_users:
base_qs = base_qs.prefetch_related("users")
return base_qs
@extend_schema(
parameters=[
OpenApiParameter("include_users", bool, default=True),
]
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@permission_required("authentik_core.add_user_to_group")
@permission_required(None, ["authentik_core.add_user"])
@extend_schema(
request=UserAccountSerializer,
responses={
@@ -182,13 +153,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
404: OpenApiResponse(description="User not found"),
},
)
@action(
detail=True,
methods=["POST"],
pagination_class=None,
filter_backends=[],
permission_classes=[],
)
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
def add_user(self, request: Request, pk: str) -> Response:
"""Add user to group"""
group: Group = self.get_object()
@@ -204,7 +169,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
group.users.add(user)
return Response(status=204)
@permission_required("authentik_core.remove_user_from_group")
@permission_required(None, ["authentik_core.add_user"])
@extend_schema(
request=UserAccountSerializer,
responses={
@@ -212,13 +177,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
404: OpenApiResponse(description="User not found"),
},
)
@action(
detail=True,
methods=["POST"],
pagination_class=None,
filter_backends=[],
permission_classes=[],
)
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
def remove_user(self, request: Request, pk: str) -> Response:
"""Add user to group"""
group: Group = self.get_object()

View File

@@ -2,7 +2,6 @@
from typing import Any
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from guardian.shortcuts import assign_perm, get_anonymous_user
@@ -21,17 +20,9 @@ from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
Token,
TokenIntents,
User,
default_token_duration,
)
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents
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
@@ -58,32 +49,6 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
attrs.setdefault("intent", TokenIntents.INTENT_API)
if attrs.get("intent") not in [TokenIntents.INTENT_API, TokenIntents.INTENT_APP_PASSWORD]:
raise ValidationError({"intent": f"Invalid intent {attrs.get('intent')}"})
if attrs.get("intent") == TokenIntents.INTENT_APP_PASSWORD:
# user IS in attrs
user: User = attrs.get("user")
max_token_lifetime = user.group_attributes(request).get(
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
)
max_token_lifetime_dt = default_token_duration()
if max_token_lifetime is not None:
try:
max_token_lifetime_dt = now() + timedelta_from_string(max_token_lifetime)
except ValueError:
pass
if "expires" in attrs and attrs.get("expires") > max_token_lifetime_dt:
raise ValidationError(
{
"expires": (
f"Token expires exceeds maximum lifetime ({max_token_lifetime_dt} UTC)."
)
}
)
elif attrs.get("intent") == TokenIntents.INTENT_API:
# For API tokens, expires cannot be overridden
attrs["expires"] = default_token_duration()
return attrs
class Meta:

View File

@@ -85,7 +85,7 @@ class UserGroupSerializer(ModelSerializer):
"""Simplified Group Serializer for user's groups"""
attributes = JSONDictField(required=False)
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
parent_name = CharField(source="parent.name", read_only=True)
class Meta:
model = Group
@@ -113,26 +113,13 @@ class UserSerializer(ModelSerializer):
queryset=Group.objects.all().order_by("name"),
default=list,
)
groups_obj = SerializerMethodField(allow_null=True)
groups_obj = ListSerializer(child=UserGroupSerializer(), read_only=True, source="ak_groups")
uid = CharField(read_only=True)
username = CharField(
max_length=150,
validators=[UniqueValidator(queryset=User.objects.all().order_by("username"))],
)
@property
def _should_include_groups(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_groups", "true")).lower() == "true"
@extend_schema_field(UserGroupSerializer(many=True))
def get_groups_obj(self, instance: User) -> list[UserGroupSerializer] | None:
if not self._should_include_groups:
return None
return UserGroupSerializer(instance.ak_groups, many=True).data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
@@ -407,19 +394,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
search_fields = ["username", "name", "is_active", "email", "uuid"]
filterset_class = UsersFilter
def get_queryset(self):
base_qs = User.objects.all().exclude_anonymous()
if self.serializer_class(context={"request": self.request})._should_include_groups:
base_qs = base_qs.prefetch_related("ak_groups")
return base_qs
@extend_schema(
parameters=[
OpenApiParameter("include_groups", bool, default=True),
]
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
def get_queryset(self): # pragma: no cover
return User.objects.all().exclude_anonymous().prefetch_related("ak_groups")
def _create_recovery_link(self) -> tuple[str, Token]:
"""Create a recovery link (when the current brand has a recovery flow set),

View File

@@ -36,7 +36,7 @@ class PropertyMappingEvaluator(BaseEvaluator):
_filename = model.name
else:
_filename = str(model)
super().__init__(None, filename=_filename)
super().__init__(filename=_filename)
req = PolicyRequest(user=User())
req.obj = model
if user:

View File

@@ -1,34 +1,10 @@
"""custom runserver command"""
from typing import TextIO
from daphne.management.commands.runserver import Command as RunServer
from daphne.server import Server
from authentik.root.signals import post_startup, pre_startup, startup
class SignalServer(Server):
"""Server which signals back to authentik when it finished starting up"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def ready_callable():
pre_startup.send(sender=self)
startup.send(sender=self)
post_startup.send(sender=self)
self.ready_callable = ready_callable
class Command(RunServer):
"""custom runserver command, which doesn't show the misleading django startup message"""
server_cls = SignalServer
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Redirect standard stdout banner from Daphne into the void
# as there are a couple more steps that happen before startup is fully done
self.stdout = TextIO()
def on_bind(self, server_port):
pass

View File

@@ -5,7 +5,6 @@ from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import authentik.core.models
from authentik.lib.generators import generate_id
def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
@@ -17,10 +16,6 @@ def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
token.save()
def default_token_key():
return generate_id(60)
class Migration(migrations.Migration):
replaces = [
("authentik_core", "0012_auto_20201003_1737"),
@@ -67,7 +62,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="token",
name="key",
field=models.TextField(default=default_token_key),
field=models.TextField(default=authentik.core.models.default_token_key),
),
migrations.AlterUniqueTogether(
name="token",

View File

@@ -1,31 +0,0 @@
# Generated by Django 5.0.2 on 2024-02-29 10:15
from django.db import migrations, models
import authentik.core.models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0033_alter_user_options"),
("authentik_tenants", "0002_tenant_default_token_duration_and_more"),
]
operations = [
migrations.AlterField(
model_name="authenticatedsession",
name="expires",
field=models.DateTimeField(default=None, null=True),
),
migrations.AlterField(
model_name="token",
name="expires",
field=models.DateTimeField(default=None, null=True),
),
migrations.AlterField(
model_name="token",
name="key",
field=models.TextField(default=authentik.core.models.default_token_key),
),
]

View File

@@ -1,52 +0,0 @@
# Generated by Django 5.0.4 on 2024-04-15 11:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("authentik_core", "0034_alter_authenticatedsession_expires_and_more"),
("authentik_rbac", "0003_alter_systempermission_options"),
]
operations = [
migrations.AlterModelOptions(
name="group",
options={
"permissions": [
("add_user_to_group", "Add user to group"),
("remove_user_from_group", "Remove user from group"),
],
"verbose_name": "Group",
"verbose_name_plural": "Groups",
},
),
migrations.AddIndex(
model_name="group",
index=models.Index(fields=["name"], name="authentik_c_name_9ba8e4_idx"),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["last_login"], name="authentik_c_last_lo_f0179a_idx"),
),
migrations.AddIndex(
model_name="user",
index=models.Index(
fields=["password_change_date"], name="authentik_c_passwor_eec915_idx"
),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["uuid"], name="authentik_c_uuid_3dae2f_idx"),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["path"], name="authentik_c_path_b1f502_idx"),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["type"], name="authentik_c_type_ecf60d_idx"),
),
]

View File

@@ -1,6 +1,6 @@
"""authentik core models"""
from datetime import datetime
from datetime import timedelta
from hashlib import sha256
from typing import Any, Optional, Self
from uuid import uuid4
@@ -25,16 +25,15 @@ from authentik.blueprints.models import ManagedModel
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.lib.avatars import get_avatar
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
from authentik.lib.models import (
CreatedUpdatedModel,
DomainlessFormattedURLValidator,
SerializerModel,
)
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
from authentik.tenants.utils import get_unique_identifier
LOGGER = get_logger()
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
@@ -43,42 +42,33 @@ USER_ATTRIBUTE_EXPIRES = "goauthentik.io/user/expires"
USER_ATTRIBUTE_DELETE_ON_LOGOUT = "goauthentik.io/user/delete-on-logout"
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME = "goauthentik.io/user/token-maximum-lifetime" # nosec
USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username"
USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name"
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
# used_by API that allows models to specify if they shadow an object
# for example the proxy provider which is built on top of an oauth provider
"authentik_used_by_shadows",
# List fields for which changes are not logged (due to them having dedicated objects)
# for example user's password and last_login
"authentik_signals_ignored_fields",
)
def default_token_duration() -> datetime:
def default_token_duration():
"""Default duration a Token is valid"""
current_tenant = get_current_tenant()
token_duration = (
current_tenant.default_token_duration
if hasattr(current_tenant, "default_token_duration")
else DEFAULT_TOKEN_DURATION
)
return now() + timedelta_from_string(token_duration)
return now() + timedelta(minutes=30)
def default_token_key() -> str:
def default_token_key():
"""Default token key"""
current_tenant = get_current_tenant()
token_length = (
current_tenant.default_token_length
if hasattr(current_tenant, "default_token_length")
else DEFAULT_TOKEN_LENGTH
)
# We use generate_id since the chars in the key should be easy
# to use in Emails (for verification) and URLs (for recovery)
return generate_id(token_length)
return generate_id(CONFIG.get_int("default_token_length"))
class UserTypes(models.TextChoices):
@@ -177,13 +167,8 @@ class Group(SerializerModel):
"parent",
),
)
indexes = [models.Index(fields=["name"])]
verbose_name = _("Group")
verbose_name_plural = _("Groups")
permissions = [
("add_user_to_group", _("Add user to group")),
("remove_user_from_group", _("Remove user from group")),
]
class UserQuerySet(models.QuerySet):
@@ -320,12 +305,13 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
("preview_user", _("Can preview user data sent to providers")),
("view_user_applications", _("View applications the user has access to")),
]
indexes = [
models.Index(fields=["last_login"]),
models.Index(fields=["password_change_date"]),
models.Index(fields=["uuid"]),
models.Index(fields=["path"]),
models.Index(fields=["type"]),
authentik_signals_ignored_fields = [
# Logged by the events `password_set`
# the `password_set` action/signal doesn't currently convey which user
# initiated the password change, so for now we'll log two actions
# ("password", "password_change_date"),
# Logged by `login`
("last_login",),
]
@@ -632,7 +618,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
raise NotImplementedError
def __str__(self) -> str:
return f"User-source connection (user={self.user_id}, source={self.source_id})"
return f"User-source connection (user={self.user.username}, source={self.source.slug})"
class Meta:
unique_together = (("user", "source"),)
@@ -641,7 +627,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
class ExpiringModel(models.Model):
"""Base Model which can expire, and is automatically cleaned up."""
expires = models.DateTimeField(default=None, null=True)
expires = models.DateTimeField(default=default_token_duration)
expiring = models.BooleanField(default=True)
class Meta:
@@ -655,7 +641,7 @@ class ExpiringModel(models.Model):
return self.delete(*args, **kwargs)
@classmethod
def filter_not_expired(cls, **kwargs) -> QuerySet["Token"]:
def filter_not_expired(cls, **kwargs) -> QuerySet:
"""Filer for tokens which are not expired yet or are not expiring,
and match filters in `kwargs`"""
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):

View File

@@ -10,14 +10,7 @@ from django.dispatch import receiver
from django.http.request import HttpRequest
from structlog.stdlib import get_logger
from authentik.core.models import (
Application,
AuthenticatedSession,
BackchannelProvider,
ExpiringModel,
User,
default_token_duration,
)
from authentik.core.models import Application, AuthenticatedSession, BackchannelProvider, User
# Arguments: user: User, password: str
password_changed = Signal()
@@ -68,12 +61,3 @@ def backchannel_provider_pre_save(sender: type[Model], instance: Model, **_):
if not isinstance(instance, BackchannelProvider):
return
instance.is_backchannel = True
@receiver(pre_save)
def expiring_model_pre_save(sender: type[Model], instance: Model, **_):
"""Ensure expires is set on ExpiringModels that are set to expire"""
if not issubclass(sender, ExpiringModel):
return
if instance.expiring and instance.expires is None:
instance.expires = default_token_duration()

View File

@@ -1,11 +1,10 @@
"""Test Groups API"""
from django.urls.base import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_admin_user, create_test_user
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
@@ -13,22 +12,13 @@ class TestGroupsAPI(APITestCase):
"""Test Groups API"""
def setUp(self) -> None:
self.login_user = create_test_user()
self.admin = create_test_admin_user()
self.user = User.objects.create(username="test-user")
def test_list_with_users(self):
"""Test listing with users"""
admin = create_test_admin_user()
self.client.force_login(admin)
response = self.client.get(reverse("authentik_api:group-list"), {"include_users": "true"})
self.assertEqual(response.status_code, 200)
def test_add_user(self):
"""Test add_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.add_user_to_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.client.force_login(self.login_user)
self.client.force_login(self.admin)
res = self.client.post(
reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}),
data={
@@ -42,9 +32,7 @@ class TestGroupsAPI(APITestCase):
def test_add_user_404(self):
"""Test add_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.add_user_to_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.client.force_login(self.login_user)
self.client.force_login(self.admin)
res = self.client.post(
reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}),
data={
@@ -56,10 +44,8 @@ class TestGroupsAPI(APITestCase):
def test_remove_user(self):
"""Test remove_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.remove_user_from_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
group.users.add(self.user)
self.client.force_login(self.login_user)
self.client.force_login(self.admin)
res = self.client.post(
reverse("authentik_api:group-remove-user", kwargs={"pk": group.pk}),
data={
@@ -73,10 +59,8 @@ class TestGroupsAPI(APITestCase):
def test_remove_user_404(self):
"""Test remove_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.remove_user_from_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
group.users.add(self.user)
self.client.force_login(self.login_user)
self.client.force_login(self.admin)
res = self.client.post(
reverse("authentik_api:group-remove-user", kwargs={"pk": group.pk}),
data={
@@ -88,12 +72,11 @@ class TestGroupsAPI(APITestCase):
def test_parent_self(self):
"""Test parent"""
group = Group.objects.create(name=generate_id())
assign_perm("view_group", self.login_user, group)
assign_perm("change_group", self.login_user, group)
self.client.force_login(self.login_user)
self.client.force_login(self.admin)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
data={
"pk": self.user.pk + 3,
"parent": group.pk,
},
)

View File

@@ -66,11 +66,14 @@ class TestPropertyMappings(TestCase):
expression="return request.http_request.path",
)
http_request = self.factory.get("/")
tmpl = f"""
res = ak_call_policy('{expr.name}')
tmpl = (
"""
res = ak_call_policy('%s')
result = [request.http_request.path, res.raw_result]
return result
"""
% expr.name
)
evaluator = PropertyMapping(expression=tmpl, name=generate_id())
res = evaluator.evaluate(self.user, http_request)
self.assertEqual(res, ["/", "/"])

View File

@@ -1,6 +1,5 @@
"""Test token API"""
from datetime import datetime, timedelta
from json import loads
from django.urls.base import reverse
@@ -8,13 +7,7 @@ from guardian.shortcuts import get_anonymous_user
from rest_framework.test import APITestCase
from authentik.core.api.tokens import TokenSerializer
from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
Token,
TokenIntents,
User,
)
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
@@ -83,77 +76,6 @@ class TestTokenAPI(APITestCase):
self.assertEqual(token.intent, TokenIntents.INTENT_API)
self.assertEqual(token.expiring, False)
def test_token_create_expiring(self):
"""Test token creation endpoint"""
self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True
self.user.save()
response = self.client.post(
reverse("authentik_api:token-list"), {"identifier": "test-token"}
)
self.assertEqual(response.status_code, 201)
token = Token.objects.get(identifier="test-token")
self.assertEqual(token.user, self.user)
self.assertEqual(token.intent, TokenIntents.INTENT_API)
self.assertEqual(token.expiring, True)
def test_token_create_expiring_custom_ok(self):
"""Test token creation endpoint"""
self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True
self.user.attributes[USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME] = "hours=2"
self.user.save()
expires = datetime.now() + timedelta(hours=1)
response = self.client.post(
reverse("authentik_api:token-list"),
{
"identifier": "test-token",
"expires": expires,
"intent": TokenIntents.INTENT_APP_PASSWORD,
},
)
self.assertEqual(response.status_code, 201)
token = Token.objects.get(identifier="test-token")
self.assertEqual(token.user, self.user)
self.assertEqual(token.intent, TokenIntents.INTENT_APP_PASSWORD)
self.assertEqual(token.expiring, True)
self.assertEqual(token.expires.timestamp(), expires.timestamp())
def test_token_create_expiring_custom_nok(self):
"""Test token creation endpoint"""
self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True
self.user.attributes[USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME] = "hours=2"
self.user.save()
expires = datetime.now() + timedelta(hours=3)
response = self.client.post(
reverse("authentik_api:token-list"),
{
"identifier": "test-token",
"expires": expires,
"intent": TokenIntents.INTENT_APP_PASSWORD,
},
)
self.assertEqual(response.status_code, 400)
def test_token_create_expiring_custom_api(self):
"""Test token creation endpoint"""
self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True
self.user.attributes[USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME] = "hours=2"
self.user.save()
expires = datetime.now() + timedelta(seconds=3)
response = self.client.post(
reverse("authentik_api:token-list"),
{
"identifier": "test-token",
"expires": expires,
"intent": TokenIntents.INTENT_API,
},
)
self.assertEqual(response.status_code, 201)
token = Token.objects.get(identifier="test-token")
self.assertEqual(token.user, self.user)
self.assertEqual(token.intent, TokenIntents.INTENT_API)
self.assertEqual(token.expiring, True)
self.assertNotEqual(token.expires.timestamp(), expires.timestamp())
def test_list(self):
"""Test Token List (Test normal authentication)"""
Token.objects.all().delete()

View File

@@ -41,12 +41,6 @@ class TestUsersAPI(APITestCase):
)
self.assertEqual(response.status_code, 200)
def test_list_with_groups(self):
"""Test listing with groups"""
self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
self.assertEqual(response.status_code, 200)
def test_metrics(self):
"""Test user's metrics"""
self.client.force_login(self.admin)

View File

@@ -8,6 +8,7 @@ from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.config import CONFIG
from authentik.tenants.utils import get_current_tenant
@@ -24,6 +25,7 @@ class TestUsersAvatars(APITestCase):
tenant.avatars = mode
tenant.save()
@CONFIG.patch("avatars", "none")
def test_avatars_none(self):
"""Test avatars none"""
self.set_avatar_mode("none")

View File

@@ -13,9 +13,9 @@ class AuthentikEnterpriseAuditConfig(EnterpriseConfig):
verbose_name = "authentik Enterprise.Audit"
default = True
def ready(self):
@EnterpriseConfig.reconcile_global
def install_middleware(self):
"""Install enterprise audit middleware"""
orig_import = "authentik.events.middleware.AuditMiddleware"
new_import = "authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware"
settings.MIDDLEWARE = [new_import if x == orig_import else x for x in settings.MIDDLEWARE]
return super().ready()

View File

@@ -11,6 +11,7 @@ from django.db.models.expressions import BaseExpression, Combinable
from django.db.models.signals import post_init
from django.http import HttpRequest
from authentik.core.models import User
from authentik.events.middleware import AuditMiddleware, should_log_model
from authentik.events.utils import cleanse_dict, sanitize_item
@@ -27,10 +28,13 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
super().connect(request)
if not self.enabled:
return
user = getattr(request, "user", self.anonymous_user)
if not user.is_authenticated:
user = self.anonymous_user
if not hasattr(request, "request_id"):
return
post_init.connect(
partial(self.post_init_handler, request=request),
partial(self.post_init_handler, user=user, request=request),
dispatch_uid=request.request_id,
weak=False,
)
@@ -72,7 +76,7 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
diff[key] = {"previous_value": value, "new_value": after.get(key)}
return sanitize_item(diff)
def post_init_handler(self, request: HttpRequest, sender, instance: Model, **_):
def post_init_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_):
"""post_init django model handler"""
if not should_log_model(instance):
return
@@ -86,6 +90,7 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
def post_save_handler(
self,
user: User,
request: HttpRequest,
sender,
instance: Model,
@@ -102,4 +107,11 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
new_state = self.serialize_simple(instance)
diff = self.diff(prev_state, new_state)
thread_kwargs["diff"] = diff
return super().post_save_handler(request, sender, instance, created, thread_kwargs, **_)
if not created:
ignored_field_sets = getattr(instance._meta, "authentik_signals_ignored_fields", [])
for field_set in ignored_field_sets:
if set(diff.keys()) == set(field_set):
return None
return super().post_save_handler(
user, request, sender, instance, created, thread_kwargs, **_
)

View File

@@ -1,18 +0,0 @@
from django.apps import apps
from django.conf import settings
from django.test import TestCase
class TestEnterpriseAudit(TestCase):
def test_import(self):
"""Ensure middleware is imported when app.ready is called"""
# Revert import swap
orig_import = "authentik.events.middleware.AuditMiddleware"
new_import = "authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware"
settings.MIDDLEWARE = [orig_import if x == new_import else x for x in settings.MIDDLEWARE]
# Re-call ready()
apps.get_app_config("authentik_enterprise_audit").ready()
self.assertIn(
"authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware", settings.MIDDLEWARE
)

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.2 on 2024-02-29 10:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_rac", "0001_squashed_0003_alter_connectiontoken_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="connectiontoken",
name="expires",
field=models.DateTimeField(default=None, null=True),
),
]

View File

@@ -201,7 +201,10 @@ class ConnectionToken(ExpiringModel):
return settings
def __str__(self):
return f"RAC Connection token {self.session_id} to {self.provider_id}/{self.endpoint_id}"
return (
f"RAC Connection token {self.session.user} to "
f"{self.endpoint.provider.name}/{self.endpoint.name}"
)
class Meta:
verbose_name = _("RAC Connection token")

View File

@@ -1,8 +1,6 @@
"""Events middleware"""
from collections.abc import Callable
from contextlib import contextmanager
from contextvars import ContextVar
from functools import partial
from threading import Thread
from typing import Any
@@ -33,9 +31,6 @@ IGNORED_MODELS = tuple(
)
)
_CTX_OVERWRITE_USER = ContextVar[User | None]("authentik_events_log_overwrite_user", default=None)
_CTX_IGNORE = ContextVar[bool]("authentik_events_log_ignore", default=False)
def should_log_model(model: Model) -> bool:
"""Return true if operation on `model` should be logged"""
@@ -49,28 +44,6 @@ def should_log_m2m(model: Model) -> bool:
return False
@contextmanager
def audit_overwrite_user(user: User):
"""Overwrite user being logged for model AuditMiddleware. Commonly used
for example in flows where a pending user is given, but the request is not authenticated yet"""
_CTX_OVERWRITE_USER.set(user)
try:
yield
finally:
_CTX_OVERWRITE_USER.set(None)
@contextmanager
def audit_ignore():
"""Ignore model operations in the block. Useful for objects which need to be modified
but are not excluded (e.g. WebAuthn devices)"""
_CTX_IGNORE.set(True)
try:
yield
finally:
_CTX_IGNORE.set(False)
class EventNewThread(Thread):
"""Create Event in background thread"""
@@ -110,32 +83,26 @@ class AuditMiddleware:
self.anonymous_user = get_anonymous_user()
def get_user(self, request: HttpRequest) -> User:
user = _CTX_OVERWRITE_USER.get()
if user:
return user
user = getattr(request, "user", self.anonymous_user)
if not user.is_authenticated:
self._ensure_fallback_user()
return self.anonymous_user
return user
def connect(self, request: HttpRequest):
"""Connect signal for automatic logging"""
self._ensure_fallback_user()
user = getattr(request, "user", self.anonymous_user)
if not user.is_authenticated:
user = self.anonymous_user
if not hasattr(request, "request_id"):
return
post_save.connect(
partial(self.post_save_handler, request=request),
partial(self.post_save_handler, user=user, request=request),
dispatch_uid=request.request_id,
weak=False,
)
pre_delete.connect(
partial(self.pre_delete_handler, request=request),
partial(self.pre_delete_handler, user=user, request=request),
dispatch_uid=request.request_id,
weak=False,
)
m2m_changed.connect(
partial(self.m2m_changed_handler, request=request),
partial(self.m2m_changed_handler, user=user, request=request),
dispatch_uid=request.request_id,
weak=False,
)
@@ -180,6 +147,7 @@ class AuditMiddleware:
def post_save_handler(
self,
user: User,
request: HttpRequest,
sender,
instance: Model,
@@ -190,22 +158,16 @@ class AuditMiddleware:
"""Signal handler for all object's post_save"""
if not should_log_model(instance):
return
if _CTX_IGNORE.get():
return
user = self.get_user(request)
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
thread.kwargs.update(thread_kwargs or {})
thread.run()
def pre_delete_handler(self, request: HttpRequest, sender, instance: Model, **_):
def pre_delete_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_):
"""Signal handler for all object's pre_delete"""
if not should_log_model(instance): # pragma: no cover
return
if _CTX_IGNORE.get():
return
user = self.get_user(request)
EventNewThread(
EventAction.MODEL_DELETED,
@@ -214,15 +176,14 @@ class AuditMiddleware:
model=model_to_dict(instance),
).run()
def m2m_changed_handler(self, request: HttpRequest, sender, instance: Model, action: str, **_):
def m2m_changed_handler(
self, user: User, request: HttpRequest, sender, instance: Model, action: str, **_
):
"""Signal handler for all object's m2m_changed"""
if action not in ["pre_add", "pre_remove", "post_clear"]:
return
if not should_log_m2m(instance):
return
if _CTX_IGNORE.get():
return
user = self.get_user(request)
EventNewThread(
EventAction.MODEL_UPDATED,

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.0.2 on 2024-02-29 10:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_events",
"0004_systemtask_squashed_0005_remove_systemtask_finish_timestamp_and_more",
),
]
operations = [
migrations.AlterField(
model_name="systemtask",
name="expires",
field=models.DateTimeField(default=None, null=True),
),
]

View File

@@ -1,39 +0,0 @@
# Generated by Django 5.0.4 on 2024-04-15 16:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0006_alter_systemtask_expires"),
]
operations = [
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["action"], name="authentik_e_action_9a9dd9_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["user"], name="authentik_e_user_1be48d_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["app"], name="authentik_e_app_6a05ce_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["created"], name="authentik_e_created_6f0834_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["client_ip"], name="authentik_e_client__51f4dd_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(
models.F("context__authorized_application"), name="authentik_e_ctx_app__idx"
),
),
]

View File

@@ -305,16 +305,6 @@ class Event(SerializerModel, ExpiringModel):
class Meta:
verbose_name = _("Event")
verbose_name_plural = _("Events")
indexes = [
models.Index(fields=["action"]),
models.Index(fields=["user"]),
models.Index(fields=["app"]),
models.Index(fields=["created"]),
models.Index(fields=["client_ip"]),
models.Index(
models.F("context__authorized_application"), name="authentik_e_ctx_app__idx"
),
]
class TransportMode(models.TextChoices):
@@ -556,7 +546,7 @@ class Notification(SerializerModel):
if len(self.body) > NOTIFICATION_SUMMARY_LENGTH
else self.body
)
return f"Notification for user {self.user_id}: {body_trunc}"
return f"Notification for user {self.user}: {body_trunc}"
class Meta:
verbose_name = _("Notification")

View File

@@ -3,11 +3,9 @@
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Application, Token, TokenIntents
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user
from authentik.events.middleware import audit_ignore, audit_overwrite_user
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
class TestEventsMiddleware(APITestCase):
@@ -17,100 +15,35 @@ class TestEventsMiddleware(APITestCase):
super().setUp()
self.user = create_test_admin_user()
self.client.force_login(self.user)
Event.objects.all().delete()
def test_create(self):
"""Test model creation event"""
uid = generate_id()
self.client.post(
reverse("authentik_api:application-list"),
data={"name": uid, "slug": uid},
data={"name": "test-create", "slug": "test-create"},
)
self.assertTrue(Application.objects.filter(name="test-create").exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED,
context__model__model_name="application",
context__model__app="authentik_core",
context__model__name="test-create",
).exists()
)
self.assertTrue(Application.objects.filter(name=uid).exists())
event = Event.objects.filter(
action=EventAction.MODEL_CREATED,
context__model__model_name="application",
context__model__app="authentik_core",
context__model__name=uid,
).first()
self.assertIsNotNone(event)
def test_delete(self):
"""Test model creation event"""
uid = generate_id()
Application.objects.create(name=uid, slug=uid)
self.client.delete(reverse("authentik_api:application-detail", kwargs={"slug": uid}))
Application.objects.create(name="test-delete", slug="test-delete")
self.client.delete(
reverse("authentik_api:application-detail", kwargs={"slug": "test-delete"})
)
self.assertFalse(Application.objects.filter(name="test").exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_DELETED,
context__model__model_name="application",
context__model__app="authentik_core",
context__model__name=uid,
context__model__name="test-delete",
).exists()
)
def test_audit_ignore(self):
"""Test audit_ignore context manager"""
uid = generate_id()
with audit_ignore():
self.client.post(
reverse("authentik_api:application-list"),
data={"name": uid, "slug": uid},
)
self.assertTrue(Application.objects.filter(name=uid).exists())
self.assertFalse(
Event.objects.filter(
action=EventAction.MODEL_CREATED,
context__model__model_name="application",
context__model__app="authentik_core",
context__model__name=uid,
).exists()
)
def test_audit_overwrite_user(self):
"""Test audit_overwrite_user context manager"""
uid = generate_id()
new_user = create_test_admin_user()
with audit_overwrite_user(new_user):
self.client.post(
reverse("authentik_api:application-list"),
data={"name": uid, "slug": uid},
)
self.assertTrue(Application.objects.filter(name=uid).exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED,
context__model__model_name="application",
context__model__app="authentik_core",
context__model__name=uid,
user__username=new_user.username,
).exists()
)
def test_create_with_api(self):
"""Test model creation event (with API token auth)"""
self.client.logout()
token = Token.objects.create(user=self.user, intent=TokenIntents.INTENT_API, expiring=False)
uid = generate_id()
self.client.post(
reverse("authentik_api:application-list"),
data={"name": uid, "slug": uid},
HTTP_AUTHORIZATION=f"Bearer {token.key}",
)
self.assertTrue(Application.objects.filter(name=uid).exists())
event = Event.objects.filter(
action=EventAction.MODEL_CREATED,
context__model__model_name="application",
context__model__app="authentik_core",
context__model__name=uid,
).first()
self.assertIsNotNone(event)
self.assertEqual(
event.user,
{
"pk": self.user.pk,
"email": self.user.email,
"username": self.user.username,
},
)

View File

@@ -1,35 +0,0 @@
"""authentik event models tests"""
from collections.abc import Callable
from django.db.models import Model
from django.test import TestCase
from authentik.core.models import default_token_key
from authentik.lib.utils.reflection import get_apps
class TestModels(TestCase):
"""Test Models"""
def model_tester_factory(test_model: type[Model]) -> Callable:
"""Test models' __str__ and __repr__"""
def tester(self: TestModels):
allowed = 0
# Token-like objects need to lookup the current tenant to get the default token length
for field in test_model._meta.fields:
if field.default == default_token_key:
allowed += 1
with self.assertNumQueries(allowed):
str(test_model())
with self.assertNumQueries(allowed):
repr(test_model())
return tester
for app in get_apps():
for model in app.get_models():
setattr(TestModels, f"test_{app.label}_{model.__name__}", model_tester_factory(model))

View File

@@ -278,7 +278,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
},
)
@action(detail=True, pagination_class=None, filter_backends=[])
def execute(self, request: Request, slug: str):
def execute(self, request: Request, _slug: str):
"""Execute flow for current user"""
# Because we pre-plan the flow here, and not in the planner, we need to manually clear
# the history of the inspector

View File

@@ -31,9 +31,10 @@ class AuthentikFlowsConfig(ManagedAppConfig):
verbose_name = "authentik Flows"
default = True
def import_related(self):
@ManagedAppConfig.reconcile_global
def load_stages(self):
"""Ensure all stages are loaded"""
from authentik.flows.models import Stage
for stage in all_subclasses(Stage):
_ = stage().view
return super().import_related()

View File

@@ -6,7 +6,6 @@ from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user
from authentik.flows.api.stages import StageSerializer, StageViewSet
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage
from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
from authentik.stages.dummy.models import DummyStage
@@ -102,21 +101,3 @@ class TestFlowsAPI(APITestCase):
reverse("authentik_api:stage-types"),
)
self.assertEqual(response.status_code, 200)
def test_execute(self):
"""Test execute endpoint"""
user = create_test_admin_user()
self.client.force_login(user)
flow = Flow.objects.create(
name=generate_id(),
slug=generate_id(),
designation=FlowDesignation.AUTHENTICATION,
)
FlowStageBinding.objects.create(
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
)
response = self.client.get(
reverse("authentik_api:flow-execute", kwargs={"slug": flow.slug})
)
self.assertEqual(response.status_code, 200)

View File

@@ -11,7 +11,7 @@ from django.http import HttpRequest, HttpResponseNotFound
from django.templatetags.static import static
from lxml import etree # nosec
from lxml.etree import Element, SubElement # nosec
from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout
from requests.exceptions import RequestException
from authentik.lib.config import get_path_from_dict
from authentik.lib.utils.http import get_http_session
@@ -23,8 +23,6 @@ if TYPE_CHECKING:
GRAVATAR_URL = "https://secure.gravatar.com"
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
CACHE_KEY_GRAVATAR = "goauthentik.io/lib/avatars/"
CACHE_KEY_GRAVATAR_AVAILABLE = "goauthentik.io/lib/avatars/gravatar_available"
GRAVATAR_STATUS_TTL_SECONDS = 60 * 60 * 8 # 8 Hours
SVG_XML_NS = "http://www.w3.org/2000/svg"
SVG_NS_MAP = {None: SVG_XML_NS}
@@ -52,9 +50,6 @@ def avatar_mode_attribute(user: "User", mode: str) -> str | None:
def avatar_mode_gravatar(user: "User", mode: str) -> str | None:
"""Gravatar avatars"""
if not cache.get(CACHE_KEY_GRAVATAR_AVAILABLE, True):
return None
# gravatar uses md5 for their URLs, so md5 can't be avoided
mail_hash = md5(user.email.lower().encode("utf-8")).hexdigest() # nosec
parameters = [("size", "158"), ("rating", "g"), ("default", "404")]
@@ -74,8 +69,6 @@ def avatar_mode_gravatar(user: "User", mode: str) -> str | None:
cache.set(full_key, None)
return None
res.raise_for_status()
except (Timeout, ConnectionError, HTTPError):
cache.set(CACHE_KEY_GRAVATAR_AVAILABLE, False, timeout=GRAVATAR_STATUS_TTL_SECONDS)
except RequestException:
return gravatar_url
cache.set(full_key, gravatar_url)

View File

@@ -14,7 +14,7 @@ from pathlib import Path
from sys import argv, stderr
from time import time
from typing import Any
from urllib.parse import quote_plus, urlparse
from urllib.parse import urlparse
import yaml
from django.conf import ImproperlyConfigured
@@ -331,26 +331,6 @@ class ConfigLoader:
CONFIG = ConfigLoader()
def redis_url(db: int) -> str:
"""Helper to create a Redis URL for a specific database"""
_redis_protocol_prefix = "redis://"
_redis_tls_requirements = ""
if CONFIG.get_bool("redis.tls", False):
_redis_protocol_prefix = "rediss://"
_redis_tls_requirements = f"?ssl_cert_reqs={CONFIG.get('redis.tls_reqs')}"
if _redis_ca := CONFIG.get("redis.tls_ca_cert", None):
_redis_tls_requirements += f"&ssl_ca_certs={_redis_ca}"
_redis_url = (
f"{_redis_protocol_prefix}"
f"{quote_plus(CONFIG.get('redis.username'))}:"
f"{quote_plus(CONFIG.get('redis.password'))}@"
f"{quote_plus(CONFIG.get('redis.host'))}:"
f"{CONFIG.get_int('redis.port')}"
f"/{db}{_redis_tls_requirements}"
)
return _redis_url
if __name__ == "__main__":
if len(argv) < 2: # noqa: PLR2004
print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder))

View File

@@ -35,7 +35,6 @@ redis:
password: ""
tls: false
tls_reqs: "none"
tls_ca_cert: null
# broker:
# url: ""
@@ -59,8 +58,6 @@ remote_debug: false
log_level: info
session_storage: cache
error_reporting:
enabled: false
sentry_dsn: https://151ba72610234c4c97c5bcff4e1cffd8@authentik.error-reporting.a7k.io/4504163677503489
@@ -95,9 +92,6 @@ outposts:
discover: true
disable_embedded_outpost: false
expressions:
global_runtime: python # or python_restricted
ldap:
task_timeout_hours: 2
page_size: 50
@@ -116,6 +110,7 @@ events:
asn: "/geoip/GeoLite2-ASN.mmdb"
cert_discovery_dir: /certs
default_token_length: 60
tenants:
enabled: false

View File

@@ -3,117 +3,27 @@
import re
import socket
from collections.abc import Iterable
from datetime import timedelta
from functools import lru_cache
from ipaddress import ip_address, ip_network
from pathlib import Path
from tempfile import gettempdir
from textwrap import indent
from typing import Any
from authentik_client.api.admin_api import AdminApi
from authentik_client.api.authenticators_api import AuthenticatorsApi
from authentik_client.api.core_api import CoreApi
from authentik_client.api.crypto_api import CryptoApi
from authentik_client.api.enterprise_api import EnterpriseApi
from authentik_client.api.events_api import EventsApi
from authentik_client.api.flows_api import FlowsApi
from authentik_client.api.managed_api import ManagedApi
from authentik_client.api.oauth2_api import Oauth2Api
from authentik_client.api.outposts_api import OutpostsApi
from authentik_client.api.policies_api import PoliciesApi
from authentik_client.api.propertymappings_api import PropertymappingsApi
from authentik_client.api.providers_api import ProvidersApi
from authentik_client.api.rac_api import RacApi
from authentik_client.api.rbac_api import RbacApi
from authentik_client.api.root_api import RootApi
from authentik_client.api.schema_api import SchemaApi
from authentik_client.api.sources_api import SourcesApi
from authentik_client.api.stages_api import StagesApi
from authentik_client.api.tenants_api import TenantsApi
from authentik_client.api_client import ApiClient
from authentik_client.configuration import Configuration
from cachetools import TLRUCache, cached
from django.conf import settings
from django.core.exceptions import FieldError
from django.utils.timezone import now
from guardian.shortcuts import get_anonymous_user
from jwt import PyJWTError, decode, encode
from rest_framework.serializers import ValidationError
from RestrictedPython import compile_restricted, limited_builtins, safe_builtins, utility_builtins
from sentry_sdk.hub import Hub
from sentry_sdk.tracing import Span
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.events.models import Event
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_key
from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.http import authentik_user_agent, get_http_session
from authentik.lib.utils.reflection import get_apps
from authentik.lib.utils.http import get_http_session
from authentik.policies.models import Policy, PolicyBinding
from authentik.policies.process import PolicyProcess
from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.stages.authenticator import devices_for_user
LOGGER = get_logger()
_tmp = Path(gettempdir())
token_path = _tmp / "authentik-evaluator-token"
API_CLIENTS = {
"AdminApi": AdminApi,
"AuthenticatorsApi": AuthenticatorsApi,
"CoreApi": CoreApi,
"CryptoApi": CryptoApi,
"EnterpriseApi": EnterpriseApi,
"EventsApi": EventsApi,
"FlowsApi": FlowsApi,
"ManagedApi": ManagedApi,
"Oauth2Api": Oauth2Api,
"OutpostsApi": OutpostsApi,
"PoliciesApi": PoliciesApi,
"PropertymappingsApi": PropertymappingsApi,
"ProvidersApi": ProvidersApi,
"RacApi": RacApi,
"RbacApi": RbacApi,
"RootApi": RootApi,
"SchemaApi": SchemaApi,
"SourcesApi": SourcesApi,
"StagesApi": StagesApi,
"TenantsApi": TenantsApi,
}
JWT_AUD = "goauthentik.io/api/expression"
_SAFE_MODULES = frozenset(("authentik_client",))
def _safe_import(name, *args, **kwargs):
if name not in _SAFE_MODULES:
raise Exception(f"Don't you even think about {name!r}")
return __import__(name, *args, **kwargs)
@lru_cache
def get_api_token_secret():
if token_path.exists():
with open(token_path) as _token_file:
return _token_file.read()
key = generate_key()
with open(_tmp / "authentik-evaluator-token", "w") as _token_file:
_token_file.write(key)
return key
def authenticate_token(raw_value: str):
"""Authenticate API call from evaluator token"""
try:
jwt = decode(raw_value, get_api_token_secret(), ["HS256"], audience=JWT_AUD)
return User.objects.filter(pk=jwt["sub"]).first()
except PyJWTError as exc:
LOGGER.debug("failed to auth", exc=exc)
return None
class BaseEvaluator:
@@ -127,14 +37,8 @@ class BaseEvaluator:
# Filename used for exec
_filename: str
_user: User
# Timeout in seconds, used for the expiration of the API key
timeout = 30
def __init__(self, user: User, filename: str | None = None):
def __init__(self, filename: str | None = None):
self._filename = filename if filename else "BaseEvaluator"
self._user = user
# update website/docs/expressions/_objects.md
# update website/docs/expressions/_functions.md
self._globals = {
@@ -153,44 +57,8 @@ class BaseEvaluator:
"resolve_dns": BaseEvaluator.expr_resolve_dns,
"reverse_dns": BaseEvaluator.expr_reverse_dns,
}
for app in get_apps():
# Load models from each app
for model in app.get_models():
self._globals[model.__name__] = model
self._globals.update(API_CLIENTS)
self._context = {}
def get_token(self) -> str:
"""Generate API token to be used by the API Client"""
_now = now()
if not self._user:
self._user = get_anonymous_user()
return encode(
{
"aud": JWT_AUD,
"iss": f"goauthentik.io/expression/{self._filename}",
"sub": str(self._user.pk),
"iat": int(_now.timestamp()),
"exp": int((_now + timedelta(seconds=self.timeout)).timestamp()),
},
get_api_token_secret(),
)
def get_api_client(self):
token = self.get_token()
config = Configuration(
f"unix://{str(_tmp.joinpath('authentik-core.sock'))}/api/v3",
api_key={
"authentik": token,
},
api_key_prefix={"authentik": "Bearer"},
)
if settings.DEBUG:
config.host = "http://localhost:8000/api/v3"
client = ApiClient(config)
client.user_agent = authentik_user_agent()
return client
@cached(cache=TLRUCache(maxsize=32, ttu=lambda key, value, now: now + 180))
@staticmethod
def expr_resolve_dns(host: str, ip_version: int | None = None) -> list[str]:
@@ -317,16 +185,7 @@ class BaseEvaluator:
def compile(self, expression: str) -> Any:
"""Parse expression. Raises SyntaxError or ValueError if the syntax is incorrect."""
param_keys = self._context.keys()
compiler = (
compile_restricted
if CONFIG.get("expressions.global_runtime") == "python_restricted"
else compile
)
return compiler(
self.wrap_expression(expression, param_keys),
self._filename,
"exec",
)
return compile(self.wrap_expression(expression, param_keys), self._filename, "exec")
def evaluate(self, expression_source: str) -> Any:
"""Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised.
@@ -342,17 +201,7 @@ class BaseEvaluator:
self.handle_error(exc, expression_source)
raise exc
try:
if CONFIG.get("expressions.global_runtime") == "python_restricted":
self._globals["__builtins__"] = {
**safe_builtins,
**limited_builtins,
**utility_builtins,
"__import__": _safe_import,
}
_locals = self._context
# We need to create the API Client later so that the token is valid
# from when the execution starts
self._globals["api"] = self.get_api_client()
# Yes this is an exec, yes it is potentially bad. Since we limit what variables are
# available here, and these policies can only be edited by admins, this is a risk
# we're willing to take.
@@ -360,7 +209,6 @@ class BaseEvaluator:
exec(ast_obj, self._globals, _locals) # nosec # noqa
result = _locals["result"]
except Exception as exc:
print(exception_to_string(exc))
# So, this is a bit questionable. Essentially, we are edit the stacktrace
# so the user only sees information relevant to them
# and none of our surrounding error handling

View File

@@ -1,7 +1,6 @@
"""Test Evaluator base functions"""
from django.test import TestCase
from guardian.shortcuts import get_anonymous_user
from authentik.core.tests.utils import create_test_admin_user
from authentik.events.models import Event
@@ -34,7 +33,7 @@ class TestEvaluator(TestCase):
def test_expr_event_create(self):
"""Test expr_event_create"""
evaluator = BaseEvaluator(get_anonymous_user(), generate_id())
evaluator = BaseEvaluator(generate_id())
evaluator._context = {
"foo": "bar",
}

View File

@@ -2,11 +2,11 @@
from uuid import uuid4
from django.conf import settings
from requests.sessions import PreparedRequest, Session
from structlog.stdlib import get_logger
from authentik import get_full_version
from authentik.lib.config import CONFIG
LOGGER = get_logger()
@@ -35,6 +35,6 @@ class DebugSession(Session):
def get_http_session() -> Session:
"""Get a requests session with common headers"""
session = DebugSession() if CONFIG.get_bool("debug") else Session()
session = DebugSession() if settings.DEBUG else Session()
session.headers["User-Agent"] = authentik_user_agent()
return session

View File

@@ -3,14 +3,12 @@
import os
from importlib import import_module
from pathlib import Path
from tempfile import gettempdir
from django.conf import settings
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
from authentik.lib.config import CONFIG
SERVICE_HOST_ENV_NAME = "KUBERNETES_SERVICE_HOST"
def all_subclasses(cls, sort=True):
"""Recursively return all subclassess of cls"""
@@ -57,7 +55,7 @@ def get_env() -> str:
return "dev"
if SERVICE_HOST_ENV_NAME in os.environ:
return "kubernetes"
if (Path(gettempdir()) / "authentik-mode").exists():
if Path("/tmp/authentik-mode").exists(): # nosec
return "compose"
if "AK_APPLIANCE" in os.environ:
return os.environ["AK_APPLIANCE"]

View File

@@ -45,14 +45,14 @@ class AuthentikOutpostConfig(ManagedAppConfig):
outpost.managed = MANAGED_OUTPOST
outpost.save()
return
outpost, created = Outpost.objects.update_or_create(
outpost, updated = Outpost.objects.update_or_create(
defaults={
"type": OutpostType.PROXY,
"name": MANAGED_OUTPOST_NAME,
},
managed=MANAGED_OUTPOST,
)
if created:
if updated:
if KubernetesServiceConnection.objects.exists():
outpost.service_connection = KubernetesServiceConnection.objects.first()
elif DockerServiceConnection.objects.exists():

View File

@@ -39,7 +39,6 @@ class Migration(migrations.Migration):
("authentik.sources.oauth", "authentik Sources.OAuth"),
("authentik.sources.plex", "authentik Sources.Plex"),
("authentik.sources.saml", "authentik Sources.SAML"),
("authentik.sources.scim", "authentik Sources.SCIM"),
("authentik.stages.authenticator_duo", "authentik Stages.Authenticator.Duo"),
("authentik.stages.authenticator_sms", "authentik Stages.Authenticator.SMS"),
(

View File

@@ -14,13 +14,12 @@ class ExpressionPolicySerializer(PolicySerializer):
def validate_expression(self, expr: str) -> str:
"""validate the syntax of the expression"""
name = "temp-policy" if not self.instance else self.instance.name
request = self.context.get("request")
PolicyEvaluator(request.user if request else None, name).validate(expr)
PolicyEvaluator(name).validate(expr)
return expr
class Meta:
model = ExpressionPolicy
fields = PolicySerializer.Meta.fields + ["expression", "execution_user"]
fields = PolicySerializer.Meta.fields + ["expression"]
class ExpressionPolicyViewSet(UsedByMixin, ModelViewSet):

View File

@@ -1,12 +1,11 @@
"""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"""
name = "authentik.policies.expression"
label = "authentik_policies_expression"
verbose_name = "authentik Policies.Expression"
default = True

View File

@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Optional
from django.http import HttpRequest
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.flows.planner import PLAN_CONTEXT_SSO
from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.policies.exceptions import PolicyException
@@ -25,8 +24,8 @@ class PolicyEvaluator(BaseEvaluator):
policy: Optional["ExpressionPolicy"] = None
def __init__(self, user: User, policy_name: str | None = None):
super().__init__(user, policy_name or "PolicyEvaluator")
def __init__(self, policy_name: str | None = None):
super().__init__(policy_name or "PolicyEvaluator")
self._messages = []
# update website/docs/expressions/_objects.md
# update website/docs/expressions/_functions.md
@@ -45,8 +44,6 @@ class PolicyEvaluator(BaseEvaluator):
if request.http_request:
self.set_http_request(request.http_request)
self._context["request"] = request
if not self._user:
self._user = request.user
self._context["context"] = request.context
def set_http_request(self, request: HttpRequest):

View File

@@ -1,26 +0,0 @@
# Generated by Django 5.0.3 on 2024-03-20 12:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_expression", "0004_expressionpolicy_authentik_p_policy__fb6feb_idx"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="expressionpolicy",
name="execution_user",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -12,9 +12,6 @@ from authentik.policies.types import PolicyRequest, PolicyResult
class ExpressionPolicy(Policy):
"""Execute arbitrary Python code to implement custom checks and validation."""
execution_user = models.ForeignKey(
"authentik_core.User", default=None, null=True, on_delete=models.SET_DEFAULT
)
expression = models.TextField()
@property
@@ -29,11 +26,17 @@ class ExpressionPolicy(Policy):
def passes(self, request: PolicyRequest) -> PolicyResult:
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
evaluator = PolicyEvaluator(self.execution_user, self.name)
evaluator = PolicyEvaluator(self.name)
evaluator.policy = self
evaluator.set_policy_request(request)
return evaluator.evaluate(self.expression)
def save(self, *args, **kwargs):
evaluator = PolicyEvaluator(self.name)
evaluator.policy = self
evaluator.validate(self.expression)
return super().save(*args, **kwargs)
class Meta(Policy.PolicyMeta):
verbose_name = _("Expression Policy")
verbose_name_plural = _("Expression Policies")

View File

@@ -1,13 +0,0 @@
from django.db.models.signals import pre_save
from django.dispatch import receiver
from authentik.policies.expression.evaluator import PolicyEvaluator
from authentik.policies.expression.models import ExpressionPolicy
@receiver(pre_save, sender=ExpressionPolicy)
def pre_save_expression_policy(sender: type[ExpressionPolicy], instance: ExpressionPolicy, **_):
"""Ensure policy is valid before saving"""
evaluator = PolicyEvaluator(instance.execution_user, instance.name)
evaluator.policy = instance
evaluator.validate(instance.expression)

View File

@@ -41,14 +41,14 @@ class TestEvaluator(TestCase):
def test_valid(self):
"""test simple value expression"""
template = "return True"
evaluator = PolicyEvaluator(self.request.user, "test")
evaluator = PolicyEvaluator("test")
evaluator.set_policy_request(self.request)
self.assertEqual(evaluator.evaluate(template).passing, True)
def test_messages(self):
"""test expression with message return"""
template = 'ak_message("some message");return False'
evaluator = PolicyEvaluator(self.request.user, "test")
evaluator = PolicyEvaluator("test")
evaluator.set_policy_request(self.request)
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
@@ -57,7 +57,7 @@ class TestEvaluator(TestCase):
def test_invalid_syntax(self):
"""test invalid syntax"""
template = ";"
evaluator = PolicyEvaluator(self.request.user, "test")
evaluator = PolicyEvaluator("test")
evaluator.set_policy_request(self.request)
with self.assertRaises(PolicyException):
evaluator.evaluate(template)
@@ -65,14 +65,14 @@ class TestEvaluator(TestCase):
def test_validate(self):
"""test validate"""
template = "True"
evaluator = PolicyEvaluator(self.request.user, "test")
evaluator = PolicyEvaluator("test")
result = evaluator.validate(template)
self.assertEqual(result, True)
def test_validate_invalid(self):
"""test validate"""
template = ";"
evaluator = PolicyEvaluator(self.request.user, "test")
evaluator = PolicyEvaluator("test")
with self.assertRaises(ValidationError):
evaluator.validate(template)
@@ -83,7 +83,7 @@ class TestEvaluator(TestCase):
execution_logging=True,
expression="ak_message(request.http_request.path)\nreturn True",
)
evaluator = PolicyEvaluator(self.request.user, "test")
evaluator = PolicyEvaluator("test")
evaluator.set_policy_request(self.request)
proc = PolicyProcess(PolicyBinding(policy=expr), request=self.request, connection=None)
res = proc.profiling_wrapper()
@@ -96,13 +96,16 @@ class TestEvaluator(TestCase):
execution_logging=True,
expression="ak_message(request.http_request.path)\nreturn True",
)
tmpl = f"""
tmpl = (
"""
ak_message(request.http_request.path)
res = ak_call_policy('{expr.name}')
res = ak_call_policy('%s')
ak_message(request.http_request.path)
for msg in res.messages:
ak_message(msg)
"""
% expr.name
)
evaluator = PolicyEvaluator("test")
evaluator.set_policy_request(self.request)
res = evaluator.evaluate(tmpl)

View File

@@ -8,7 +8,6 @@ from django.http import HttpRequest
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from authentik.core.models import default_token_duration
from authentik.events.signals import get_login_event
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.constants import (
@@ -88,9 +87,7 @@ class IDToken:
) -> "IDToken":
"""Create ID Token"""
id_token = IDToken(provider, token, **kwargs)
id_token.exp = int(
(token.expires if token.expires is not None else default_token_duration()).timestamp()
)
id_token.exp = int(token.expires.timestamp())
id_token.iss = provider.get_issuer(request)
id_token.aud = provider.client_id
id_token.claims = {}

View File

@@ -1,36 +0,0 @@
# Generated by Django 5.0.2 on 2024-02-29 10:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_providers_oauth2",
"0017_accesstoken_session_id_authorizationcode_session_id_and_more",
),
]
operations = [
migrations.AlterField(
model_name="accesstoken",
name="expires",
field=models.DateTimeField(default=None, null=True),
),
migrations.AlterField(
model_name="authorizationcode",
name="expires",
field=models.DateTimeField(default=None, null=True),
),
migrations.AlterField(
model_name="devicetoken",
name="expires",
field=models.DateTimeField(default=None, null=True),
),
migrations.AlterField(
model_name="refreshtoken",
name="expires",
field=models.DateTimeField(default=None, null=True),
),
]

View File

@@ -326,7 +326,7 @@ class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel):
verbose_name_plural = _("Authorization Codes")
def __str__(self):
return f"Authorization code for {self.provider_id} for user {self.user_id}"
return f"Authorization code for {self.provider} for user {self.user}"
@property
def serializer(self) -> Serializer:
@@ -356,7 +356,7 @@ class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
verbose_name_plural = _("OAuth2 Access Tokens")
def __str__(self):
return f"Access Token for {self.provider_id} for user {self.user_id}"
return f"Access Token for {self.provider} for user {self.user}"
@property
def id_token(self) -> IDToken:
@@ -399,7 +399,7 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
verbose_name_plural = _("OAuth2 Refresh Tokens")
def __str__(self):
return f"Refresh Token for {self.provider_id} for user {self.user_id}"
return f"Refresh Token for {self.provider} for user {self.user}"
@property
def id_token(self) -> IDToken:
@@ -443,4 +443,4 @@ class DeviceToken(ExpiringModel):
verbose_name_plural = _("Device Tokens")
def __str__(self):
return f"Device Token for {self.provider_id}"
return f"Device Token for {self.provider}"

View File

@@ -208,7 +208,7 @@ class TestToken(OAuthTestCase):
"token_type": TOKEN_TYPE,
"expires_in": 3600,
"id_token": provider.encode(
access.id_token.to_dict(),
refresh.id_token.to_dict(),
),
},
)
@@ -267,7 +267,7 @@ class TestToken(OAuthTestCase):
"token_type": TOKEN_TYPE,
"expires_in": 3600,
"id_token": provider.encode(
access.id_token.to_dict(),
refresh.id_token.to_dict(),
),
},
)

View File

@@ -133,7 +133,7 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
"""Validate code and save the returned http response"""
response = validate_code(code, self.stage.request)
if not response:
raise ValidationError(_("Invalid code"), "invalid")
raise ValidationError("Invalid code", "invalid")
return response

View File

@@ -31,7 +31,6 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.events.middleware import audit_ignore
from authentik.events.models import Event, EventAction
from authentik.events.signals import get_login_event
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION
@@ -466,25 +465,22 @@ class TokenParams:
def __create_user_from_jwt(self, token: dict[str, Any], app: Application, source: OAuthSource):
"""Create user from JWT"""
with audit_ignore():
self.user, created = User.objects.update_or_create(
username=f"{self.provider.name}-{token.get('sub')}",
defaults={
"attributes": {
USER_ATTRIBUTE_GENERATED: True,
},
"last_login": timezone.now(),
"name": (
f"Autogenerated user from application {app.name} (client credentials JWT)"
),
"path": source.get_user_path(),
"type": UserTypes.SERVICE_ACCOUNT,
self.user, created = User.objects.update_or_create(
username=f"{self.provider.name}-{token.get('sub')}",
defaults={
"attributes": {
USER_ATTRIBUTE_GENERATED: True,
},
)
exp = token.get("exp")
if created and exp:
self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp
self.user.save()
"last_login": timezone.now(),
"name": f"Autogenerated user from application {app.name} (client credentials JWT)",
"path": source.get_user_path(),
"type": UserTypes.SERVICE_ACCOUNT,
},
)
exp = token.get("exp")
if created and exp:
self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp
self.user.save()
@method_decorator(csrf_exempt, name="dispatch")
@@ -651,7 +647,7 @@ class TokenView(View):
"expires_in": int(
timedelta_from_string(self.provider.access_token_validity).total_seconds()
),
"id_token": access_token.id_token.to_jwt(self.provider),
"id_token": id_token.to_jwt(self.provider),
}
def create_client_credentials_response(self) -> dict[str, Any]:

View File

@@ -13,21 +13,15 @@ from pydanticscim.user import User as BaseUser
class User(BaseUser):
"""Modified User schema with added externalId field"""
schemas: list[str] = [
"urn:ietf:params:scim:schemas:core:2.0:User",
]
schemas: tuple[str] = ("urn:ietf:params:scim:schemas:core:2.0:User",)
externalId: str | None = None
meta: dict | None = None
class Group(BaseGroup):
"""Modified Group schema with added externalId field"""
schemas: list[str] = [
"urn:ietf:params:scim:schemas:core:2.0:Group",
]
schemas: tuple[str] = ("urn:ietf:params:scim:schemas:core:2.0:Group",)
externalId: str | None = None
meta: dict | None = None
class ServiceProviderConfiguration(BaseServiceProviderConfiguration):

View File

@@ -105,7 +105,7 @@ class SCIMUser(models.Model):
unique_together = (("id", "user", "provider"),)
def __str__(self) -> str:
return f"SCIM User {self.user_id} to {self.provider_id}"
return f"SCIM User {self.user.username} to {self.provider.name}"
class SCIMGroup(models.Model):
@@ -119,4 +119,4 @@ class SCIMGroup(models.Model):
unique_together = (("id", "group", "provider"),)
def __str__(self) -> str:
return f"SCIM Group {self.group_id} to {self.provider_id}"
return f"SCIM Group {self.group.name} to {self.provider.name}"

View File

@@ -8,16 +8,7 @@ from rest_framework.request import Request
class ObjectPermissions(DjangoObjectPermissions):
"""RBAC Permissions"""
def has_permission(self, request: Request, view) -> bool:
"""Always grant permission for object-specific requests
as view permission checking is done by `ObjectFilter`,
and write permission checking is done by `has_object_permission`"""
lookup = getattr(view, "lookup_url_kwarg", None) or getattr(view, "lookup_field", None)
if lookup and lookup in view.kwargs:
return True
return super().has_permission(request, view)
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
def has_object_permission(self, request: Request, view, obj: Model):
queryset = self._queryset(view)
model_cls = queryset.model
perms = self.get_required_object_permissions(request.method, model_cls)

View File

@@ -4,7 +4,7 @@ from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Group, User, UserTypes
from authentik.core.models import Group, UserTypes
from authentik.core.tests.utils import create_test_admin_user, create_test_user
from authentik.lib.generators import generate_id
from authentik.rbac.api.rbac_assigned_by_users import UserAssignedObjectPermissionSerializer
@@ -26,7 +26,6 @@ class TestRBACUserAPI(APITestCase):
def test_filter_assigned(self):
"""Test UserAssignedPermissionViewSet's filters"""
User.objects.filter(username="akadmin").delete()
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,

View File

@@ -121,29 +121,3 @@ class TestAPIPerms(APITestCase):
},
)
self.assertEqual(res.status_code, 403)
def test_update_simple(self):
"""Test update with permission"""
self.client.force_login(self.user)
inv = Invitation.objects.create(name=generate_id(), created_by=self.superuser)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv)
self.role.assign_permission("authentik_stages_invitation.change_invitation", obj=inv)
res = self.client.patch(
reverse("authentik_api:invitation-detail", kwargs={"pk": inv.pk}),
data={
"name": generate_id(),
},
)
self.assertEqual(res.status_code, 200)
def test_update_simple_denied(self):
"""Test update without assigning permission"""
self.client.force_login(self.user)
inv = Invitation.objects.create(name=generate_id(), created_by=self.superuser)
res = self.client.patch(
reverse("authentik_api:invitation-detail", kwargs={"pk": inv.pk}),
data={
"name": generate_id(),
},
)
self.assertEqual(res.status_code, 403)

View File

@@ -60,18 +60,7 @@ class RouteNotFoundMiddleware:
raise exc
class AuthentikAsgi(SentryAsgiMiddleware):
"""Root ASGI App wrapper"""
def call_startup(self):
from authentik.root.signals import post_startup, pre_startup, startup
pre_startup.send(sender=self)
startup.send(sender=self)
post_startup.send(sender=self)
application = AuthentikAsgi(
application = SentryAsgiMiddleware(
ProtocolTypeRouter(
{
"http": get_asgi_application(),

View File

@@ -5,13 +5,13 @@ import os
from collections import OrderedDict
from hashlib import sha512
from pathlib import Path
from urllib.parse import quote_plus
from celery.schedules import crontab
from django.conf import ImproperlyConfigured
from sentry_sdk import set_tag
from authentik import ENV_GIT_HASH_KEY, __version__
from authentik.lib.config import CONFIG, redis_url
from authentik.lib.config import CONFIG
from authentik.lib.logging import get_logger_config, structlog_configure
from authentik.lib.sentry import sentry_init
from authentik.lib.utils.reflection import get_env
@@ -90,7 +90,6 @@ TENANT_APPS = [
"authentik.sources.oauth",
"authentik.sources.plex",
"authentik.sources.saml",
"authentik.sources.scim",
"authentik.stages.authenticator",
"authentik.stages.authenticator_duo",
"authentik.stages.authenticator_sms",
@@ -157,10 +156,6 @@ SPECTACULAR_SETTINGS = {
"UserTypeEnum": "authentik.core.models.UserTypes",
},
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
"ENUM_GENERATE_CHOICE_DESCRIPTION": False,
"PREPROCESSING_HOOKS": [
"authentik.api.schema.preprocess_schema_exclude_non_api",
],
"POSTPROCESSING_HOOKS": [
"authentik.api.schema.postprocess_schema_responses",
"drf_spectacular.hooks.postprocess_schema_enums",
@@ -195,15 +190,25 @@ REST_FRAMEWORK = {
},
}
_redis_protocol_prefix = "redis://"
_redis_celery_tls_requirements = ""
if CONFIG.get_bool("redis.tls", False):
_redis_protocol_prefix = "rediss://"
_redis_celery_tls_requirements = f"?ssl_cert_reqs={CONFIG.get('redis.tls_reqs')}"
_redis_url = (
f"{_redis_protocol_prefix}"
f"{quote_plus(CONFIG.get('redis.username'))}:"
f"{quote_plus(CONFIG.get('redis.password'))}@"
f"{quote_plus(CONFIG.get('redis.host'))}:"
f"{CONFIG.get_int('redis.port')}"
)
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": CONFIG.get("cache.url") or redis_url(CONFIG.get("redis.db")),
"LOCATION": CONFIG.get("cache.url") or f"{_redis_url}/{CONFIG.get('redis.db')}",
"TIMEOUT": CONFIG.get_int("cache.timeout", 300),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
"KEY_PREFIX": "authentik_cache",
"KEY_FUNCTION": "django_tenants.cache.make_key",
"REVERSE_KEY_FUNCTION": "django_tenants.cache.reverse_key",
@@ -212,15 +217,7 @@ CACHES = {
DJANGO_REDIS_SCAN_ITERSIZE = 1000
DJANGO_REDIS_IGNORE_EXCEPTIONS = True
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
match CONFIG.get("session_storage", "cache"):
case "cache":
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
case "db":
SESSION_ENGINE = "django.contrib.sessions.backends.db"
case _:
raise ImproperlyConfigured(
"Invalid session_storage setting, allowed values are db and cache"
)
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_SERIALIZER = "authentik.root.sessions.pickle.PickleSerializer"
SESSION_CACHE_ALIAS = "default"
# Configured via custom SessionMiddleware
@@ -274,7 +271,7 @@ CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer",
"CONFIG": {
"hosts": [CONFIG.get("channel.url") or redis_url(CONFIG.get("redis.db"))],
"hosts": [CONFIG.get("channel.url", f"{_redis_url}/{CONFIG.get('redis.db')}")],
"prefix": "authentik_channels_",
},
},
@@ -374,9 +371,11 @@ CELERY = {
"beat_scheduler": "authentik.tenants.scheduler:TenantAwarePersistentScheduler",
"task_create_missing_queues": True,
"task_default_queue": "authentik",
"broker_url": CONFIG.get("broker.url") or redis_url(CONFIG.get("redis.db")),
"result_backend": CONFIG.get("result_backend.url") or redis_url(CONFIG.get("redis.db")),
"broker_url": CONFIG.get("broker.url")
or f"{_redis_url}/{CONFIG.get('redis.db')}{_redis_celery_tls_requirements}",
"broker_transport_options": CONFIG.get_dict_from_b64_json("broker.transport_options"),
"result_backend": CONFIG.get("result_backend.url")
or f"{_redis_url}/{CONFIG.get('redis.db')}{_redis_celery_tls_requirements}",
}
# Sentry integration

View File

@@ -1,26 +0,0 @@
from datetime import timedelta
from django.core.signals import Signal
from django.dispatch import receiver
from django.utils.timezone import now
from structlog.stdlib import get_logger
# Signal dispatched before actual startup trigger
pre_startup = Signal()
# Signal dispatched which should trigger all startup logic
startup = Signal()
# Signal dispatched after the startup logic
post_startup = Signal()
LOGGER = get_logger()
@receiver(pre_startup)
def pre_startup_log(sender, **_):
sender._start_time = now()
@receiver(post_startup)
def post_startup_log(sender, **_):
took: timedelta = now() - sender._start_time
LOGGER.info("authentik Core Worker finished starting", took_s=took.total_seconds())

View File

@@ -76,7 +76,7 @@ class S3Storage(BaseS3Storage):
return safe_join(self.location, connection.schema_name, name)
except ValueError:
raise SuspiciousOperation(f"Attempted access to '{name}' denied.") from None
raise SuspiciousOperation("Attempted access to '%s' denied." % name) from None
# This is a fix for https://github.com/jschneier/django-storages/pull/839
def url(self, name, parameters=None, expire=None, http_method=None):

View File

@@ -1,21 +0,0 @@
from os import environ
import pytest
from authentik import get_full_version
IS_CI = "CI" in environ
@pytest.hookimpl(hookwrapper=True)
def pytest_sessionstart(*_, **__):
"""Clear the console ahead of the pytest output starting"""
if not IS_CI:
print("\x1b[2J\x1b[H")
yield
@pytest.hookimpl(trylast=True)
def pytest_report_header(*_, **__):
"""Add authentik version to pytest output"""
return [f"authentik version: {get_full_version()}"]

View File

@@ -4,13 +4,11 @@ import os
from argparse import ArgumentParser
from unittest import TestCase
import pytest
from django.conf import settings
from django.test.runner import DiscoverRunner
from authentik.lib.config import CONFIG
from authentik.lib.sentry import sentry_init
from authentik.root.signals import post_startup, pre_startup, startup
from tests.e2e.utils import get_docker_tag
# globally set maxDiff to none to show full assert error
@@ -48,10 +46,6 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
CONFIG.set("error_reporting.send_pii", True)
sentry_init()
pre_startup.send(sender=self, mode="test")
startup.send(sender=self, mode="test")
post_startup.send(sender=self, mode="test")
@classmethod
def add_arguments(cls, parser: ArgumentParser):
"""Add more pytest-specific arguments"""
@@ -106,4 +100,6 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
f"path instead."
)
import pytest
return pytest.main(self.args)

View File

@@ -34,7 +34,7 @@ def mock_ad_connection(password: str) -> Connection:
"objectSid": "unique-test-group",
"objectClass": "group",
"distinguishedName": "cn=group1,ou=groups,dc=goauthentik,dc=io",
"member": ["cn=user,ou=users,dc=goauthentik,dc=io"],
"member": ["cn=user0,ou=users,dc=goauthentik,dc=io"],
},
)
# Group without SID
@@ -47,7 +47,7 @@ def mock_ad_connection(password: str) -> Connection:
},
)
connection.strategy.add_entry(
"cn=user0,ou=foo,ou=users,dc=goauthentik,dc=io",
"cn=user0,ou=users,dc=goauthentik,dc=io",
{
"userPassword": password,
"sAMAccountName": "user0_sn",

View File

@@ -55,7 +55,7 @@ class LDAPSyncTests(TestCase):
)
connection.assert_called_with(
connection_kwargs={
"user": "cn=user0,ou=foo,ou=users,dc=goauthentik,dc=io",
"user": "cn=user0,ou=users,dc=goauthentik,dc=io",
"password": LDAP_PASSWORD,
}
)

View File

@@ -130,13 +130,7 @@ class OAuthSourceSerializer(SourceSerializer):
"oidc_jwks_url",
"oidc_jwks",
]
extra_kwargs = {
"consumer_secret": {"write_only": True},
"request_token_url": {"allow_blank": True},
"authorization_url": {"allow_blank": True},
"access_token_url": {"allow_blank": True},
"profile_url": {"allow_blank": True},
}
extra_kwargs = {"consumer_secret": {"write_only": True}}
class OAuthSourceFilter(FilterSet):

View File

@@ -33,10 +33,11 @@ class AuthentikSourceOAuthConfig(ManagedAppConfig):
mountpoint = "source/oauth/"
default = True
def import_related(self):
@ManagedAppConfig.reconcile_global
def load_source_types(self):
"""Load source_types from config file"""
for source_type in AUTHENTIK_SOURCES_OAUTH_TYPES:
try:
self.import_module(source_type)
except ImportError as exc:
LOGGER.warning("Failed to load OAuth Source", exc=exc)
return super().import_related()

View File

@@ -80,7 +80,7 @@ class OAuth2Client(BaseOAuthClient):
access_token_url = self.source.source_type.access_token_url or ""
if self.source.source_type.urls_customizable and self.source.access_token_url:
access_token_url = self.source.access_token_url
response = self.do_request(
response = self.session.request(
"post", access_token_url, data=args, headers=self._default_headers, **request_kwargs
)
response.raise_for_status()

View File

@@ -1,37 +0,0 @@
"""Apple Type tests"""
from django.test import RequestFactory, TestCase
from guardian.shortcuts import get_anonymous_user
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import dummy_get_response
from authentik.root.middleware import SessionMiddleware
from authentik.sources.oauth.models import OAuthSource
from authentik.sources.oauth.types.registry import registry
class TestTypeApple(TestCase):
"""OAuth Source tests"""
def setUp(self):
self.source = OAuthSource.objects.create(
name="test",
slug="test",
provider_type="apple",
authorization_url="",
profile_url="",
consumer_key=generate_id(),
)
self.factory = RequestFactory()
def test_login_challenge(self):
"""Test login_challenge"""
request = self.factory.get("/")
request.user = get_anonymous_user()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()
oauth_type = registry.find_type("apple")
challenge = oauth_type().login_challenge(self.source, request)
self.assertTrue(challenge.is_valid(raise_exception=True))

View File

@@ -125,7 +125,7 @@ class AppleType(SourceType):
)
args = apple_client.get_redirect_args()
return AppleLoginChallenge(
data={
instance={
"client_id": apple_client.get_client_id(),
"scope": "name email",
"redirect_uri": args["redirect_uri"],

View File

@@ -66,7 +66,7 @@ class PlexSource(Source):
icon = static("authentik/sources/plex.svg")
return UILoginButton(
challenge=PlexAuthenticationChallenge(
data={
{
"type": ChallengeTypes.NATIVE.value,
"component": "ak-source-plex",
"client_id": self.client_id,

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