Compare commits

..

3 Commits

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

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2021.6.1-rc1
current_version = 2021.4.6
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
@ -19,18 +19,26 @@ values =
[bumpversion:file:website/docs/installation/docker-compose.md]
[bumpversion:file:website/docs/installation/kubernetes.md]
[bumpversion:file:docker-compose.yml]
[bumpversion:file:helm/values.yaml]
[bumpversion:file:helm/README.md]
[bumpversion:file:helm/Chart.yaml]
[bumpversion:file:.github/workflows/release.yml]
[bumpversion:file:authentik/__init__.py]
[bumpversion:file:internal/constants/constants.go]
[bumpversion:file:outpost/pkg/version.go]
[bumpversion:file:web/src/constants.ts]
[bumpversion:file:web/nginx.conf]
[bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md]
[bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md]

View File

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

View File

@ -1,13 +1,5 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
assignees:
- BeryJu
- package-ecosystem: gomod
directory: "/outpost"
schedule:

13
.github/stale.yml vendored
View File

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

View File

@ -3,117 +3,90 @@ name: authentik-on-release
on:
release:
types: [published, created]
push:
branches:
- version-*
jobs:
# Build
build-server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v1
- name: Docker Login Registry
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
- name: Building Docker Image
uses: docker/build-push-action@v2
with:
push: ${{ github.event_name == 'release' }}
tags: |
beryju/authentik:2021.6.1-rc1,
beryju/authentik:latest,
ghcr.io/goauthentik/server:2021.6.1-rc1,
ghcr.io/goauthentik/server:latest
platforms: linux/amd64,linux/arm64
context: .
run: docker build
--no-cache
-t beryju/authentik:2021.4.6
-t beryju/authentik:latest
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/authentik:2021.4.6
- name: Push Docker Container to Registry (latest)
run: docker push beryju/authentik:latest
build-proxy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v1
- uses: actions/setup-go@v2
with:
go-version: "^1.15"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: prepare go api client
run: |
cd outpost
go get -u github.com/go-swagger/go-swagger/cmd/swagger
swagger generate client -f ../swagger.yaml -A authentik -t pkg/
go build -v .
- name: Docker Login Registry
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
- name: Building Docker Image
uses: docker/build-push-action@v2
with:
push: ${{ github.event_name == 'release' }}
tags: |
beryju/authentik-proxy:2021.6.1-rc1,
beryju/authentik-proxy:latest,
ghcr.io/goauthentik/proxy:2021.6.1-rc1,
ghcr.io/goauthentik/proxy:latest
file: outpost/proxy.Dockerfile
platforms: linux/amd64,linux/arm64
build-ldap:
run: |
cd outpost/
docker build \
--no-cache \
-t beryju/authentik-proxy:2021.4.6 \
-t beryju/authentik-proxy:latest \
-f proxy.Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/authentik-proxy:2021.4.6
- name: Push Docker Container to Registry (latest)
run: docker push beryju/authentik-proxy:latest
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: "^1.15"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v1
- name: prepare ts api client
run: |
docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
- name: Docker Login Registry
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
- name: Building Docker Image
uses: docker/build-push-action@v2
with:
push: ${{ github.event_name == 'release' }}
tags: |
beryju/authentik-ldap:2021.6.1-rc1,
beryju/authentik-ldap:latest,
ghcr.io/goauthentik/ldap:2021.6.1-rc1,
ghcr.io/goauthentik/ldap:latest
file: outpost/ldap.Dockerfile
platforms: linux/amd64,linux/arm64
run: |
cd web/
docker build \
--no-cache \
-t beryju/authentik-static:2021.4.6 \
-t beryju/authentik-static:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/authentik-static:2021.4.6
- name: Push Docker Container to Registry (latest)
run: docker push beryju/authentik-static:latest
test-release:
if: ${{ github.event_name == 'release' }}
needs:
- build-server
- build-static
- build-proxy
- build-ldap
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v1
- name: Run test suite in final docker images
run: |
sudo apt-get install -y pwgen
@ -122,21 +95,20 @@ jobs:
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root --entrypoint /bin/bash server -c "apt-get update && apt-get install -y --no-install-recommends git && pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
sentry-release:
if: ${{ github.event_name == 'release' }}
needs:
- test-release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v1
- name: Create a Sentry.io release
uses: getsentry/action-release@v1
uses: tclindner/sentry-releases-action@v1.2.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: beryjuorg
SENTRY_PROJECT: authentik
SENTRY_URL: https://sentry.beryju.org
with:
version: authentik@2021.6.1-rc1
tagName: 2021.4.6
environment: beryjuorg-prod

View File

@ -10,7 +10,7 @@ jobs:
name: Create Release from Tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@master
- name: Pre-release test
run: |
sudo apt-get install -y pwgen
@ -24,17 +24,26 @@ jobs:
-f Dockerfile .
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root --entrypoint /bin/bash server -c "apt-get update && apt-get install -y --no-install-recommends git && pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
- name: Install Helm
run: |
apt update && apt install -y curl
curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash
- name: Helm package
run: |
helm dependency update helm/
helm package helm/
mv authentik-*.tgz authentik-chart.tgz
- name: Extract version number
id: get_version
uses: actions/github-script@v4.0.2
uses: actions/github-script@0.2.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
- name: Create Release
id: create_release
uses: actions/create-release@v1.1.4
uses: actions/create-release@v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -42,3 +51,13 @@ jobs:
release_name: Release ${{ steps.get_version.outputs.result }}
draft: true
prerelease: false
- name: Upload packaged Helm Chart
id: upload-release-asset
uses: actions/upload-release-asset@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./authentik-chart.tgz
asset_name: authentik-chart.tgz
asset_content_type: application/gzip

2
.gitignore vendored
View File

@ -202,5 +202,3 @@ selenium_screenshots/
backups/
media/
*mmdb
.idea/

View File

@ -1,4 +1,3 @@
# Stage 1: Lock python dependencies
FROM python:3.9-slim-buster as locker
COPY ./Pipfile /app/
@ -10,45 +9,6 @@ RUN pip install pipenv && \
pipenv lock -r > requirements.txt && \
pipenv lock -rd > requirements-dev.txt
# Stage 2: Build web API
FROM openapitools/openapi-generator-cli as api-builder
COPY ./schema.yml /local/schema.yml
RUN docker-entrypoint.sh generate \
-i /local/schema.yml \
-g typescript-fetch \
-o /local/web/api \
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
# Stage 3: Build webui
FROM node as npm-builder
COPY ./web /static/
COPY --from=api-builder /local/web/api /static/api
ENV NODE_ENV=production
RUN cd /static && npm i --production=false && npm run build
# Stage 4: Build go proxy
FROM golang:1.16.5 AS builder
WORKDIR /work
COPY --from=npm-builder /static/robots.txt /work/web/robots.txt
COPY --from=npm-builder /static/security.txt /work/web/security.txt
COPY --from=npm-builder /static/dist/ /work/web/dist/
COPY --from=npm-builder /static/authentik/ /work/web/authentik/
COPY ./cmd /work/cmd
COPY ./web/static.go /work/web/static.go
COPY ./internal /work/internal
COPY ./go.mod /work/go.mod
COPY ./go.sum /work/go.sum
RUN go build -o /work/authentik ./cmd/server/main.go
# Stage 5: Run
FROM python:3.9-slim-buster
WORKDIR /
@ -59,17 +19,23 @@ ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg git runit && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg && \
curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
echo "deb http://apt.postgresql.org/pub/repos/apt buster-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
apt-get update && \
apt-get install -y --no-install-recommends libpq-dev postgresql-client build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
pip install -r /requirements.txt --no-cache-dir && \
apt-get remove --purge -y build-essential git && \
apt-get autoremove --purge -y && \
apt-get install -y --no-install-recommends postgresql-client-12 postgresql-client-11 build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
pip install -r /requirements.txt --no-cache-dir && \
apt-get remove --purge -y build-essential && \
apt-get autoremove --purge -y && \
# This is quite hacky, but docker has no guaranteed Group ID
# we could instead check for the GID of the socket and add the user dynamically,
# but then we have to drop permmissions later
groupadd -g 998 docker_998 && \
groupadd -g 999 docker_999 && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
usermod -a -G docker_998 authentik && \
usermod -a -G docker_999 authentik && \
mkdir /backups && \
chown authentik:authentik /backups
@ -78,9 +44,9 @@ COPY ./pyproject.toml /
COPY ./xml /xml
COPY ./manage.py /
COPY ./lifecycle/ /lifecycle
COPY --from=builder /work/authentik /authentik-proxy
USER authentik
STOPSIGNAL SIGINT
ENV TMPDIR /dev/shm/
ENV PYTHONUBUFFERED 1
ENTRYPOINT [ "/lifecycle/bootstrap.sh" ]

View File

@ -1,9 +1,4 @@
.SHELLFLAGS += -x -e
PWD = $(shell pwd)
UID = $(shell id -u)
GID = $(shell id -g)
all: lint-fix lint test gen
all: lint-fix lint coverage gen
test-integration:
k3d cluster create || exit 0
@ -13,7 +8,7 @@ test-integration:
test-e2e:
coverage run manage.py test --failfast -v 3 tests/e2e
test:
coverage:
coverage run manage.py test -v 3 authentik
coverage html
coverage report
@ -27,39 +22,16 @@ lint:
bandit -r authentik tests lifecycle -x node_modules
pylint authentik tests lifecycle
gen-build:
./manage.py spectacular --file schema.yml
gen: coverage
./manage.py generate_swagger -o swagger.yaml -f yaml
gen-clean:
rm -rf web/api/src/
rm -rf outpost/api/
local-stack:
export AUTHENTIK_TAG=testing
docker build -t beryju/authentik:testng .
docker-compose up -d
docker-compose run --rm server migrate
gen-web:
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
openapitools/openapi-generator-cli generate \
-i /local/schema.yml \
-g typescript-fetch \
-o /local/web/api \
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
cd web/api && npx tsc
gen-outpost:
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
openapitools/openapi-generator-cli generate \
--git-host goauthentik.io \
--git-repo-id outpost \
--git-user-id api \
-i /local/schema.yml \
-g go \
-o /local/outpost/api \
--additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true
rm -f outpost/api/go.mod outpost/api/go.sum
gen: gen-build gen-clean gen-web gen-outpost
run:
go run -v cmd/server/main.go
build-static:
docker-compose -f scripts/ci.docker-compose.yml up -d
docker build -t beryju/authentik-static -f static.Dockerfile --network=scripts_default .
docker-compose -f scripts/ci.docker-compose.yml down -v

11
Pipfile
View File

@ -11,7 +11,7 @@ channels-redis = "*"
dacite = "*"
defusedxml = "*"
django = "*"
django-dbbackup = { git = 'https://github.com/django-dbbackup/django-dbbackup.git', ref = '9d1909c30a3271c8c9c8450add30d6e0b996e145' }
django-dbbackup = "*"
django-filter = "*"
django-guardian = "*"
django-model-utils = "*"
@ -22,7 +22,7 @@ django-storages = "*"
djangorestframework = "*"
djangorestframework-guardian = "*"
docker = "*"
drf-spectacular = "*"
drf_yasg = "*"
facebook-sdk = "*"
geoip2 = "*"
gunicorn = "*"
@ -32,7 +32,7 @@ lxml = ">=4.6.3"
packaging = "*"
psycopg2-binary = "*"
pycryptodome = "*"
pyjwt = "*"
pyjwkest = "*"
pyyaml = "*"
requests-oauthlib = "*"
sentry-sdk = "*"
@ -44,15 +44,13 @@ urllib3 = {extras = ["secure"],version = "*"}
uvicorn = {extras = ["standard"],version = "*"}
webauthn = "*"
xmlsec = "*"
duo-client = "*"
ua-parser = "*"
[requires]
python_version = "3.9"
[dev-packages]
bandit = "*"
black = "==21.5b1"
black = "==20.8b1"
bump2version = "*"
colorama = "*"
coverage = "*"
@ -61,4 +59,3 @@ pylint-django = "*"
pytest = "*"
pytest-django = "*"
selenium = "*"
requests-mock = "*"

657
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,6 @@
![Docker pulls](https://img.shields.io/docker/pulls/beryju/authentik.svg?style=flat-square)
![Latest version](https://img.shields.io/docker/v/beryju/authentik?sort=semver&style=flat-square)
![LGTM Grade](https://img.shields.io/lgtm/grade/python/github/goauthentik/authentik?style=flat-square)
[Transifex](https://www.transifex.com/beryjuorg/authentik/)
## What is authentik?

View File

@ -4,8 +4,8 @@
| Version | Supported |
| ---------- | ------------------ |
| 2021.3.x | :white_check_mark: |
| 2021.4.x | :white_check_mark: |
| 2021.5.x | :white_check_mark: |
## Reporting a Vulnerability

View File

@ -1,3 +1,3 @@
"""authentik"""
__version__ = "2021.6.1-rc1"
__version__ = "2021.4.6"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -1,5 +1,5 @@
"""Meta API"""
from drf_spectacular.utils import extend_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.fields import CharField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
@ -22,7 +22,7 @@ class AppsViewSet(ViewSet):
permission_classes = [IsAdminUser]
@extend_schema(responses={200: AppSerializer(many=True)})
@swagger_auto_schema(responses={200: AppSerializer(many=True)})
def list(self, request: Request) -> Response:
"""List current messages and pass into Serializer"""
data = []

View File

@ -7,12 +7,12 @@ from django.db.models import Count, ExpressionWrapper, F
from django.db.models.fields import DurationField
from django.db.models.functions import ExtractHour
from django.utils.timezone import now
from drf_spectacular.utils import extend_schema, extend_schema_field
from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method
from rest_framework.fields import IntegerField, SerializerMethodField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import PassiveSerializer
from authentik.events.models import Event, EventAction
@ -58,24 +58,24 @@ class LoginMetricsSerializer(PassiveSerializer):
logins_per_1h = SerializerMethodField()
logins_failed_per_1h = SerializerMethodField()
@extend_schema_field(CoordinateSerializer(many=True))
@swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
def get_logins_per_1h(self, _):
"""Get successful logins per hour for the last 24 hours"""
return get_events_per_1h(action=EventAction.LOGIN)
@extend_schema_field(CoordinateSerializer(many=True))
@swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
def get_logins_failed_per_1h(self, _):
"""Get failed logins per hour for the last 24 hours"""
return get_events_per_1h(action=EventAction.LOGIN_FAILED)
class AdministrationMetricsViewSet(APIView):
class AdministrationMetricsViewSet(ViewSet):
"""Login Metrics per 1h"""
permission_classes = [IsAdminUser]
@extend_schema(responses={200: LoginMetricsSerializer(many=False)})
def get(self, request: Request) -> Response:
@swagger_auto_schema(responses={200: LoginMetricsSerializer(many=False)})
def list(self, request: Request) -> Response:
"""Login Metrics per 1h"""
serializer = LoginMetricsSerializer(True)
return Response(serializer.data)

View File

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

View File

@ -4,8 +4,7 @@ from importlib import import_module
from django.contrib import messages
from django.http.response import Http404
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField
from rest_framework.permissions import IsAdminUser
@ -22,7 +21,7 @@ class TaskSerializer(PassiveSerializer):
task_name = CharField()
task_description = CharField()
task_finish_timestamp = DateTimeField(source="finish_time")
task_finish_timestamp = DateTimeField(source="finish_timestamp")
status = ChoiceField(
source="result.status.name",
@ -30,32 +29,14 @@ class TaskSerializer(PassiveSerializer):
)
messages = ListField(source="result.messages")
def to_representation(self, instance):
"""When a new version of authentik adds fields to TaskInfo,
the API will fail with an AttributeError, as the classes
are pickled in cache. In that case, just delete the info"""
try:
return super().to_representation(instance)
except AttributeError:
if isinstance(self.instance, list):
for inst in self.instance:
inst.delete()
else:
self.instance.delete()
return {}
class TaskViewSet(ViewSet):
"""Read-only view set that returns all background tasks"""
permission_classes = [IsAdminUser]
serializer_class = TaskSerializer
@extend_schema(
responses={
200: TaskSerializer(many=False),
404: OpenApiResponse(description="Task not found"),
}
@swagger_auto_schema(
responses={200: TaskSerializer(many=False), 404: "Task not found"}
)
# pylint: disable=invalid-name
def retrieve(self, request: Request, pk=None) -> Response:
@ -65,19 +46,18 @@ class TaskViewSet(ViewSet):
raise Http404
return Response(TaskSerializer(task, many=False).data)
@extend_schema(responses={200: TaskSerializer(many=True)})
@swagger_auto_schema(responses={200: TaskSerializer(many=True)})
def list(self, request: Request) -> Response:
"""List system tasks"""
tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name)
return Response(TaskSerializer(tasks, many=True).data)
@extend_schema(
request=OpenApiTypes.NONE,
@swagger_auto_schema(
responses={
204: OpenApiResponse(description="Task retried successfully"),
404: OpenApiResponse(description="Task not found"),
500: OpenApiResponse(description="Failed to retry task"),
},
204: "Task retried successfully",
404: "Task not found",
500: "Failed to retry task",
}
)
@action(detail=True, methods=["post"])
# pylint: disable=invalid-name

View File

@ -2,13 +2,14 @@
from os import environ
from django.core.cache import cache
from drf_spectacular.utils import extend_schema
from drf_yasg.utils import swagger_auto_schema
from packaging.version import parse
from rest_framework.fields import SerializerMethodField
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
from authentik import ENV_GIT_HASH_KEY, __version__
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
@ -46,14 +47,17 @@ class VersionSerializer(PassiveSerializer):
)
class VersionView(APIView):
class VersionViewSet(ListModelMixin, GenericViewSet):
"""Get running and latest version."""
permission_classes = [IsAuthenticated]
pagination_class = None
filter_backends = []
@extend_schema(responses={200: VersionSerializer(many=False)})
def get(self, request: Request) -> Response:
def get_queryset(self): # pragma: no cover
return None
@swagger_auto_schema(responses={200: VersionSerializer(many=False)})
def list(self, request: Request) -> Response:
"""Get running and latest version."""
return Response(VersionSerializer(True).data)

View File

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

View File

@ -1,15 +1,13 @@
"""authentik admin tasks"""
import re
from os import environ
from django.core.cache import cache
from django.core.validators import URLValidator
from packaging.version import parse
from prometheus_client import Info
from requests import RequestException, get
from structlog.stdlib import get_logger
from authentik import ENV_GIT_HASH_KEY, __version__
from authentik import __version__
from authentik.events.models import Event, EventAction
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
from authentik.root.celery import CELERY_APP
@ -19,18 +17,6 @@ VERSION_CACHE_KEY = "authentik_latest_version"
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
# Chop of the first ^ because we want to search the entire string
URL_FINDER = URLValidator.regex.pattern[1:]
PROM_INFO = Info("authentik_version", "Currently running authentik version")
def _set_prom_info():
"""Set prometheus info for version"""
PROM_INFO.info(
{
"version": __version__,
"latest": cache.get(VERSION_CACHE_KEY, ""),
"build_hash": environ.get(ENV_GIT_HASH_KEY, ""),
}
)
@CELERY_APP.task(bind=True, base=MonitoredTask)
@ -50,7 +36,6 @@ def update_latest_version(self: MonitoredTask):
TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"]
)
)
_set_prom_info()
# Check if upstream version is newer than what we're running,
# and if no event exists yet, create one.
local_version = parse(__version__)
@ -68,6 +53,3 @@ def update_latest_version(self: MonitoredTask):
except (RequestException, IndexError) as exc:
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
_set_prom_info()

View File

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

View File

@ -1,9 +1,8 @@
"""API Authentication"""
from base64 import b64decode
from base64 import b64decode, b64encode
from binascii import Error
from typing import Any, Optional, Union
from drf_spectacular.authentication import OpenApiAuthenticationExtension
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
@ -18,8 +17,16 @@ LOGGER = get_logger()
def token_from_header(raw_header: bytes) -> Optional[Token]:
"""raw_header in the Format of `Bearer dGVzdDp0ZXN0`"""
auth_credentials = raw_header.decode()
if auth_credentials == "" or " " not in auth_credentials:
if auth_credentials == "":
return None
# Legacy, accept basic auth thats fully encoded (2021.3 outposts)
if " " not in auth_credentials:
try:
plain = b64decode(auth_credentials.encode()).decode()
auth_type, body = plain.split()
auth_credentials = f"{auth_type} {b64encode(body.encode()).decode()}"
except (UnicodeDecodeError, Error):
raise AuthenticationFailed("Malformed header")
auth_type, auth_credentials = auth_credentials.split()
if auth_type.lower() not in ["basic", "bearer"]:
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
@ -43,7 +50,7 @@ def token_from_header(raw_header: bytes) -> Optional[Token]:
return tokens.first()
class TokenAuthentication(BaseAuthentication):
class AuthentikTokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""
def authenticate(self, request: Request) -> Union[tuple[User, Any], None]:
@ -55,19 +62,4 @@ class TokenAuthentication(BaseAuthentication):
if not token:
return None
return (token.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": "apiKey",
"in": "header",
"name": "Authorization",
}
return (token.user, None)

View File

@ -1,35 +0,0 @@
"""API Authorization"""
from django.db.models import Model
from django.db.models.query import QuerySet
from rest_framework.filters import BaseFilterBackend
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
class OwnerFilter(BaseFilterBackend):
"""Filter objects by their owner"""
owner_key = "user"
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
return queryset.filter(**{self.owner_key: request.user})
class OwnerPermissions(BasePermission):
"""Authorize requests by an object's owner matching the requesting user"""
owner_key = "user"
def has_permission(self, request: Request, view) -> bool:
"""If the user is authenticated, we allow all requests here. For listing, the
object-level permissions are done by the filter backend"""
return request.user.is_authenticated
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
"""Check if the object's owner matches the currently logged in user"""
if not hasattr(obj, self.owner_key):
return False
owner = getattr(obj, self.owner_key)
if owner != request.user:
return False
return True

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
{% load static %}
{% block title %}
API Browser - {{ tenant.branding_title }}
authentik API Browser
{% endblock %}
{% block head %}

View File

@ -5,7 +5,7 @@ from django.test import TestCase
from guardian.shortcuts import get_anonymous_user
from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import token_from_header
from authentik.api.auth import token_from_header
from authentik.core.models import Token, TokenIntents

View File

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

View File

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

View File

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

View File

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

View File

@ -1,70 +1,50 @@
"""core Configs API"""
from os import environ, path
from django.conf import settings
from django.db import models
from drf_spectacular.utils import extend_schema
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
from rest_framework.fields import BooleanField, CharField, ChoiceField, ListField
from drf_yasg.utils import swagger_auto_schema
from rest_framework.fields import BooleanField, CharField, ListField
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import PassiveSerializer
from authentik.events.geo import GEOIP_READER
from authentik.lib.config import CONFIG
class Capabilities(models.TextChoices):
"""Define capabilities which influence which APIs can/should be used"""
class FooterLinkSerializer(PassiveSerializer):
"""Links returned in Config API"""
CAN_SAVE_MEDIA = "can_save_media"
CAN_GEO_IP = "can_geo_ip"
CAN_BACKUP = "can_backup"
href = CharField(read_only=True)
name = CharField(read_only=True)
class ConfigSerializer(PassiveSerializer):
"""Serialize authentik Config into DRF Object"""
branding_logo = CharField(read_only=True)
branding_title = CharField(read_only=True)
ui_footer_links = ListField(child=FooterLinkSerializer(), read_only=True)
error_reporting_enabled = BooleanField(read_only=True)
error_reporting_environment = CharField(read_only=True)
error_reporting_send_pii = BooleanField(read_only=True)
capabilities = ListField(child=ChoiceField(choices=Capabilities.choices))
class ConfigView(APIView):
class ConfigsViewSet(ViewSet):
"""Read-only view set that returns the current session's Configs"""
permission_classes = [AllowAny]
def get_capabilities(self) -> list[Capabilities]:
"""Get all capabilities this server instance supports"""
caps = []
deb_test = settings.DEBUG or settings.TEST
if path.ismount(settings.MEDIA_ROOT) or deb_test:
caps.append(Capabilities.CAN_SAVE_MEDIA)
if GEOIP_READER.enabled:
caps.append(Capabilities.CAN_GEO_IP)
if SERVICE_HOST_ENV_NAME in environ:
# Running in k8s, only s3 backup is supported
if CONFIG.y("postgresql.s3_backup"):
caps.append(Capabilities.CAN_BACKUP)
else:
# Running in compose, backup is always supported
caps.append(Capabilities.CAN_BACKUP)
return caps
@extend_schema(responses={200: ConfigSerializer(many=False)})
def get(self, request: Request) -> Response:
@swagger_auto_schema(responses={200: ConfigSerializer(many=False)})
def list(self, request: Request) -> Response:
"""Retrive public configuration options"""
config = ConfigSerializer(
{
"branding_logo": CONFIG.y("authentik.branding.logo"),
"branding_title": CONFIG.y("authentik.branding.title"),
"error_reporting_enabled": CONFIG.y("error_reporting.enabled"),
"error_reporting_environment": CONFIG.y("error_reporting.environment"),
"error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"),
"capabilities": self.get_capabilities(),
"ui_footer_links": CONFIG.y("authentik.footer_links"),
}
)
return Response(config.data)

View File

@ -1,18 +1,18 @@
"""api v2 urls"""
from django.urls import path
from drf_spectacular.views import SpectacularAPIView
from django.urls import path, re_path
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import routers
from rest_framework.permissions import AllowAny
from authentik.admin.api.meta import AppsViewSet
from authentik.admin.api.metrics import AdministrationMetricsViewSet
from authentik.admin.api.system import SystemView
from authentik.admin.api.tasks import TaskViewSet
from authentik.admin.api.version import VersionView
from authentik.admin.api.workers import WorkerView
from authentik.api.v2.config import ConfigView
from authentik.api.views import APIBrowserView
from authentik.admin.api.version import VersionViewSet
from authentik.admin.api.workers import WorkerViewSet
from authentik.api.v2.config import ConfigsViewSet
from authentik.api.views import SwaggerView
from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
from authentik.core.api.groups import GroupViewSet
from authentik.core.api.propertymappings import PropertyMappingViewSet
from authentik.core.api.providers import ProviderViewSet
@ -28,12 +28,12 @@ from authentik.flows.api.bindings import FlowStageBindingViewSet
from authentik.flows.api.flows import FlowViewSet
from authentik.flows.api.stages import StageViewSet
from authentik.flows.views import FlowExecutorView
from authentik.outposts.api.outposts import OutpostViewSet
from authentik.outposts.api.service_connections import (
from authentik.outposts.api.outpost_service_connections import (
DockerServiceConnectionViewSet,
KubernetesServiceConnectionViewSet,
ServiceConnectionViewSet,
)
from authentik.outposts.api.outposts import OutpostViewSet
from authentik.policies.api.bindings import PolicyBindingViewSet
from authentik.policies.api.policies import PolicyViewSet
from authentik.policies.dummy.api import DummyPolicyViewSet
@ -47,7 +47,6 @@ from authentik.policies.reputation.api import (
ReputationPolicyViewSet,
UserReputationViewSet,
)
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet
from authentik.providers.oauth2.api.scope import ScopeMappingViewSet
from authentik.providers.oauth2.api.tokens import (
@ -64,13 +63,7 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet
from authentik.sources.oauth.api.source_connection import (
UserOAuthSourceConnectionViewSet,
)
from authentik.sources.plex.api import PlexSourceViewSet
from authentik.sources.saml.api import SAMLSourceViewSet
from authentik.stages.authenticator_duo.api import (
AuthenticatorDuoStageViewSet,
DuoAdminDeviceViewSet,
DuoDeviceViewSet,
)
from authentik.stages.authenticator_static.api import (
AuthenticatorStaticStageViewSet,
StaticAdminDeviceViewSet,
@ -102,21 +95,24 @@ from authentik.stages.user_delete.api import UserDeleteStageViewSet
from authentik.stages.user_login.api import UserLoginStageViewSet
from authentik.stages.user_logout.api import UserLogoutStageViewSet
from authentik.stages.user_write.api import UserWriteStageViewSet
from authentik.tenants.api import TenantViewSet
router = routers.DefaultRouter()
router.register("root/config", ConfigsViewSet, basename="configs")
router.register("admin/version", VersionViewSet, basename="admin_version")
router.register("admin/workers", WorkerViewSet, basename="admin_workers")
router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics")
router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks")
router.register("admin/apps", AppsViewSet, basename="apps")
router.register("core/authenticated_sessions", AuthenticatedSessionViewSet)
router.register("core/applications", ApplicationViewSet)
router.register("core/groups", GroupViewSet)
router.register("core/users", UserViewSet)
router.register("core/user_consent", UserConsentViewSet)
router.register("core/tokens", TokenViewSet)
router.register("core/tenants", TenantViewSet)
router.register("outposts/outposts", OutpostViewSet)
router.register("outposts/instances", OutpostViewSet)
router.register("outposts/service_connections/all", ServiceConnectionViewSet)
router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet)
@ -124,7 +120,6 @@ router.register(
"outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet
)
router.register("outposts/proxy", ProxyOutpostConfigViewSet)
router.register("outposts/ldap", LDAPOutpostConfigViewSet)
router.register("flows/instances", FlowViewSet)
router.register("flows/bindings", FlowStageBindingViewSet)
@ -141,7 +136,6 @@ router.register("sources/oauth_user_connections", UserOAuthSourceConnectionViewS
router.register("sources/ldap", LDAPSourceViewSet)
router.register("sources/saml", SAMLSourceViewSet)
router.register("sources/oauth", OAuthSourceViewSet)
router.register("sources/plex", PlexSourceViewSet)
router.register("policies/all", PolicyViewSet)
router.register("policies/bindings", PolicyBindingViewSet)
@ -155,7 +149,6 @@ router.register("policies/reputation/ips", IPReputationViewSet)
router.register("policies/reputation", ReputationPolicyViewSet)
router.register("providers/all", ProviderViewSet)
router.register("providers/ldap", LDAPProviderViewSet)
router.register("providers/proxy", ProxyProviderViewSet)
router.register("providers/oauth2", OAuth2ProviderViewSet)
router.register("providers/saml", SAMLProviderViewSet)
@ -168,31 +161,14 @@ router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
router.register("propertymappings/scope", ScopeMappingViewSet)
router.register("authenticators/duo", DuoDeviceViewSet)
router.register("authenticators/static", StaticDeviceViewSet)
router.register("authenticators/totp", TOTPDeviceViewSet)
router.register("authenticators/webauthn", WebAuthnDeviceViewSet)
router.register(
"authenticators/admin/duo",
DuoAdminDeviceViewSet,
basename="admin-duodevice",
)
router.register(
"authenticators/admin/static",
StaticAdminDeviceViewSet,
basename="admin-staticdevice",
)
router.register(
"authenticators/admin/totp", TOTPAdminDeviceViewSet, basename="admin-totpdevice"
)
router.register(
"authenticators/admin/webauthn",
WebAuthnAdminDeviceViewSet,
basename="admin-webauthndevice",
)
router.register("authenticators/admin/static", StaticAdminDeviceViewSet)
router.register("authenticators/admin/totp", TOTPAdminDeviceViewSet)
router.register("authenticators/admin/webauthn", WebAuthnAdminDeviceViewSet)
router.register("stages/all", StageViewSet)
router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet)
router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet)
router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet)
router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet)
@ -215,26 +191,32 @@ router.register("stages/user_write", UserWriteStageViewSet)
router.register("stages/dummy", DummyStageViewSet)
router.register("policies/dummy", DummyPolicyViewSet)
info = openapi.Info(
title="authentik API",
default_version="v2beta",
contact=openapi.Contact(email="hello@beryju.org"),
license=openapi.License(
name="GNU GPLv3",
url="https://github.com/goauthentik/authentik/blob/master/LICENSE",
),
)
SchemaView = get_schema_view(info, public=True, permission_classes=(AllowAny,))
urlpatterns = (
[
path("", APIBrowserView.as_view(), name="schema-browser"),
path("", SwaggerView.as_view(), name="swagger"),
]
+ router.urls
+ [
path(
"admin/metrics/",
AdministrationMetricsViewSet.as_view(),
name="admin_metrics",
),
path("admin/version/", VersionView.as_view(), name="admin_version"),
path("admin/workers/", WorkerView.as_view(), name="admin_workers"),
path("admin/system/", SystemView.as_view(), name="admin_system"),
path("root/config/", ConfigView.as_view(), name="config"),
path(
"flows/executor/<slug:flow_slug>/",
FlowExecutorView.as_view(),
name="flow-executor",
),
path("schema/", SpectacularAPIView.as_view(), name="schema"),
re_path(
r"^swagger(?P<format>\.json|\.yaml)$",
SchemaView.without_ui(cache_timeout=0),
name="schema-json",
),
]
)

View File

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

View File

@ -1,23 +1,13 @@
"""Application API Views"""
from typing import Optional
from django.core.cache import cache
from django.db.models import QuerySet
from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
extend_schema,
inline_serializer,
)
from drf_yasg import openapi
from drf_yasg.utils import no_body, swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import (
BooleanField,
CharField,
FileField,
IntegerField,
ReadOnlyField,
)
from rest_framework.fields import SerializerMethodField
from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
@ -29,12 +19,9 @@ from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
from authentik.api.decorators import permission_required
from authentik.core.api.providers import ProviderSerializer
from authentik.core.models import Application, User
from authentik.core.models import Application
from authentik.events.models import EventAction
from authentik.policies.api.exec import PolicyTestResultSerializer
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyResult
from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED
LOGGER = get_logger()
@ -47,10 +34,12 @@ def user_app_cache_key(user_pk: str) -> str:
class ApplicationSerializer(ModelSerializer):
"""Application Serializer"""
launch_url = ReadOnlyField(source="get_launch_url")
launch_url = SerializerMethodField()
provider_obj = ProviderSerializer(source="get_provider", required=False)
meta_icon = ReadOnlyField(source="get_meta_icon")
def get_launch_url(self, instance: Application) -> Optional[str]:
"""Get generated launch URL"""
return instance.get_launch_url()
class Meta:
@ -68,9 +57,6 @@ class ApplicationSerializer(ModelSerializer):
"meta_publisher",
"policy_engine_mode",
]
extra_kwargs = {
"meta_icon": {"read_only": True},
}
class ApplicationViewSet(ModelViewSet):
@ -105,48 +91,17 @@ class ApplicationViewSet(ModelViewSet):
applications.append(application)
return applications
@extend_schema(
request=inline_serializer(
"CheckAccessRequest", fields={"for_user": IntegerField(required=False)}
),
responses={
200: PolicyTestResultSerializer(),
404: OpenApiResponse(description="for_user user not found"),
},
)
@action(detail=True, methods=["POST"])
# pylint: disable=unused-argument
def check_access(self, request: Request, slug: str) -> Response:
"""Check access to a single application by slug"""
# Don't use self.get_object as that checks for view_application permission
# which the user might not have, even if they have access
application = get_object_or_404(Application, slug=slug)
# If the current user is superuser, they can set `for_user`
for_user = self.request.user
if self.request.user.is_superuser and "for_user" in request.data:
for_user = get_object_or_404(User, pk=request.data.get("for_user"))
engine = PolicyEngine(application, for_user, self.request)
engine.build()
result = engine.result
response = PolicyTestResultSerializer(PolicyResult(False))
if result.passing:
response = PolicyTestResultSerializer(PolicyResult(True))
if self.request.user.is_superuser:
response = PolicyTestResultSerializer(result)
return Response(response.data)
@extend_schema(
parameters=[
OpenApiParameter(
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(
name="superuser_full_list",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
in_=openapi.IN_QUERY,
type=openapi.TYPE_BOOLEAN,
)
]
)
def list(self, request: Request) -> Response:
"""Custom list method that checks Policy based access instead of guardian"""
self.request.session.pop(USER_LOGIN_AUTHENTICATED, None)
queryset = self._filter_queryset_for_list(self.get_queryset())
self.paginate_queryset(queryset)
@ -176,20 +131,17 @@ class ApplicationViewSet(ModelViewSet):
return self.get_paginated_response(serializer.data)
@permission_required("authentik_core.change_application")
@extend_schema(
request={
"multipart/form-data": inline_serializer(
"SetIcon",
fields={
"file": FileField(required=False),
"clear": BooleanField(default=False),
},
@swagger_auto_schema(
request_body=no_body,
manual_parameters=[
openapi.Parameter(
name="file",
in_=openapi.IN_FORM,
type=openapi.TYPE_FILE,
required=True,
)
},
responses={
200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"),
},
],
responses={200: "Success", 400: "Bad request"},
)
@action(
detail=True,
@ -203,46 +155,16 @@ class ApplicationViewSet(ModelViewSet):
"""Set application icon"""
app: Application = self.get_object()
icon = request.FILES.get("file", None)
clear = request.data.get("clear", False)
if clear:
# .delete() saves the model by default
app.meta_icon.delete()
return Response({})
if icon:
app.meta_icon = icon
app.save()
return Response({})
return HttpResponseBadRequest()
@permission_required("authentik_core.change_application")
@extend_schema(
request=inline_serializer("SetIconURL", fields={"url": CharField()}),
responses={
200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(
detail=True,
pagination_class=None,
filter_backends=[],
methods=["POST"],
)
# pylint: disable=unused-argument
def set_icon_url(self, request: Request, slug: str):
"""Set application icon (as URL)"""
app: Application = self.get_object()
url = request.data.get("url", None)
if url is None:
if not icon:
return HttpResponseBadRequest()
app.meta_icon.name = url
app.meta_icon = icon
app.save()
return Response({})
@permission_required(
"authentik_core.view_application", ["authentik_events.view_event"]
)
@extend_schema(responses={200: CoordinateSerializer(many=True)})
@swagger_auto_schema(responses={200: CoordinateSerializer(many=True)})
@action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=unused-argument
def metrics(self, request: Request, slug: str):

View File

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

View File

@ -1,9 +1,7 @@
"""Groups API Viewset"""
from django.db.models.query import QuerySet
from rest_framework.fields import JSONField
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.core.api.utils import is_dict
from authentik.core.models import Group
@ -28,16 +26,3 @@ class GroupViewSet(ModelViewSet):
search_fields = ["name", "is_superuser"]
filterset_fields = ["name", "is_superuser"]
ordering = ["name"]
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends):
if backend == ObjectPermissionsFilter:
continue
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def filter_queryset(self, queryset):
if self.request.user.has_perm("authentik_core.view_group"):
return self._filter_queryset_for_list(queryset)
return super().filter_queryset(queryset)

View File

@ -1,8 +1,8 @@
"""PropertyMapping API Views"""
from json import dumps
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework import mixins
from rest_framework.decorators import action
@ -78,10 +78,10 @@ class PropertyMappingViewSet(
filterset_fields = {"managed": ["isnull"]}
ordering = ["name"]
def get_queryset(self): # pragma: no cover
def get_queryset(self):
return PropertyMapping.objects.select_subclasses()
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request) -> Response:
"""Get all creatable property-mapping types"""
@ -100,17 +100,14 @@ class PropertyMappingViewSet(
return Response(TypeCreateSerializer(data, many=True).data)
@permission_required("authentik_core.view_propertymapping")
@extend_schema(
request=PolicyTestSerializer(),
responses={
200: PropertyMappingTestResultSerializer,
400: OpenApiResponse(description="Invalid parameters"),
},
parameters=[
OpenApiParameter(
@swagger_auto_schema(
request_body=PolicyTestSerializer(),
responses={200: PropertyMappingTestResultSerializer, 400: "Invalid parameters"},
manual_parameters=[
openapi.Parameter(
name="format_result",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
in_=openapi.IN_QUERY,
type=openapi.TYPE_BOOLEAN,
)
],
)

View File

@ -1,6 +1,6 @@
"""Provider API Views"""
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField
@ -22,7 +22,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
component = SerializerMethodField()
def get_component(self, obj: Provider) -> str: # pragma: no cover
def get_component(self, obj: Provider): # pragma: no cover
"""Get object component so that we know how to edit the object"""
# pyright: reportGeneralTypeIssues=false
if obj.__class__ == Provider:
@ -63,10 +63,10 @@ class ProviderViewSet(
"application__name",
]
def get_queryset(self): # pragma: no cover
def get_queryset(self):
return Provider.objects.select_subclasses()
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request) -> Response:
"""Get all creatable provider types"""

View File

@ -1,7 +1,7 @@
"""Source API Views"""
from typing import Iterable
from drf_spectacular.utils import extend_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.request import Request
@ -24,7 +24,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
component = SerializerMethodField()
def get_component(self, obj: Source) -> str:
def get_component(self, obj: Source):
"""Get object component so that we know how to edit the object"""
# pyright: reportGeneralTypeIssues=false
if obj.__class__ == Source:
@ -45,7 +45,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"verbose_name",
"verbose_name_plural",
"policy_engine_mode",
"user_matching_mode",
]
@ -61,10 +60,10 @@ class SourceViewSet(
serializer_class = SourceSerializer
lookup_field = "slug"
def get_queryset(self): # pragma: no cover
def get_queryset(self):
return Source.objects.select_subclasses()
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request) -> Response:
"""Get all creatable source types"""
@ -87,7 +86,7 @@ class SourceViewSet(
)
return Response(TypeCreateSerializer(data, many=True).data)
@extend_schema(responses={200: UserSettingSerializer(many=True)})
@swagger_auto_schema(responses={200: UserSettingSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def user_settings(self, request: Request) -> Response:
"""Get all sources the user can configure"""

View File

@ -1,6 +1,6 @@
"""Tokens API Viewset"""
from django.http.response import Http404
from drf_spectacular.utils import OpenApiResponse, extend_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField
from rest_framework.request import Request
@ -67,10 +67,10 @@ class TokenViewSet(ModelViewSet):
serializer.save(user=self.request.user, intent=TokenIntents.INTENT_API)
@permission_required("authentik_core.view_token_key")
@extend_schema(
@swagger_auto_schema(
responses={
200: TokenViewSerializer(many=False),
404: OpenApiResponse(description="Token not found or expired"),
404: "Token not found or expired",
}
)
@action(detail=True, pagination_class=None, filter_backends=[])

View File

@ -1,30 +1,18 @@
"""User API Views"""
from json import loads
from django.db.models.query import QuerySet
from django.http.response import Http404
from django.urls import reverse_lazy
from django.utils.http import urlencode
from django_filters.filters import BooleanFilter, CharFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field
from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method
from guardian.utils import get_anonymous_user
from rest_framework.decorators import action
from rest_framework.fields import CharField, JSONField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
BooleanField,
ListSerializer,
ModelSerializer,
ValidationError,
)
from rest_framework.serializers import BooleanField, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
from authentik.api.decorators import permission_required
from authentik.core.api.groups import GroupSerializer
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
from authentik.core.middleware import (
SESSION_IMPERSONATE_ORIGINAL_USER,
@ -32,7 +20,7 @@ from authentik.core.middleware import (
)
from authentik.core.models import Token, TokenIntents, User
from authentik.events.models import EventAction
from authentik.tenants.models import Tenant
from authentik.flows.models import Flow, FlowDesignation
class UserSerializer(ModelSerializer):
@ -41,8 +29,6 @@ class UserSerializer(ModelSerializer):
is_superuser = BooleanField(read_only=True)
avatar = CharField(read_only=True)
attributes = JSONField(validators=[is_dict], required=False)
groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
uid = CharField(read_only=True)
class Meta:
@ -54,11 +40,9 @@ class UserSerializer(ModelSerializer):
"is_active",
"last_login",
"is_superuser",
"groups",
"email",
"avatar",
"attributes",
"uid",
]
@ -77,13 +61,13 @@ class UserMetricsSerializer(PassiveSerializer):
logins_failed_per_1h = SerializerMethodField()
authorizations_per_1h = SerializerMethodField()
@extend_schema_field(CoordinateSerializer(many=True))
@swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
def get_logins_per_1h(self, _):
"""Get successful logins per hour for the last 24 hours"""
user = self.context["user"]
return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk)
@extend_schema_field(CoordinateSerializer(many=True))
@swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
def get_logins_failed_per_1h(self, _):
"""Get failed logins per hour for the last 24 hours"""
user = self.context["user"]
@ -91,7 +75,7 @@ class UserMetricsSerializer(PassiveSerializer):
action=EventAction.LOGIN_FAILED, context__username=user.username
)
@extend_schema_field(CoordinateSerializer(many=True))
@swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
def get_authorizations_per_1h(self, _):
"""Get failed logins per hour for the last 24 hours"""
user = self.context["user"]
@ -100,49 +84,18 @@ class UserMetricsSerializer(PassiveSerializer):
)
class UsersFilter(FilterSet):
"""Filter for users"""
attributes = CharFilter(
field_name="attributes",
lookup_expr="",
label="Attributes",
method="filter_attributes",
)
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
# pylint: disable=unused-argument
def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args"""
try:
value = loads(value)
except ValueError:
raise ValidationError(detail="filter: failed to parse JSON")
if not isinstance(value, dict):
raise ValidationError(detail="filter: value must be key:value mapping")
qs = {}
for key, _value in value.items():
qs[f"attributes__{key}"] = _value
return queryset.filter(**qs)
class Meta:
model = User
fields = ["username", "name", "is_active", "is_superuser", "attributes"]
class UserViewSet(ModelViewSet):
"""User Viewset"""
queryset = User.objects.none()
serializer_class = UserSerializer
search_fields = ["username", "name", "is_active"]
filterset_class = UsersFilter
filterset_fields = ["username", "name", "is_active"]
def get_queryset(self): # pragma: no cover
def get_queryset(self):
return User.objects.all().exclude(pk=get_anonymous_user().pk)
@extend_schema(responses={200: SessionUserSerializer(many=False)})
@swagger_auto_schema(responses={200: SessionUserSerializer(many=False)})
@action(detail=False, pagination_class=None, filter_backends=[])
# pylint: disable=invalid-name
def me(self, request: Request) -> Response:
@ -158,7 +111,7 @@ class UserViewSet(ModelViewSet):
return Response(serializer.data)
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
@swagger_auto_schema(responses={200: UserMetricsSerializer(many=False)})
@action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=invalid-name, unused-argument
def metrics(self, request: Request, pk: int) -> Response:
@ -169,19 +122,15 @@ class UserViewSet(ModelViewSet):
return Response(serializer.data)
@permission_required("authentik_core.reset_user_password")
@extend_schema(
responses={
"200": LinkSerializer(many=False),
"404": OpenApiResponse(description="No recovery flow found."),
},
@swagger_auto_schema(
responses={"200": LinkSerializer(many=False), "404": "No recovery flow found."},
)
@action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=invalid-name, unused-argument
def recovery(self, request: Request, pk: int) -> Response:
"""Create a temporary link that a user can use to recover their accounts"""
tenant: Tenant = request._request.tenant
# Check that there is a recovery flow, if not return an error
flow = tenant.flow_recovery
flow = Flow.with_policy(request, designation=FlowDesignation.RECOVERY)
if not flow:
raise Http404
user: User = self.get_object()
@ -192,20 +141,6 @@ class UserViewSet(ModelViewSet):
)
querystring = urlencode({"token": token.key})
link = request.build_absolute_uri(
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
+ f"?{querystring}"
reverse_lazy("authentik_flows:default-recovery") + f"?{querystring}"
)
return Response({"link": link})
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends):
if backend == ObjectPermissionsFilter:
continue
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def filter_queryset(self, queryset):
if self.request.user.has_perm("authentik_core.view_group"):
return self._filter_queryset_for_list(queryset)
return super().filter_queryset(queryset)

View File

@ -20,17 +20,12 @@ def is_dict(value: Any):
class PassiveSerializer(Serializer):
"""Base serializer class which doesn't implement create/update methods"""
def create(self, validated_data: dict) -> Model: # pragma: no cover
def create(self, validated_data: dict) -> Model:
return Model()
def update(
self, instance: Model, validated_data: dict
) -> Model: # pragma: no cover
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class Meta:
model = Model
class MetaNameSerializer(PassiveSerializer):
"""Add verbose names to response"""

View File

@ -2,10 +2,6 @@
from importlib import import_module
from django.apps import AppConfig
from django.db import ProgrammingError
from authentik.core.signals import GAUGE_MODELS
from authentik.lib.utils.reflection import get_apps
class AuthentikCoreConfig(AppConfig):
@ -19,12 +15,3 @@ class AuthentikCoreConfig(AppConfig):
def ready(self):
import_module("authentik.core.signals")
import_module("authentik.core.managed")
try:
for app in get_apps():
for model in app.get_models():
GAUGE_MODELS.labels(
model_name=model._meta.model_name,
app=model._meta.app_label,
).set(model.objects.count())
except ProgrammingError:
pass

View File

@ -4,7 +4,7 @@ from channels.generic.websocket import JsonWebsocketConsumer
from rest_framework.exceptions import AuthenticationFailed
from structlog.stdlib import get_logger
from authentik.api.authentication import token_from_header
from authentik.api.auth import token_from_header
from authentik.core.models import User
LOGGER = get_logger()

View File

@ -42,14 +42,10 @@ class RequestIDMiddleware:
if not hasattr(request, "request_id"):
request_id = uuid4().hex
setattr(request, "request_id", request_id)
LOCAL.authentik = {
"request_id": request_id,
"host": request.get_host(),
}
LOCAL.authentik = {"request_id": request_id}
response = self.get_response(request)
response[RESPONSE_HEADER_ID] = request.request_id
del LOCAL.authentik["request_id"]
del LOCAL.authentik["host"]
return response
@ -58,5 +54,4 @@ def structlog_add_request_id(logger: Logger, method_name: str, event_dict):
"""If threadlocal has authentik defined, add request_id to log"""
if hasattr(LOCAL, "authentik"):
event_dict["request_id"] = LOCAL.authentik.get("request_id", "")
event_dict["host"] = LOCAL.authentik.get("host", "")
return event_dict

View File

@ -1,40 +0,0 @@
# Generated by Django 3.2 on 2021-05-03 17:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0019_source_managed"),
]
operations = [
migrations.AddField(
model_name="source",
name="user_matching_mode",
field=models.TextField(
choices=[
("identifier", "Use the source-specific identifier"),
(
"email_link",
"Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.",
),
(
"email_deny",
"Use the user's email address, but deny enrollment when the email address already exists.",
),
(
"username_link",
"Link to a user with identical username address. Can have security implications when a username is used with another source.",
),
(
"username_deny",
"Use the user's username, but deny enrollment when the username already exists.",
),
],
default="identifier",
help_text="How the source determines if an existing user should be authenticated or a new user enrolled.",
),
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 3.2.3 on 2021-05-14 08:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0020_source_user_matching_mode"),
]
operations = [
migrations.AlterField(
model_name="application",
name="slug",
field=models.SlugField(
help_text="Internal application name, used in URLs.", unique=True
),
),
]

View File

@ -1,63 +0,0 @@
# Generated by Django 3.2.3 on 2021-05-29 22:14
import uuid
import django.db.models.deletion
from django.apps.registry import Apps
from django.conf import settings
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import authentik.core.models
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
session_keys = cache.keys(KEY_PREFIX + "*")
cache.delete_many(session_keys)
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0021_alter_application_slug"),
]
operations = [
migrations.CreateModel(
name="AuthenticatedSession",
fields=[
(
"expires",
models.DateTimeField(
default=authentik.core.models.default_token_duration
),
),
("expiring", models.BooleanField(default=True)),
(
"uuid",
models.UUIDField(
default=uuid.uuid4, primary_key=True, serialize=False
),
),
("session_key", models.CharField(max_length=40)),
("last_ip", models.TextField()),
("last_user_agent", models.TextField(blank=True)),
("last_used", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.RunPython(migrate_sessions),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 3.2.3 on 2021-06-02 21:51
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0022_authenticatedsession"),
]
operations = [
migrations.AlterField(
model_name="application",
name="meta_launch_url",
field=models.TextField(
blank=True,
default="",
validators=[django.core.validators.URLValidator()],
),
),
]

View File

@ -1,35 +0,0 @@
# Generated by Django 3.2.3 on 2021-06-03 09:33
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.models import Count
def fix_duplicates(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Token = apps.get_model("authentik_core", "token")
identifiers = (
Token.objects.using(db_alias)
.values("identifier")
.annotate(identifier_count=Count("identifier"))
.filter(identifier_count__gt=1)
)
for ident in identifiers:
Token.objects.using(db_alias).filter(identifier=ident["identifier"]).delete()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0023_alter_application_meta_launch_url"),
]
operations = [
migrations.RunPython(fix_duplicates),
migrations.AlterField(
model_name="token",
name="identifier",
field=models.SlugField(max_length=255, unique=True),
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 3.2.3 on 2021-06-05 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0024_alter_token_identifier"),
]
operations = [
migrations.AlterField(
model_name="application",
name="meta_icon",
field=models.FileField(
default=None, null=True, upload_to="application-icons/"
),
),
]

View File

@ -8,7 +8,6 @@ from uuid import uuid4
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.core import validators
from django.db import models
from django.db.models import Q, QuerySet
from django.http import HttpRequest
@ -24,18 +23,17 @@ from structlog.stdlib import get_logger
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.signals import password_changed
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.core.types import UILoginButton
from authentik.flows.challenge import Challenge
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.lib.utils.http import get_client_ip
from authentik.managed.models import ManagedModel
from authentik.policies.models import PolicyBindingModel
LOGGER = get_logger()
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
GRAVATAR_URL = "https://secure.gravatar.com"
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
@ -208,35 +206,17 @@ class Application(PolicyBindingModel):
add custom fields and other properties"""
name = models.TextField(help_text=_("Application's display Name."))
slug = models.SlugField(
help_text=_("Internal application name, used in URLs."), unique=True
)
slug = models.SlugField(help_text=_("Internal application name, used in URLs."))
provider = models.OneToOneField(
"Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
)
meta_launch_url = models.TextField(
default="", blank=True, validators=[validators.URLValidator()]
)
meta_launch_url = models.URLField(default="", blank=True)
# For template applications, this can be set to /static/authentik/applications/*
meta_icon = models.FileField(
upload_to="application-icons/", default=None, null=True
)
meta_icon = models.FileField(upload_to="application-icons/", default="", blank=True)
meta_description = models.TextField(default="", blank=True)
meta_publisher = models.TextField(default="", blank=True)
@property
def get_meta_icon(self) -> Optional[str]:
"""Get the URL to the App Icon image. If the name is /static or starts with http
it is returned as-is"""
if not self.meta_icon:
return None
if self.meta_icon.name.startswith("http") or self.meta_icon.name.startswith(
"/static"
):
return self.meta_icon.name
return self.meta_icon.url
def get_launch_url(self) -> Optional[str]:
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
if self.meta_launch_url:
@ -260,30 +240,6 @@ class Application(PolicyBindingModel):
verbose_name_plural = _("Applications")
class SourceUserMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning users"""
IDENTIFIER = "identifier", _("Use the source-specific identifier")
EMAIL_LINK = "email_link", _(
(
"Link to a user with identical email address. Can have security implications "
"when a source doesn't validate email addresses."
)
)
EMAIL_DENY = "email_deny", _(
"Use the user's email address, but deny enrollment when the email address already exists."
)
USERNAME_LINK = "username_link", _(
(
"Link to a user with identical username address. Can have security implications "
"when a username is used with another source."
)
)
USERNAME_DENY = "username_deny", _(
"Use the user's username, but deny enrollment when the username already exists."
)
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
@ -316,17 +272,6 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
related_name="source_enrollment",
)
user_matching_mode = models.TextField(
choices=SourceUserMatchingModes.choices,
default=SourceUserMatchingModes.IDENTIFIER,
help_text=_(
(
"How the source determines if an existing user should be authenticated or "
"a new user enrolled."
)
),
)
objects = InheritanceManager()
@property
@ -341,9 +286,9 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
return None
@property
def ui_user_settings(self) -> Optional[UserSettingSerializer]:
def ui_user_settings(self) -> Optional[Challenge]:
"""Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or UserSettingSerializer."""
user settings are available, or a challenge."""
return None
def __str__(self):
@ -356,8 +301,6 @@ class UserSourceConnection(CreatedUpdatedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
source = models.ForeignKey(Source, on_delete=models.CASCADE)
objects = InheritanceManager()
class Meta:
unique_together = (("user", "source"),)
@ -405,7 +348,7 @@ class Token(ManagedModel, ExpiringModel):
"""Token used to authenticate the User for API Access or confirm another Stage like Email."""
token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
identifier = models.SlugField(max_length=255, unique=True)
identifier = models.SlugField(max_length=255)
key = models.TextField(default=default_token_key)
intent = models.TextField(
choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION
@ -469,33 +412,3 @@ class PropertyMapping(SerializerModel, ManagedModel):
verbose_name = _("Property Mapping")
verbose_name_plural = _("Property Mappings")
class AuthenticatedSession(ExpiringModel):
"""Additional session class for authenticated users. Augments the standard django session
to achieve the following:
- Make it queryable by user
- Have a direct connection to user objects
- Allow users to view their own sessions and terminate them
- Save structured and well-defined information.
"""
uuid = models.UUIDField(default=uuid4, primary_key=True)
session_key = models.CharField(max_length=40)
user = models.ForeignKey(User, on_delete=models.CASCADE)
last_ip = models.TextField()
last_user_agent = models.TextField(blank=True)
last_used = models.DateTimeField(auto_now=True)
@staticmethod
def from_request(request: HttpRequest, user: User) -> "AuthenticatedSession":
"""Create a new session from a http request"""
return AuthenticatedSession(
session_key=request.session.session_key,
user=user,
last_ip=get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
expires=request.session.get_expiry_date(),
)

View File

@ -1,38 +1,20 @@
"""authentik core signals"""
from typing import TYPE_CHECKING
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.core.cache import cache
from django.core.signals import Signal
from django.db.models import Model
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.http.request import HttpRequest
from prometheus_client import Gauge
# Arguments: user: User, password: str
password_changed = Signal()
GAUGE_MODELS = Gauge(
"authentik_models", "Count of various objects", ["model_name", "app"]
)
if TYPE_CHECKING:
from authentik.core.models import User
@receiver(post_save)
# pylint: disable=unused-argument
def post_save_application(sender: type[Model], instance, created: bool, **_):
def post_save_application(sender, instance, created: bool, **_):
"""Clear user's application cache upon application creation"""
from authentik.core.api.applications import user_app_cache_key
from authentik.core.models import Application
GAUGE_MODELS.labels(
model_name=sender._meta.model_name,
app=sender._meta.app_label,
).set(sender.objects.count())
if sender != Application:
return
if not created: # pragma: no cover
@ -40,23 +22,3 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
# Also delete user application cache
keys = cache.keys(user_app_cache_key("*"))
cache.delete_many(keys)
@receiver(user_logged_in)
# pylint: disable=unused-argument
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
"""Create an AuthenticatedSession from request"""
from authentik.core.models import AuthenticatedSession
AuthenticatedSession.from_request(request, user).save()
@receiver(user_logged_out)
# pylint: disable=unused-argument
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
"""Delete AuthenticatedSession if it exists"""
from authentik.core.models import AuthenticatedSession
AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).delete()

View File

@ -1,286 +0,0 @@
"""Source decision helper"""
from enum import Enum
from typing import Any, Optional, Type
from django.contrib import messages
from django.db import IntegrityError
from django.db.models.query_utils import Q
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.translation import gettext as _
from structlog.stdlib import get_logger
from authentik.core.models import (
Source,
SourceUserMatchingModes,
User,
UserSourceConnection,
)
from authentik.core.sources.stage import (
PLAN_CONTEXT_SOURCES_CONNECTION,
PostUserEnrollmentStage,
)
from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow, Stage, in_memory_stage
from authentik.flows.planner import (
PLAN_CONTEXT_PENDING_USER,
PLAN_CONTEXT_REDIRECT,
PLAN_CONTEXT_SOURCE,
PLAN_CONTEXT_SSO,
FlowPlanner,
)
from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs
from authentik.policies.utils import delete_none_keys
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
class Action(Enum):
"""Actions that can be decided based on the request
and source settings"""
LINK = "link"
AUTH = "auth"
ENROLL = "enroll"
DENY = "deny"
class SourceFlowManager:
"""Help sources decide what they should do after authorization. Based on source settings and
previous connections, authenticate the user, enroll a new user, link to an existing user
or deny the request."""
source: Source
request: HttpRequest
identifier: str
connection_type: Type[UserSourceConnection] = UserSourceConnection
def __init__(
self,
source: Source,
request: HttpRequest,
identifier: str,
enroll_info: dict[str, Any],
) -> None:
self.source = source
self.request = request
self.identifier = identifier
self.enroll_info = enroll_info
self._logger = get_logger().bind(source=source, identifier=identifier)
# pylint: disable=too-many-return-statements
def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
"""decide which action should be taken"""
new_connection = self.connection_type(
source=self.source, identifier=self.identifier
)
# When request is authenticated, always link
if self.request.user.is_authenticated:
new_connection.user = self.request.user
new_connection = self.update_connection(new_connection, **kwargs)
new_connection.save()
return Action.LINK, new_connection
existing_connections = self.connection_type.objects.filter(
source=self.source, identifier=self.identifier
)
if existing_connections.exists():
connection = existing_connections.first()
return Action.AUTH, self.update_connection(connection, **kwargs)
# No connection exists, but we match on identifier, so enroll
if self.source.user_matching_mode == SourceUserMatchingModes.IDENTIFIER:
# We don't save the connection here cause it doesn't have a user assigned yet
return Action.ENROLL, self.update_connection(new_connection, **kwargs)
# Check for existing users with matching attributes
query = Q()
# Either query existing user based on email or username
if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_LINK,
SourceUserMatchingModes.EMAIL_DENY,
]:
if not self.enroll_info.get("email", None):
self._logger.warning("Refusing to use none email", source=self.source)
return Action.DENY, None
query = Q(email__exact=self.enroll_info.get("email", None))
if self.source.user_matching_mode in [
SourceUserMatchingModes.USERNAME_LINK,
SourceUserMatchingModes.USERNAME_DENY,
]:
if not self.enroll_info.get("username", None):
self._logger.warning(
"Refusing to use none username", source=self.source
)
return Action.DENY, None
query = Q(username__exact=self.enroll_info.get("username", None))
self._logger.debug("trying to link with existing user", query=query)
matching_users = User.objects.filter(query)
# No matching users, always enroll
if not matching_users.exists():
self._logger.debug("no matching users found, enrolling")
return Action.ENROLL, self.update_connection(new_connection, **kwargs)
user = matching_users.first()
if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_LINK,
SourceUserMatchingModes.USERNAME_LINK,
]:
new_connection.user = user
new_connection = self.update_connection(new_connection, **kwargs)
new_connection.save()
return Action.LINK, new_connection
if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_DENY,
SourceUserMatchingModes.USERNAME_DENY,
]:
self._logger.info("denying source because user exists", user=user)
return Action.DENY, None
# Should never get here as default enroll case is returned above.
return Action.DENY, None
def update_connection(
self, connection: UserSourceConnection, **kwargs
) -> UserSourceConnection:
"""Optionally make changes to the connection after it is looked up/created."""
return connection
def get_flow(self, **kwargs) -> HttpResponse:
"""Get the flow response based on user_matching_mode"""
try:
action, connection = self.get_action(**kwargs)
except IntegrityError as exc:
self._logger.warning("failed to get action", exc=exc)
return redirect("/")
self._logger.debug("get_action() says", action=action, connection=connection)
if connection:
if action == Action.LINK:
self._logger.debug("Linking existing user")
return self.handle_existing_user_link(connection)
if action == Action.AUTH:
self._logger.debug("Handling auth user")
return self.handle_auth_user(connection)
if action == Action.ENROLL:
self._logger.debug("Handling enrollment of new user")
return self.handle_enroll(connection)
# Default case, assume deny
messages.error(
self.request,
_(
(
"Request to authenticate with %(source)s has been denied. Please authenticate "
"with the source you've previously signed up with."
)
% {"source": self.source.name}
),
)
return redirect("/")
# pylint: disable=unused-argument
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
"""Hook to override stages which are appended to the flow"""
if flow.slug == self.source.enrollment_flow.slug:
return [
in_memory_stage(PostUserEnrollmentStage),
]
return []
def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse:
"""Prepare Authentication Plan, redirect user FlowExecutor"""
# Ensure redirect is carried through when user was trying to
# authorize application
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-admin"
)
kwargs.update(
{
# Since we authenticate the user by their token, they have no backend set
PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend",
PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_REDIRECT: final_redirect,
}
)
if not flow:
return HttpResponseBadRequest()
# We run the Flow planner here so we can pass the Pending user in the context
planner = FlowPlanner(flow)
plan = planner.plan(self.request, kwargs)
for stage in self.get_stages_to_append(flow):
plan.append(stage)
self.request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=flow.slug,
)
# pylint: disable=unused-argument
def handle_auth_user(
self,
connection: UserSourceConnection,
) -> HttpResponse:
"""Login user and redirect."""
messages.success(
self.request,
_(
"Successfully authenticated with %(source)s!"
% {"source": self.source.name}
),
)
flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs)
def handle_existing_user_link(
self,
connection: UserSourceConnection,
) -> HttpResponse:
"""Handler when the user was already authenticated and linked an external source
to their account."""
# Connection has already been saved
Event.new(
EventAction.SOURCE_LINKED,
message="Linked Source",
source=self.source,
).from_http(self.request)
messages.success(
self.request,
_("Successfully linked %(source)s!" % {"source": self.source.name}),
)
# When request isn't authenticated we jump straight to auth
if not self.request.user.is_authenticated:
return self.handle_auth_user(connection)
return redirect(
reverse(
"authentik_core:if-admin",
)
+ f"#/user;page-{self.source.slug}"
)
def handle_enroll(
self,
connection: UserSourceConnection,
) -> HttpResponse:
"""User was not authenticated and previous request was not authenticated."""
messages.success(
self.request,
_(
"Successfully authenticated with %(source)s!"
% {"source": self.source.name}
),
)
# We run the Flow planner here so we can pass the Pending user in the context
if not self.source.enrollment_flow:
self._logger.warning("source has no enrollment flow")
return HttpResponseBadRequest()
return self._handle_login_flow(
self.source.enrollment_flow,
**{
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
},
)

View File

@ -75,6 +75,5 @@ def backup_database(self: MonitoredTask): # pragma: no cover
Boto3Error,
PermissionError,
CommandConnectorError,
ValueError,
) as exc:
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))

View File

@ -7,15 +7,16 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title>
<title>{% block title %}{% trans title|default:config.authentik.branding.title %}{% endblock %}</title>
<link rel="icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}">
<link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}">
{% block head_before %}
{% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}">
<script src="{% static 'dist/poly.js' %}?v={{ ak_version }}" type="module"></script>
<script>window["polymerSkipLoadingFontRoboto"] = true;</script>
{% block head %}

View File

@ -4,9 +4,7 @@
{% load i18n %}
{% block head_before %}
{% if flow.compatibility_mode %}
<script>ShadyDOM = { force: !navigator.webdriver };</script>
{% endif %}
{% endblock %}
{% block head %}

View File

@ -3,10 +3,6 @@
{% load static %}
{% load i18n %}
{% block head_before %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}?v={{ ak_version }}">
{% endblock %}
{% block body %}
<div class="pf-c-background-image">
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
@ -26,7 +22,10 @@
<div class="ak-login-container">
<header class="pf-c-login__header">
<div class="pf-c-brand ak-brand">
<img src="{{ tenant.branding_logo }}" alt="authentik icon" />
<img src="{{ config.authentik.branding.logo }}" alt="authentik icon" />
{% if config.authentik.branding.title_show %}
<p>{{ config.authentik.branding.title }}</p>
{% endif %}
</div>
</header>
{% block main_container %}
@ -46,12 +45,12 @@
<footer class="pf-c-login__footer">
<p></p>
<ul class="pf-c-list pf-m-inline">
{% for link in footer_links %}
{% for link in config.authentik.footer_links %}
<li>
<a href="{{ link.href }}">{{ link.name }}</a>
</li>
{% endfor %}
{% if tenant.branding_title != "authentik" %}
{% if config.authentik.branding.title != "authentik" %}
<li>
<a href="https://goauthentik.io">
{% trans 'Powered by authentik' %}

View File

@ -1,131 +0,0 @@
"""Test Applications API"""
from django.urls import reverse
from django.utils.encoding import force_str
from rest_framework.test import APITestCase
from authentik.core.models import Application, User
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
class TestApplicationsAPI(APITestCase):
"""Test applications API"""
def setUp(self) -> None:
self.user = User.objects.get(username="akadmin")
self.allowed = Application.objects.create(name="allowed", slug="allowed")
self.denied = Application.objects.create(name="denied", slug="denied")
PolicyBinding.objects.create(
target=self.denied,
policy=DummyPolicy.objects.create(
name="deny", result=False, wait_min=1, wait_max=2
),
order=0,
)
def test_check_access(self):
"""Test check_access operation"""
self.client.force_login(self.user)
response = self.client.post(
reverse(
"authentik_api:application-check-access",
kwargs={"slug": self.allowed.slug},
)
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content), {"messages": [], "passing": True}
)
response = self.client.post(
reverse(
"authentik_api:application-check-access",
kwargs={"slug": self.denied.slug},
)
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content), {"messages": ["dummy"], "passing": False}
)
def test_list(self):
"""Test list operation without superuser_full_list"""
self.client.force_login(self.user)
response = self.client.get(reverse("authentik_api:application-list"))
self.assertJSONEqual(
force_str(response.content),
{
"pagination": {
"next": 0,
"previous": 0,
"count": 2,
"current": 1,
"total_pages": 1,
"start_index": 1,
"end_index": 2,
},
"results": [
{
"pk": str(self.allowed.pk),
"name": "allowed",
"slug": "allowed",
"provider": None,
"provider_obj": None,
"launch_url": None,
"meta_launch_url": "",
"meta_icon": None,
"meta_description": "",
"meta_publisher": "",
"policy_engine_mode": "any",
},
],
},
)
def test_list_superuser_full_list(self):
"""Test list operation with superuser_full_list"""
self.client.force_login(self.user)
response = self.client.get(
reverse("authentik_api:application-list") + "?superuser_full_list=true"
)
self.assertJSONEqual(
force_str(response.content),
{
"pagination": {
"next": 0,
"previous": 0,
"count": 2,
"current": 1,
"total_pages": 1,
"start_index": 1,
"end_index": 2,
},
"results": [
{
"pk": str(self.allowed.pk),
"name": "allowed",
"slug": "allowed",
"provider": None,
"provider_obj": None,
"launch_url": None,
"meta_launch_url": "",
"meta_icon": None,
"meta_description": "",
"meta_publisher": "",
"policy_engine_mode": "any",
},
{
"launch_url": None,
"meta_description": "",
"meta_icon": None,
"meta_launch_url": "",
"meta_publisher": "",
"name": "denied",
"pk": str(self.denied.pk),
"policy_engine_mode": "any",
"provider": None,
"provider_obj": None,
"slug": "denied",
},
],
},
)

View File

@ -1,31 +0,0 @@
"""Test AuthenticatedSessions API"""
from json import loads
from django.urls.base import reverse
from django.utils.encoding import force_str
from rest_framework.test import APITestCase
from authentik.core.models import User
class TestAuthenticatedSessionsAPI(APITestCase):
"""Test AuthenticatedSessions API"""
def setUp(self) -> None:
super().setUp()
self.user = User.objects.get(username="akadmin")
self.other_user = User.objects.create(username="normal-user")
def test_list(self):
"""Test session list endpoint"""
self.client.force_login(self.user)
response = self.client.get(reverse("authentik_api:authenticatedsession-list"))
self.assertEqual(response.status_code, 200)
def test_non_admin_list(self):
"""Test non-admin list"""
self.client.force_login(self.other_user)
response = self.client.get(reverse("authentik_api:authenticatedsession-list"))
self.assertEqual(response.status_code, 200)
body = loads(force_str(response.content))
self.assertEqual(body["pagination"]["count"], 1)

View File

@ -1,14 +1,11 @@
"""authentik core models tests"""
from time import sleep
from typing import Callable, Type
from django.test import TestCase
from django.utils.timezone import now
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import Provider, Source, Token
from authentik.flows.models import Stage
from authentik.lib.utils.reflection import all_subclasses
from authentik.core.models import Token
class TestModels(TestCase):
@ -21,46 +18,9 @@ class TestModels(TestCase):
self.assertTrue(token.is_expired)
def test_token_expire_no_expire(self):
"""Test token expiring with "expiring" set"""
"""Test token expiring with "expiring" set """
token = Token.objects.create(
expires=now(), user=get_anonymous_user(), expiring=False
)
sleep(0.5)
self.assertFalse(token.is_expired)
def source_tester_factory(test_model: Type[Stage]) -> Callable:
"""Test source"""
def tester(self: TestModels):
model_class = None
if test_model._meta.abstract:
model_class = test_model.__bases__[0]()
else:
model_class = test_model()
model_class.slug = "test"
self.assertIsNotNone(model_class.component)
_ = model_class.ui_login_button
_ = model_class.ui_user_settings
return tester
def provider_tester_factory(test_model: Type[Stage]) -> Callable:
"""Test provider"""
def tester(self: TestModels):
model_class = None
if test_model._meta.abstract:
model_class = test_model.__bases__[0]()
else:
model_class = test_model()
self.assertIsNotNone(model_class.component)
return tester
for model in all_subclasses(Source):
setattr(TestModels, f"test_model_{model.__name__}", source_tester_factory(model))
for model in all_subclasses(Provider):
setattr(TestModels, f"test_model_{model.__name__}", provider_tester_factory(model))

View File

@ -1,29 +0,0 @@
"""Test Users API"""
from django.urls.base import reverse
from rest_framework.test import APITestCase
from authentik.core.models import User
class TestUsersAPI(APITestCase):
"""Test Users API"""
def setUp(self) -> None:
self.admin = User.objects.get(username="akadmin")
self.user = User.objects.create(username="test-user")
def test_metrics(self):
"""Test user's metrics"""
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 200)
def test_metrics_denied(self):
"""Test user's metrics (non-superuser)"""
self.client.force_login(self.user)
response = self.client.get(
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 403)

View File

@ -2,10 +2,9 @@
from dataclasses import dataclass
from typing import Optional
from rest_framework.fields import CharField, DictField
from rest_framework.fields import CharField
from authentik.core.api.utils import PassiveSerializer
from authentik.flows.challenge import Challenge
@dataclass
@ -15,8 +14,8 @@ class UILoginButton:
# Name, ran through i18n
name: str
# Challenge which is presented to the user when they click the button
challenge: Challenge
# URL Which Button points to
url: str
# Icon URL, used as-is
icon_url: Optional[str] = None
@ -26,7 +25,7 @@ class UILoginButtonSerializer(PassiveSerializer):
"""Serializer for Login buttons of sources"""
name = CharField()
challenge = DictField()
url = CharField()
icon_url = CharField(required=False, allow_null=True)
@ -36,4 +35,3 @@ class UserSettingSerializer(PassiveSerializer):
object_uid = CharField()
component = CharField()
title = CharField()
configure_url = CharField()

View File

@ -6,8 +6,6 @@ from django.views.generic import RedirectView
from django.views.generic.base import TemplateView
from authentik.core.views import impersonate
from authentik.core.views.interface import FlowInterfaceView
from authentik.core.views.session import EndSessionView
urlpatterns = [
path(
@ -34,18 +32,7 @@ urlpatterns = [
),
path(
"if/flow/<slug:flow_slug>/",
ensure_csrf_cookie(FlowInterfaceView.as_view()),
ensure_csrf_cookie(TemplateView.as_view(template_name="if/flow.html")),
name="if-flow",
),
path(
"if/session-end/<slug:application_slug>/",
ensure_csrf_cookie(EndSessionView.as_view()),
name="if-session-end",
),
# Fallback for WS
path("ws/outpost/<uuid:pk>/", TemplateView.as_view(template_name="if/admin.html")),
path(
"ws/client/",
TemplateView.as_view(template_name="if/admin.html"),
),
]

View File

@ -1,17 +0,0 @@
"""Interface views"""
from typing import Any
from django.shortcuts import get_object_or_404
from django.views.generic.base import TemplateView
from authentik.flows.models import Flow
class FlowInterfaceView(TemplateView):
"""Flow interface"""
template_name = "if/flow.html"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
return super().get_context_data(**kwargs)

View File

@ -3,10 +3,8 @@ import django_filters
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import load_pem_x509_certificate
from django.http.response import HttpResponse
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import (
CharField,
@ -41,7 +39,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
"""Show if this keypair has a private key configured or not"""
return instance.key_data != "" and instance.key_data is not None
def validate_certificate_data(self, value: str) -> str:
def validate_certificate_data(self, value):
"""Verify that input is a valid PEM x509 Certificate"""
try:
load_pem_x509_certificate(value.encode("utf-8"), default_backend())
@ -49,7 +47,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
raise ValidationError("Unable to load certificate.")
return value
def validate_key_data(self, value: str) -> str:
def validate_key_data(self, value):
"""Verify that input is a valid PEM RSA Key"""
# Since this field is optional, data can be empty.
if value != "":
@ -59,10 +57,8 @@ class CertificateKeyPairSerializer(ModelSerializer):
password=None,
backend=default_backend(),
)
except (ValueError, TypeError):
raise ValidationError(
"Unable to load private key (possibly encrypted?)."
)
except ValueError:
raise ValidationError("Unable to load private key.")
return value
class Meta:
@ -125,12 +121,9 @@ class CertificateKeyPairViewSet(ModelViewSet):
filterset_class = CertificateKeyPairFilter
@permission_required(None, ["authentik_crypto.add_certificatekeypair"])
@extend_schema(
request=CertificateGenerationSerializer(),
responses={
200: CertificateKeyPairSerializer,
400: OpenApiResponse(description="Bad request"),
},
@swagger_auto_schema(
request_body=CertificateGenerationSerializer(),
responses={200: CertificateKeyPairSerializer, 400: "Bad request"},
)
@action(detail=False, methods=["POST"])
def generate(self, request: Request) -> Response:
@ -150,16 +143,7 @@ class CertificateKeyPairViewSet(ModelViewSet):
serializer = self.get_serializer(instance)
return Response(serializer.data)
@extend_schema(
parameters=[
OpenApiParameter(
name="download",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
)
],
responses={200: CertificateDataSerializer(many=False)},
)
@swagger_auto_schema(responses={200: CertificateDataSerializer(many=False)})
@action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=invalid-name, unused-argument
def view_certificate(self, request: Request, pk: str) -> Response:
@ -170,29 +154,11 @@ class CertificateKeyPairViewSet(ModelViewSet):
secret=certificate,
type="certificate",
).from_http(request)
if "download" in request._request.GET:
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
response = HttpResponse(
certificate.certificate_data, content_type="application/x-pem-file"
)
response[
"Content-Disposition"
] = f'attachment; filename="{certificate.name}_certificate.pem"'
return response
return Response(
CertificateDataSerializer({"data": certificate.certificate_data}).data
)
@extend_schema(
parameters=[
OpenApiParameter(
name="download",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
)
],
responses={200: CertificateDataSerializer(many=False)},
)
@swagger_auto_schema(responses={200: CertificateDataSerializer(many=False)})
@action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=invalid-name, unused-argument
def view_private_key(self, request: Request, pk: str) -> Response:
@ -203,13 +169,4 @@ class CertificateKeyPairViewSet(ModelViewSet):
secret=certificate,
type="private_key",
).from_http(request)
if "download" in request._request.GET:
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
response = HttpResponse(
certificate.key_data, content_type="application/x-pem-file"
)
response[
"Content-Disposition"
] = f'attachment; filename="{certificate.name}_private_key.pem"'
return response
return Response(CertificateDataSerializer({"data": certificate.key_data}).data)

View File

@ -33,7 +33,7 @@ class CertificateBuilder:
def save(self) -> Optional[CertificateKeyPair]:
"""Save generated certificate as model"""
if not self.__certificate:
raise ValueError("Certificated hasn't been built yet")
return None
return CertificateKeyPair.objects.create(
name=self.common_name,
certificate_data=self.certificate,

View File

@ -2,9 +2,7 @@
import datetime
from django.test import TestCase
from django.urls import reverse
from authentik.core.models import User
from authentik.crypto.api import CertificateKeyPairSerializer
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair
@ -39,8 +37,6 @@ class TestCrypto(TestCase):
"""Test Builder"""
builder = CertificateBuilder()
builder.common_name = "test-cert"
with self.assertRaises(ValueError):
builder.save()
builder.build(
subject_alt_names=[],
validity_days=3,
@ -49,45 +45,3 @@ class TestCrypto(TestCase):
now = datetime.datetime.today()
self.assertEqual(instance.name, "test-cert")
self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
def test_certificate_download(self):
"""Test certificate export (download)"""
self.client.force_login(User.objects.get(username="akadmin"))
keypair = CertificateKeyPair.objects.first()
response = self.client.get(
reverse(
"authentik_api:certificatekeypair-view-certificate",
kwargs={"pk": keypair.pk},
)
)
self.assertEqual(200, response.status_code)
response = self.client.get(
reverse(
"authentik_api:certificatekeypair-view-certificate",
kwargs={"pk": keypair.pk},
)
+ "?download",
)
self.assertEqual(200, response.status_code)
self.assertIn("Content-Disposition", response)
def test_private_key_download(self):
"""Test private_key export (download)"""
self.client.force_login(User.objects.get(username="akadmin"))
keypair = CertificateKeyPair.objects.first()
response = self.client.get(
reverse(
"authentik_api:certificatekeypair-view-private-key",
kwargs={"pk": keypair.pk},
)
)
self.assertEqual(200, response.status_code)
response = self.client.get(
reverse(
"authentik_api:certificatekeypair-view-private-key",
kwargs={"pk": keypair.pk},
)
+ "?download",
)
self.assertEqual(200, response.status_code)
self.assertIn("Content-Disposition", response)

View File

@ -2,17 +2,16 @@
import django_filters
from django.db.models.aggregates import Count
from django.db.models.fields.json import KeyTextTransform
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from drf_yasg.utils import swagger_auto_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.fields import CharField, DictField, IntegerField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ReadOnlyModelViewSet
from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
from authentik.core.api.utils import TypeCreateSerializer
from authentik.events.models import Event, EventAction
@ -39,13 +38,31 @@ class EventSerializer(ModelSerializer):
]
class EventTopPerUserSerializer(PassiveSerializer):
class EventTopPerUserParams(Serializer):
"""Query params for top_per_user"""
top_n = IntegerField(default=15)
def create(self, request: Request) -> Response:
raise NotImplementedError
def update(self, request: Request) -> Response:
raise NotImplementedError
class EventTopPerUserSerializer(Serializer):
"""Response object of Event's top_per_user"""
application = DictField()
counted_events = IntegerField()
unique_users = IntegerField()
def create(self, request: Request) -> Response:
raise NotImplementedError
def update(self, request: Request) -> Response:
raise NotImplementedError
class EventsFilter(django_filters.FilterSet):
"""Filter for events"""
@ -106,23 +123,16 @@ class EventViewSet(ReadOnlyModelViewSet):
]
filterset_class = EventsFilter
@extend_schema(
methods=["GET"],
@swagger_auto_schema(
method="GET",
responses={200: EventTopPerUserSerializer(many=True)},
parameters=[
OpenApiParameter(
"top_n",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
)
],
query_serializer=EventTopPerUserParams,
)
@action(detail=False, methods=["GET"], pagination_class=None)
@action(detail=False, methods=["GET"])
def top_per_user(self, request: Request):
"""Get the top_n events grouped by user count"""
filtered_action = request.query_params.get("action", EventAction.LOGIN)
top_n = int(request.query_params.get("top_n", "15"))
top_n = request.query_params.get("top_n", 15)
return Response(
get_objects_for_user(request.user, "authentik_events.view_event")
.filter(action=filtered_action)
@ -136,7 +146,7 @@ class EventViewSet(ReadOnlyModelViewSet):
.order_by("-counted_events")[:top_n]
)
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def actions(self, request: Request) -> Response:
"""Get all actions"""

View File

@ -1,12 +1,9 @@
"""Notification API Views"""
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import mixins
from rest_framework.fields import ReadOnlyField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet
from authentik.api.authorization import OwnerFilter, OwnerPermissions
from authentik.events.api.event import EventSerializer
from authentik.events.models import Notification
@ -49,5 +46,8 @@ class NotificationViewSet(
"event",
"seen",
]
permission_classes = [OwnerPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
def get_queryset(self):
if not self.request:
return super().get_queryset()
return Notification.objects.filter(user=self.request.user)

View File

@ -2,25 +2,22 @@
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.groups import GroupSerializer
from authentik.events.models import NotificationRule
class NotificationRuleSerializer(ModelSerializer):
"""NotificationRule Serializer"""
group_obj = GroupSerializer(read_only=True, source="group")
class Meta:
model = NotificationRule
depth = 2
fields = [
"pk",
"name",
"transports",
"severity",
"group",
"group_obj",
]

View File

@ -1,6 +1,5 @@
"""NotificationTransport API Views"""
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema
from drf_yasg.utils import no_body, swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.request import Request
@ -23,7 +22,7 @@ class NotificationTransportSerializer(ModelSerializer):
mode_verbose = SerializerMethodField()
def get_mode_verbose(self, instance: NotificationTransport) -> str:
def get_mode_verbose(self, instance: NotificationTransport):
"""Return selected mode with a UI Label"""
return TransportMode(instance.mode).label
@ -59,12 +58,12 @@ class NotificationTransportViewSet(ModelViewSet):
serializer_class = NotificationTransportSerializer
@permission_required("authentik_events.change_notificationtransport")
@extend_schema(
@swagger_auto_schema(
responses={
200: NotificationTransportTestSerializer(many=False),
500: OpenApiResponse(description="Failed to test transport"),
503: "Failed to test transport",
},
request=OpenApiTypes.NONE,
request_body=no_body,
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
# pylint: disable=invalid-name, unused-argument
@ -84,4 +83,4 @@ class NotificationTransportViewSet(ModelViewSet):
response.is_valid()
return Response(response.data)
except NotificationTransportError as exc:
return Response(str(exc.__cause__ or None), status=500)
return Response(str(exc.__cause__ or None), status=503)

View File

@ -1,10 +1,7 @@
"""authentik events app"""
from datetime import timedelta
from importlib import import_module
from django.apps import AppConfig
from django.db import ProgrammingError
from django.utils.timezone import now
class AuthentikEventsConfig(AppConfig):
@ -16,12 +13,3 @@ class AuthentikEventsConfig(AppConfig):
def ready(self):
import_module("authentik.events.signals")
try:
from authentik.events.models import Event
date_from = now() - timedelta(days=1)
for event in Event.objects.filter(created__gte=date_from):
event._set_prom_metrics()
except ProgrammingError:
pass

View File

@ -1,12 +1,7 @@
"""events GeoIP Reader"""
from datetime import datetime
from os import stat
from time import time
from typing import Optional, TypedDict
from typing import Optional
from geoip2.database import Reader
from geoip2.errors import GeoIP2Error
from geoip2.models import City
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
@ -14,78 +9,17 @@ from authentik.lib.config import CONFIG
LOGGER = get_logger()
class GeoIPDict(TypedDict):
"""GeoIP Details"""
continent: str
country: str
lat: float
long: float
city: str
def get_geoip_reader() -> Optional[Reader]:
"""Get GeoIP Reader, if configured, otherwise none"""
path = CONFIG.y("authentik.geoip")
if path == "" or not path:
return None
try:
reader = Reader(path)
LOGGER.info("Enabled GeoIP support")
return reader
except OSError:
return None
class GeoIPReader:
"""Slim wrapper around GeoIP API"""
__reader: Optional[Reader] = None
__last_mtime: float = 0.0
def __init__(self):
self.__open()
def __open(self):
"""Get GeoIP Reader, if configured, otherwise none"""
path = CONFIG.y("authentik.geoip")
if path == "" or not path:
return
try:
reader = Reader(path)
LOGGER.info("Loaded GeoIP database")
self.__reader = reader
self.__last_mtime = stat(path).st_mtime
except OSError as exc:
LOGGER.warning("Failed to load GeoIP database", exc=exc)
def __check_expired(self):
"""Check if the geoip database has been opened longer than 8 hours,
and re-open it, as it will probably will have been re-downloaded"""
now = time()
diff = datetime.fromtimestamp(now) - datetime.fromtimestamp(self.__last_mtime)
diff_hours = diff.total_seconds() // 3600
if diff_hours >= 8:
LOGGER.info("GeoIP databased loaded too long, re-opening", diff=diff)
self.__open()
@property
def enabled(self) -> bool:
"""Check if GeoIP is enabled"""
return bool(self.__reader)
def city(self, ip_address: str) -> Optional[City]:
"""Wrapper for Reader.city"""
if not self.enabled:
return None
self.__check_expired()
try:
return self.__reader.city(ip_address)
except (GeoIP2Error, ValueError):
return None
def city_dict(self, ip_address: str) -> Optional[GeoIPDict]:
"""Wrapper for self.city that returns a dict"""
city = self.city(ip_address)
if not city:
return None
city_dict: GeoIPDict = {
"continent": city.continent.code,
"country": city.country.iso_code,
"lat": city.location.latitude,
"long": city.location.longitude,
"city": "",
}
if city.city.name:
city_dict["city"] = city.city.name
return city_dict
GEOIP_READER = GeoIPReader()
GEOIP_READER = get_geoip_reader()

View File

@ -1,45 +0,0 @@
# Generated by Django 3.2.3 on 2021-06-09 07:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0014_expiry"),
]
operations = [
migrations.AlterField(
model_name="event",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("secret_view", "Secret View"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("configuration_error", "Configuration Error"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("email_sent", "Email Sent"),
("update_available", "Update Available"),
("custom_", "Custom Prefix"),
]
),
),
]

View File

@ -10,7 +10,7 @@ from django.db import models
from django.http import HttpRequest
from django.utils.timezone import now
from django.utils.translation import gettext as _
from prometheus_client import Gauge
from geoip2.errors import GeoIP2Error
from requests import RequestException, post
from structlog.stdlib import get_logger
@ -28,11 +28,6 @@ from authentik.policies.models import PolicyBindingModel
from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger("authentik.events")
GAUGE_EVENTS = Gauge(
"authentik_events",
"Events in authentik",
["action", "user_username", "app", "client_ip"],
)
def default_event_duration():
@ -77,7 +72,6 @@ class EventAction(models.TextChoices):
MODEL_CREATED = "model_created"
MODEL_UPDATED = "model_updated"
MODEL_DELETED = "model_deleted"
EMAIL_SENT = "email_sent"
UPDATE_AVAILABLE = "update_available"
@ -149,7 +143,7 @@ class Event(ExpiringModel):
request.session[SESSION_IMPERSONATE_USER]
)
# User 255.255.255.255 as fallback if IP cannot be determined
self.client_ip = get_client_ip(request)
self.client_ip = get_client_ip(request) or "255.255.255.255"
# Apply GeoIP Data, when enabled
self.with_geoip()
# If there's no app set, we get it from the requests too
@ -158,20 +152,22 @@ class Event(ExpiringModel):
self.save()
return self
def with_geoip(self): # pragma: no cover
def with_geoip(self):
"""Apply GeoIP Data, when enabled"""
city = GEOIP_READER.city_dict(self.client_ip)
if not city:
if not GEOIP_READER:
return
self.context["geo"] = city
def _set_prom_metrics(self):
GAUGE_EVENTS.labels(
action=self.action,
user_username=self.user.get("username"),
app=self.app,
client_ip=self.client_ip,
).set(self.created.timestamp())
try:
response = GEOIP_READER.city(self.client_ip)
self.context["geo"] = {
"continent": response.continent.code,
"country": response.country.iso_code,
"lat": response.location.latitude,
"long": response.location.longitude,
}
if response.city.name:
self.context["geo"]["city"] = response.city.name
except GeoIP2Error as exc:
LOGGER.warning("Failed to add geoIP Data to event", exc=exc)
def save(self, *args, **kwargs):
if self._state.adding:
@ -182,8 +178,7 @@ class Event(ExpiringModel):
client_ip=self.client_ip,
user=self.user,
)
super().save(*args, **kwargs)
self._set_prom_metrics()
return super().save(*args, **kwargs)
@property
def summary(self) -> str:

View File

@ -2,22 +2,14 @@
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from timeit import default_timer
from traceback import format_tb
from typing import Any, Optional
from celery import Task
from django.core.cache import cache
from prometheus_client import Gauge
from authentik.events.models import Event, EventAction
GAUGE_TASKS = Gauge(
"authentik_system_tasks",
"System tasks and their status",
["task_name", "task_uid", "status"],
)
class TaskResultStatus(Enum):
"""Possible states of tasks"""
@ -51,9 +43,7 @@ class TaskInfo:
"""Info about a task run"""
task_name: str
start_timestamp: float
finish_timestamp: float
finish_time: datetime
finish_timestamp: datetime
result: TaskResult
@ -83,28 +73,12 @@ class TaskInfo:
"""Delete task info from cache"""
return cache.delete(f"task_{self.task_name}")
def set_prom_metrics(self):
"""Update prometheus metrics"""
start = default_timer()
if hasattr(self, "start_timestamp"):
start = self.start_timestamp
try:
duration = max(self.finish_timestamp - start, 0)
except TypeError:
duration = 0
GAUGE_TASKS.labels(
task_name=self.task_name,
task_uid=self.result.uid or "",
status=self.result.status,
).set(duration)
def save(self, timeout_hours=6):
"""Save task into cache"""
key = f"task_{self.task_name}"
if self.result.uid:
key += f"_{self.result.uid}"
self.task_name += f"_{self.result.uid}"
self.set_prom_metrics()
cache.set(key, self, timeout=timeout_hours * 60 * 60)
@ -124,7 +98,6 @@ class MonitoredTask(Task):
self._uid = None
self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[])
self.result_timeout_hours = 6
self.start = default_timer()
def set_uid(self, uid: str):
"""Set UID, so in the case of an unexpected error its saved correctly"""
@ -144,9 +117,7 @@ class MonitoredTask(Task):
TaskInfo(
task_name=self.__name__,
task_description=self.__doc__,
start_timestamp=self.start,
finish_timestamp=default_timer(),
finish_time=datetime.now(),
finish_timestamp=datetime.now(),
result=self._result,
task_call_module=self.__module__,
task_call_func=self.__name__,
@ -162,9 +133,7 @@ class MonitoredTask(Task):
TaskInfo(
task_name=self.__name__,
task_description=self.__doc__,
start_timestamp=self.start,
finish_timestamp=default_timer(),
finish_time=datetime.now(),
finish_timestamp=datetime.now(),
result=self._result,
task_call_module=self.__module__,
task_call_func=self.__name__,
@ -182,7 +151,3 @@ class MonitoredTask(Task):
def run(self, *args, **kwargs):
raise NotImplementedError
for task in TaskInfo.all().values():
task.set_prom_metrics()

View File

@ -1,6 +1,6 @@
"""Event notification tasks"""
from guardian.shortcuts import get_anonymous_user
from structlog.stdlib import get_logger
from structlog import get_logger
from authentik.core.models import User
from authentik.events.models import (
@ -35,10 +35,7 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid)
return
event: Event = events.first()
triggers: NotificationRule = NotificationRule.objects.filter(name=trigger_name)
if not triggers.exists():
return
trigger = triggers.first()
trigger: NotificationRule = NotificationRule.objects.get(name=trigger_name)
if "policy_uuid" in event.context:
policy_uuid = event.context["policy_uuid"]
@ -61,13 +58,7 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
return
LOGGER.debug("e(trigger): checking if trigger applies", trigger=trigger)
try:
user = (
User.objects.filter(pk=event.user.get("pk")).first() or get_anonymous_user()
)
except User.DoesNotExist:
LOGGER.warning("e(trigger): failed to get user", trigger=trigger)
return
user = User.objects.filter(pk=event.user.get("pk")).first() or get_anonymous_user()
policy_engine = PolicyEngine(trigger, user)
policy_engine.mode = PolicyEngineMode.MODE_ANY
policy_engine.empty_result = False

View File

@ -1,26 +0,0 @@
"""Test GeoIP Wrapper"""
from django.test import TestCase
from authentik.events.geo import GeoIPReader
class TestGeoIP(TestCase):
"""Test GeoIP Wrapper"""
def setUp(self) -> None:
self.reader = GeoIPReader()
def test_simple(self):
"""Test simple city wrapper"""
# IPs from
# https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
self.assertEqual(
self.reader.city_dict("2.125.160.216"),
{
"city": "Boxford",
"continent": "EU",
"country": "GB",
"lat": 51.75,
"long": -1.25,
},
)

View File

@ -6,11 +6,10 @@ from django.db.models import Model
from django.http.response import HttpResponseBadRequest, JsonResponse
from django.urls import reverse
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from drf_yasg import openapi
from drf_yasg.utils import no_body, swagger_auto_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.fields import BooleanField, FileField, ReadOnlyField
from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
@ -42,9 +41,7 @@ class FlowSerializer(ModelSerializer):
cache_count = SerializerMethodField()
background = ReadOnlyField(source="background_url")
def get_cache_count(self, flow: Flow) -> int:
def get_cache_count(self, flow: Flow):
"""Get count of cached flows"""
return len(cache.keys(f"{cache_key(flow)}*"))
@ -63,11 +60,7 @@ class FlowSerializer(ModelSerializer):
"policies",
"cache_count",
"policy_engine_mode",
"compatibility_mode",
]
extra_kwargs = {
"background": {"read_only": True},
}
class FlowDiagramSerializer(Serializer):
@ -104,19 +97,16 @@ class FlowViewSet(ModelViewSet):
filterset_fields = ["flow_uuid", "name", "slug", "designation"]
@permission_required(None, ["authentik_flows.view_flow_cache"])
@extend_schema(responses={200: CacheSerializer(many=False)})
@swagger_auto_schema(responses={200: CacheSerializer(many=False)})
@action(detail=False, pagination_class=None, filter_backends=[])
def cache_info(self, request: Request) -> Response:
"""Info about cached flows"""
return Response(data={"count": len(cache.keys("flow_*"))})
@permission_required(None, ["authentik_flows.clear_flow_cache"])
@extend_schema(
request=OpenApiTypes.NONE,
responses={
204: OpenApiResponse(description="Successfully cleared cache"),
400: OpenApiResponse(description="Bad request"),
},
@swagger_auto_schema(
request_body=no_body,
responses={204: "Successfully cleared cache", 400: "Bad request"},
)
@action(detail=False, methods=["POST"])
def cache_clear(self, request: Request) -> Response:
@ -143,16 +133,17 @@ class FlowViewSet(ModelViewSet):
"authentik_stages_prompt.change_prompt",
],
)
@extend_schema(
request={
"multipart/form-data": inline_serializer(
"SetIcon", fields={"file": FileField()}
@swagger_auto_schema(
request_body=no_body,
manual_parameters=[
openapi.Parameter(
name="file",
in_=openapi.IN_FORM,
type=openapi.TYPE_FILE,
required=True,
)
},
responses={
204: OpenApiResponse(description="Successfully imported flow"),
400: OpenApiResponse(description="Bad request"),
},
],
responses={204: "Successfully imported flow", 400: "Bad request"},
)
@action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,))
def import_flow(self, request: Request) -> Response:
@ -166,8 +157,8 @@ class FlowViewSet(ModelViewSet):
return HttpResponseBadRequest()
successful = importer.apply()
if not successful:
return HttpResponseBadRequest()
return Response(status=204)
return Response(status=204)
return HttpResponseBadRequest()
@permission_required(
"authentik_flows.export_flow",
@ -180,9 +171,11 @@ class FlowViewSet(ModelViewSet):
"authentik_stages_prompt.view_prompt",
],
)
@extend_schema(
@swagger_auto_schema(
responses={
"200": OpenApiResponse(response=OpenApiTypes.BINARY),
"200": openapi.Response(
"File Attachment", schema=openapi.Schema(type=openapi.TYPE_FILE)
),
},
)
@action(detail=True, pagination_class=None, filter_backends=[])
@ -195,7 +188,7 @@ class FlowViewSet(ModelViewSet):
response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"'
return response
@extend_schema(responses={200: FlowDiagramSerializer()})
@swagger_auto_schema(responses={200: FlowDiagramSerializer()})
@action(detail=True, pagination_class=None, filter_backends=[], methods=["get"])
# pylint: disable=unused-argument
def diagram(self, request: Request, slug: str) -> Response:
@ -217,7 +210,6 @@ class FlowViewSet(ModelViewSet):
request.user, "authentik_policies.view_policybinding"
)
.filter(target=stage_binding)
.exclude(policy__isnull=True)
.order_by("order")
):
body.append(
@ -266,20 +258,17 @@ class FlowViewSet(ModelViewSet):
return Response({"diagram": diagram})
@permission_required("authentik_flows.change_flow")
@extend_schema(
request={
"multipart/form-data": inline_serializer(
"SetIcon",
fields={
"file": FileField(required=False),
"clear": BooleanField(default=False),
},
@swagger_auto_schema(
request_body=no_body,
manual_parameters=[
openapi.Parameter(
name="file",
in_=openapi.IN_FORM,
type=openapi.TYPE_FILE,
required=True,
)
},
responses={
200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"),
},
],
responses={200: "Success", 400: "Bad request"},
)
@action(
detail=True,
@ -291,49 +280,16 @@ class FlowViewSet(ModelViewSet):
# pylint: disable=unused-argument
def set_background(self, request: Request, slug: str):
"""Set Flow background"""
flow: Flow = self.get_object()
background = request.FILES.get("file", None)
clear = request.data.get("clear", False)
if clear:
# .delete() saves the model by default
flow.background.delete()
return Response({})
if background:
flow.background = background
flow.save()
return Response({})
return HttpResponseBadRequest()
@permission_required("authentik_core.change_application")
@extend_schema(
request=inline_serializer("SetIconURL", fields={"url": CharField()}),
responses={
200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(
detail=True,
pagination_class=None,
filter_backends=[],
methods=["POST"],
)
# pylint: disable=unused-argument
def set_background_url(self, request: Request, slug: str):
"""Set Flow background (as URL)"""
flow: Flow = self.get_object()
url = request.data.get("url", None)
if not url:
app: Flow = self.get_object()
icon = request.FILES.get("file", None)
if not icon:
return HttpResponseBadRequest()
flow.background.name = url
flow.save()
app.background = icon
app.save()
return Response({})
@extend_schema(
responses={
200: LinkSerializer(many=False),
400: OpenApiResponse(description="Flow not applicable"),
},
@swagger_auto_schema(
responses={200: LinkSerializer(many=False), 400: "Flow not applicable"},
)
@action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=unused-argument

View File

@ -1,10 +1,10 @@
"""Flow Stage API Views"""
from typing import Iterable
from django.urls.base import reverse
from drf_spectacular.utils import extend_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.fields import BooleanField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, SerializerMethodField
@ -20,6 +20,12 @@ from authentik.lib.utils.reflection import all_subclasses
LOGGER = get_logger()
class StageUserSettingSerializer(UserSettingSerializer):
"""User settings but can include a configure flow"""
configure_flow = BooleanField(required=False)
class StageSerializer(ModelSerializer, MetaNameSerializer):
"""Stage Serializer"""
@ -59,10 +65,10 @@ class StageViewSet(
search_fields = ["name"]
filterset_fields = ["name"]
def get_queryset(self): # pragma: no cover
def get_queryset(self):
return Stage.objects.select_subclasses()
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request) -> Response:
"""Get all creatable stage types"""
@ -80,7 +86,7 @@ class StageViewSet(
data = sorted(data, key=lambda x: x["name"])
return Response(TypeCreateSerializer(data, many=True).data)
@extend_schema(responses={200: UserSettingSerializer(many=True)})
@swagger_auto_schema(responses={200: StageUserSettingSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def user_settings(self, request: Request) -> Response:
"""Get all stages the user can configure"""
@ -91,10 +97,9 @@ class StageViewSet(
if not user_settings:
continue
user_settings.initial_data["object_uid"] = str(stage.pk)
if hasattr(stage, "configure_url"):
user_settings.initial_data["configure_url"] = reverse(
"authentik_flows:configure",
kwargs={"stage_uuid": stage.uuid.hex},
if hasattr(stage, "configure_flow"):
user_settings.initial_data["configure_flow"] = bool(
stage.configure_flow
)
if not user_settings.is_valid():
LOGGER.warning(user_settings.errors)

View File

@ -2,9 +2,6 @@
from importlib import import_module
from django.apps import AppConfig
from django.db.utils import ProgrammingError
from authentik.lib.utils.reflection import all_subclasses
class AuthentikFlowsConfig(AppConfig):
@ -17,10 +14,3 @@ class AuthentikFlowsConfig(AppConfig):
def ready(self):
import_module("authentik.flows.signals")
try:
from authentik.flows.models import Stage
for stage in all_subclasses(Stage):
_ = stage().type
except ProgrammingError:
pass

View File

@ -28,14 +28,6 @@ class ErrorDetailSerializer(PassiveSerializer):
code = CharField()
class ContextualFlowInfo(PassiveSerializer):
"""Contextual flow information for a challenge"""
title = CharField(required=False, allow_blank=True)
background = CharField(required=False)
cancel_url = CharField()
class Challenge(PassiveSerializer):
"""Challenge that gets sent to the client based on which stage
is currently active"""
@ -43,8 +35,9 @@ class Challenge(PassiveSerializer):
type = ChoiceField(
choices=[(x.value, x.name) for x in ChallengeTypes],
)
flow_info = ContextualFlowInfo(required=False)
component = CharField(default="")
component = CharField(required=False)
title = CharField(required=False)
background = CharField(required=False)
response_errors = DictField(
child=ErrorDetailSerializer(many=True), allow_empty=True, required=False
@ -55,20 +48,18 @@ class RedirectChallenge(Challenge):
"""Challenge type to redirect the client"""
to = CharField()
component = CharField(default="xak-flow-redirect")
class ShellChallenge(Challenge):
"""challenge type to render HTML as-is"""
"""Legacy challenge type to render HTML as-is"""
body = CharField()
component = CharField(default="xak-flow-shell")
class WithUserInfoChallenge(Challenge):
"""Challenge base which shows some user info"""
pending_user = CharField(allow_blank=True)
pending_user = CharField()
pending_user_avatar = CharField()
@ -76,7 +67,6 @@ class AccessDeniedChallenge(Challenge):
"""Challenge when a flow's active stage calls `stage_invalid()`."""
error_message = CharField(required=False)
component = CharField(default="ak-stage-access-denied")
class PermissionSerializer(PassiveSerializer):
@ -90,7 +80,6 @@ class ChallengeResponse(PassiveSerializer):
"""Base class for all challenge responses"""
stage: Optional["StageView"]
component = CharField(default="xak-flow-response-default")
def __init__(self, instance=None, data=None, **kwargs):
self.stage = kwargs.pop("stage", None)

View File

@ -21,7 +21,7 @@ context["user_backend"] = "django.contrib.auth.backends.ModelBackend"
return True"""
def create_default_oobe_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.stages.prompt.models import FieldTypes
User = apps.get_model("authentik_core", "User")
@ -52,20 +52,20 @@ def create_default_oobe_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor
# Create a policy that sets the flow's user
prefill_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
name="default-oobe-prefill-user",
name="default-oob-prefill-user",
defaults={"expression": PREFILL_POLICY_EXPRESSION},
)
password_usable_policy, _ = ExpressionPolicy.objects.using(
db_alias
).update_or_create(
name="default-oobe-password-usable",
name="default-oob-password-usable",
defaults={"expression": PW_USABLE_POLICY_EXPRESSION},
)
prompt_header, _ = Prompt.objects.using(db_alias).update_or_create(
field_key="oobe-header-text",
field_key="oob-header-text",
defaults={
"label": "oobe-header-text",
"label": "oob-header-text",
"type": FieldTypes.STATIC,
"placeholder": "Welcome to authentik! Please set a password for the default admin user, akadmin.",
"order": 100,
@ -84,7 +84,7 @@ def create_default_oobe_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor
password_second = Prompt.objects.using(db_alias).get(field_key="password_repeat")
prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create(
name="default-oobe-password",
name="default-oob-password",
)
prompt_stage.fields.set(
[prompt_header, prompt_email, password_first, password_second]
@ -102,7 +102,7 @@ def create_default_oobe_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor
slug="initial-setup",
designation=FlowDesignation.STAGE_CONFIGURATION,
defaults={
"name": "default-oobe-setup",
"name": "default-oob-setup",
"title": "Welcome to authentik!",
},
)
@ -146,5 +146,5 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RunPython(create_default_oobe_flow),
migrations.RunPython(create_default_oob_flow),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 3.2.3 on 2021-06-05 17:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0018_oob_flows"),
]
operations = [
migrations.AlterField(
model_name="flow",
name="background",
field=models.FileField(
default=None,
help_text="Background shown during execution",
null=True,
upload_to="flow-backgrounds/",
),
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 3.2.3 on 2021-06-05 17:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0019_alter_flow_background"),
]
operations = [
migrations.AddField(
model_name="flow",
name="compatibility_mode",
field=models.BooleanField(
default=True,
help_text="Enable compatibility mode, increases compatibility with password managers on mobile devices.",
),
),
]

View File

@ -110,31 +110,11 @@ class Flow(SerializerModel, PolicyBindingModel):
background = models.FileField(
upload_to="flow-backgrounds/",
default=None,
null=True,
default="../static/dist/assets/images/flow_background.jpg",
blank=True,
help_text=_("Background shown during execution"),
)
compatibility_mode = models.BooleanField(
default=True,
help_text=_(
"Enable compatibility mode, increases compatibility with "
"password managers on mobile devices."
),
)
@property
def background_url(self) -> str:
"""Get the URL to the background image. If the name is /static or starts with http
it is returned as-is"""
if not self.background:
return "/static/dist/assets/images/flow_background.jpg"
if self.background.name.startswith("http") or self.background.name.startswith(
"/static"
):
return self.background.name
return self.background.url
stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True)
@property
@ -162,6 +142,11 @@ class Flow(SerializerModel, PolicyBindingModel):
LOGGER.debug("with_policy: no flow found", filters=flow_filter)
return None
def related_flow(self, designation: str, request: HttpRequest) -> Optional["Flow"]:
"""Get a related flow with `designation`. Currently this only queries
Flows by `designation`, but will eventually use `self` for related lookups."""
return Flow.with_policy(request, designation=designation)
def __str__(self) -> str:
return f"Flow {self.name} ({self.slug})"

View File

@ -4,7 +4,6 @@ from typing import Any, Optional
from django.core.cache import cache
from django.http import HttpRequest
from prometheus_client import Histogram
from sentry_sdk.hub import Hub
from sentry_sdk.tracing import Span
from structlog.stdlib import BoundLogger, get_logger
@ -15,7 +14,6 @@ from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableExce
from authentik.flows.markers import ReevaluateMarker, StageMarker
from authentik.flows.models import Flow, FlowStageBinding, Stage
from authentik.policies.engine import PolicyEngine
from authentik.root.monitoring import UpdatingGauge
LOGGER = get_logger()
PLAN_CONTEXT_PENDING_USER = "pending_user"
@ -23,16 +21,6 @@ PLAN_CONTEXT_SSO = "is_sso"
PLAN_CONTEXT_REDIRECT = "redirect"
PLAN_CONTEXT_APPLICATION = "application"
PLAN_CONTEXT_SOURCE = "source"
GAUGE_FLOWS_CACHED = UpdatingGauge(
"authentik_flows_cached",
"Cached flows",
update_func=lambda: len(cache.keys("flow_*") or []),
)
HIST_FLOWS_PLAN_TIME = Histogram(
"authentik_flows_plan_time",
"Duration to build a plan for a flow",
["flow_slug"],
)
def cache_key(flow: Flow, user: Optional[User] = None) -> str:
@ -158,7 +146,6 @@ class FlowPlanner:
)
plan = self._build_plan(user, request, default_context)
cache.set(cache_key(self.flow, user), plan)
GAUGE_FLOWS_CACHED.update()
if not plan.stages and not self.allow_empty_flows:
raise EmptyFlowException()
return plan
@ -171,9 +158,7 @@ class FlowPlanner:
) -> FlowPlan:
"""Build flow plan by checking each stage in their respective
order and checking the applied policies"""
with Hub.current.start_span(
op="flow.planner.build_plan"
) as span, HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time():
with Hub.current.start_span(op="flow.planner.build_plan") as span:
span: Span
span.set_data("flow", self.flow)
span.set_data("user", user)
@ -217,7 +202,6 @@ class FlowPlanner:
marker = ReevaluateMarker(binding=binding, user=user)
if stage:
plan.append(stage, marker)
HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug)
self._logger.debug(
"f(plan): finished building",
)

View File

@ -3,7 +3,6 @@ from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest
from django.http.request import QueryDict
from django.http.response import HttpResponse
from django.urls import reverse
from django.views.generic.base import View
from rest_framework.request import Request
from structlog.stdlib import get_logger
@ -12,7 +11,6 @@ from authentik.core.models import DEFAULT_AVATAR, User
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
ContextualFlowInfo,
HttpChallengeResponse,
WithUserInfoChallenge,
)
@ -95,16 +93,10 @@ class ChallengeStageView(StageView):
def _get_challenge(self, *args, **kwargs) -> Challenge:
challenge = self.get_challenge(*args, **kwargs)
if "flow_info" not in challenge.initial_data:
flow_info = ContextualFlowInfo(
data={
"title": self.executor.flow.title,
"background": self.executor.flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
}
)
flow_info.is_valid()
challenge.initial_data["flow_info"] = flow_info.data
if "title" not in challenge.initial_data:
challenge.initial_data["title"] = self.executor.flow.title
if "background" not in challenge.initial_data:
challenge.initial_data["background"] = self.executor.flow.background.url
if isinstance(challenge, WithUserInfoChallenge):
# If there's a pending user, update the `username` field
# this field is only used by password managers.

View File

@ -1,32 +0,0 @@
"""base model tests"""
from typing import Callable, Type
from django.test import TestCase
from authentik.flows.models import Stage
from authentik.flows.stage import StageView
from authentik.lib.utils.reflection import all_subclasses
class TestModels(TestCase):
"""Generic model properties tests"""
def model_tester_factory(test_model: Type[Stage]) -> Callable:
"""Test a form"""
def tester(self: TestModels):
model_class = None
if test_model._meta.abstract:
model_class = test_model.__bases__[0]()
else:
model_class = test_model()
self.assertTrue(issubclass(model_class.type, StageView))
self.assertIsNotNone(test_model.component)
_ = test_model.ui_user_settings
return tester
for model in all_subclasses(Stage):
setattr(TestModels, f"test_model_{model.__name__}", model_tester_factory(model))

View File

@ -93,11 +93,7 @@ class TestFlowExecutor(TestCase):
{
"component": "ak-stage-access-denied",
"error_message": FlowNonApplicableException.__doc__,
"flow_info": {
"background": flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
"title": "",
"type": ChallengeTypes.NATIVE.value,
},
)
@ -293,11 +289,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"component": "xak-flow-redirect",
"to": reverse("authentik_core:root-redirect"),
"type": ChallengeTypes.REDIRECT.value,
},
{"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
)
def test_reevaluate_keep(self):
@ -374,11 +366,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"component": "xak-flow-redirect",
"to": reverse("authentik_core:root-redirect"),
"type": ChallengeTypes.REDIRECT.value,
},
{"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
)
def test_reevaluate_remove_consecutive(self):
@ -426,13 +414,10 @@ class TestFlowExecutor(TestCase):
self.assertJSONEqual(
force_str(response.content),
{
"background": flow.background.url,
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-dummy",
"flow_info": {
"background": flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
"title": binding.stage.name,
},
)
@ -460,13 +445,10 @@ class TestFlowExecutor(TestCase):
self.assertJSONEqual(
force_str(response.content),
{
"background": flow.background.url,
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-dummy",
"flow_info": {
"background": flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
"title": binding4.stage.name,
},
)
@ -476,11 +458,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"component": "xak-flow-redirect",
"to": reverse("authentik_core:root-redirect"),
"type": ChallengeTypes.REDIRECT.value,
},
{"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
)
def test_stageview_user_identifier(self):

View File

@ -13,7 +13,7 @@ from django.db.models.query_utils import Q
from django.db.utils import IntegrityError
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import BoundLogger, get_logger
from structlog import BoundLogger, get_logger
from authentik.flows.models import Flow, FlowStageBinding, Stage
from authentik.flows.transfer.common import (

View File

@ -1,5 +1,6 @@
"""flow urls"""
from django.urls import path
from django.views.generic import RedirectView
from authentik.flows.models import FlowDesignation
from authentik.flows.views import CancelView, ConfigureFlowInitView, ToDefaultFlow
@ -15,10 +16,30 @@ urlpatterns = [
ToDefaultFlow.as_view(designation=FlowDesignation.INVALIDATION),
name="default-invalidation",
),
path(
"-/default/recovery/",
ToDefaultFlow.as_view(designation=FlowDesignation.RECOVERY),
name="default-recovery",
),
path(
"-/default/enrollment/",
ToDefaultFlow.as_view(designation=FlowDesignation.ENROLLMENT),
name="default-enrollment",
),
path(
"-/default/unenrollment/",
ToDefaultFlow.as_view(designation=FlowDesignation.UNRENOLLMENT),
name="default-unenrollment",
),
path("-/cancel/", CancelView.as_view(), name="cancel"),
path(
"-/configure/<uuid:stage_uuid>/",
ConfigureFlowInitView.as_view(),
name="configure",
),
path(
"<slug:flow_slug>/",
RedirectView.as_view(pattern_name="authentik_core:if-flow"),
name="flow-executor-shell",
),
]

View File

@ -2,23 +2,16 @@
from traceback import format_tb
from typing import Any, Optional
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
from django.http.request import QueryDict
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls.base import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import View
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
PolymorphicProxySerializer,
extend_schema,
)
from drf_yasg import openapi
from drf_yasg.utils import no_body, swagger_auto_schema
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
from sentry_sdk import capture_exception
@ -34,7 +27,6 @@ from authentik.flows.challenge import (
HttpChallengeResponse,
RedirectChallenge,
ShellChallenge,
WithUserInfoChallenge,
)
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
@ -44,9 +36,8 @@ from authentik.flows.planner import (
FlowPlan,
FlowPlanner,
)
from authentik.lib.utils.reflection import all_subclasses, class_to_path
from authentik.lib.utils.reflection import class_to_path
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
from authentik.tenants.models import Tenant
LOGGER = get_logger()
# Argument used to redirect user after login
@ -56,43 +47,6 @@ SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre"
SESSION_KEY_GET = "authentik_flows_get"
def challenge_types():
"""This is a workaround for PolymorphicProxySerializer not accepting a callable for
`serializers`. This function returns a class which is an iterator, which returns the
subclasses of Challenge, and Challenge itself."""
class Inner(dict):
"""dummy class with custom callback on .items()"""
def items(self):
mapping = {}
classes = all_subclasses(Challenge)
classes.remove(WithUserInfoChallenge)
for cls in classes:
mapping[cls().fields["component"].default] = cls
return mapping.items()
return Inner()
def challenge_response_types():
"""This is a workaround for PolymorphicProxySerializer not accepting a callable for
`serializers`. This function returns a class which is an iterator, which returns the
subclasses of Challenge, and Challenge itself."""
class Inner(dict):
"""dummy class with custom callback on .items()"""
def items(self):
mapping = {}
classes = all_subclasses(ChallengeResponse)
for cls in classes:
mapping[cls(stage=None).fields["component"].default] = cls
return mapping.items()
return Inner()
@method_decorator(xframe_options_sameorigin, name="dispatch")
class FlowExecutorView(APIView):
"""Stage 1 Flow executor, passing requests to Stage Views"""
@ -171,25 +125,19 @@ class FlowExecutorView(APIView):
self.current_stage_view.request = request
return super().dispatch(request)
@extend_schema(
@swagger_auto_schema(
responses={
200: PolymorphicProxySerializer(
component_name="FlowChallengeRequest",
serializers=challenge_types(),
resource_type_field_name="component",
),
404: OpenApiResponse(
description="No Token found"
), # This error can be raised by the email stage
200: Challenge(),
404: "No Token found", # This error can be raised by the email stage
},
request=OpenApiTypes.NONE,
parameters=[
OpenApiParameter(
name="query",
location=OpenApiParameter.QUERY,
request_body=no_body,
manual_parameters=[
openapi.Parameter(
"query",
openapi.IN_QUERY,
required=True,
description="Querystring as received",
type=OpenApiTypes.STR,
type=openapi.TYPE_STRING,
)
],
operation_id="flows_executor_get",
@ -205,32 +153,20 @@ class FlowExecutorView(APIView):
stage_response = self.current_stage_view.get(request, *args, **kwargs)
return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except
if settings.DEBUG or settings.TEST:
raise exc
capture_exception(exc)
self._logger.warning(exc)
return to_stage_response(request, FlowErrorResponse(request, exc))
@extend_schema(
responses={
200: PolymorphicProxySerializer(
component_name="FlowChallengeRequest",
serializers=challenge_types(),
resource_type_field_name="component",
),
},
request=PolymorphicProxySerializer(
component_name="FlowChallengeResponse",
serializers=challenge_response_types(),
resource_type_field_name="component",
),
parameters=[
OpenApiParameter(
name="query",
location=OpenApiParameter.QUERY,
@swagger_auto_schema(
responses={200: Challenge()},
request_body=ChallengeResponse(),
manual_parameters=[
openapi.Parameter(
"query",
openapi.IN_QUERY,
required=True,
description="Querystring as received",
type=OpenApiTypes.STR,
type=openapi.TYPE_STRING,
)
],
operation_id="flows_executor_solve",
@ -246,8 +182,6 @@ class FlowExecutorView(APIView):
stage_response = self.current_stage_view.post(request, *args, **kwargs)
return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except
if settings.DEBUG or settings.TEST:
raise exc
capture_exception(exc)
self._logger.warning(exc)
return to_stage_response(request, FlowErrorResponse(request, exc))
@ -284,7 +218,7 @@ class FlowExecutorView(APIView):
if self.plan.stages:
self._logger.debug(
"f(exec): Continuing with next stage",
remaining=len(self.plan.stages),
reamining=len(self.plan.stages),
)
kwargs = self.kwargs
kwargs.update({"flow_slug": self.flow.slug})
@ -310,13 +244,9 @@ class FlowExecutorView(APIView):
AccessDeniedChallenge(
{
"error_message": error_message,
"title": self.flow.title,
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-access-denied",
"flow_info": {
"title": self.flow.title,
"background": self.flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
},
}
)
)
@ -368,7 +298,7 @@ class CancelView(View):
if SESSION_KEY_PLAN in request.session:
del request.session[SESSION_KEY_PLAN]
LOGGER.debug("Canceled current plan")
return redirect("authentik_flows:default-invalidation")
return redirect("authentik_core:root-redirect")
class ToDefaultFlow(View):
@ -377,17 +307,7 @@ class ToDefaultFlow(View):
designation: Optional[FlowDesignation] = None
def dispatch(self, request: HttpRequest) -> HttpResponse:
tenant: Tenant = request.tenant
flow = None
# First, attempt to get default flow from tenant
if self.designation == FlowDesignation.AUTHENTICATION:
flow = tenant.flow_authentication
if self.designation == FlowDesignation.INVALIDATION:
flow = tenant.flow_invalidation
# If no flow was set, get the first based on slug and policy
if not flow:
flow = Flow.with_policy(request, designation=self.designation)
# If we still don't have a flow, 404
flow = Flow.with_policy(request, designation=self.designation)
if not flow:
raise Http404
# If user already has a pending plan, clear it so we don't have to later.
@ -418,10 +338,7 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons
)
return HttpChallengeResponse(
RedirectChallenge(
{
"type": ChallengeTypes.REDIRECT,
"to": str(redirect_url),
}
{"type": ChallengeTypes.REDIRECT, "to": str(redirect_url)}
)
)
if isinstance(source, TemplateResponse):
@ -433,7 +350,7 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons
}
)
)
# Check for actual HttpResponse (without isinstance as we don't want to check inheritance)
# Check for actual HttpResponse (without isinstance as we dont want to check inheritance)
if source.__class__ == HttpResponse:
return HttpChallengeResponse(
ShellChallenge(

View File

@ -10,6 +10,9 @@ from urllib.parse import urlparse
import yaml
from django.conf import ImproperlyConfigured
from django.http import HttpRequest
from authentik import __version__
SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob(
"/etc/authentik/config.d/*.yml", recursive=True
@ -18,6 +21,11 @@ ENV_PREFIX = "AUTHENTIK"
ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local")
def context_processor(request: HttpRequest) -> dict[str, Any]:
"""Context Processor that injects config object into every template"""
return {"config": CONFIG.raw, "ak_version": __version__}
class ConfigLoader:
"""Search through SEARCH_PATHS and load configuration. Environment variables starting with
`ENV_PREFIX` are also applied.
@ -78,13 +86,6 @@ class ConfigLoader:
url = urlparse(value)
if url.scheme == "env":
value = os.getenv(url.netloc, url.query)
if url.scheme == "file":
try:
with open(url.path, "r") as _file:
value = _file.read()
except OSError:
self._log("error", f"Failed to read config value from {url.path}")
value = url.query
return value
def update_from_file(self, path: str):
@ -162,7 +163,6 @@ class ConfigLoader:
# Walk each component of the path
path_parts = path.split(sep)
for comp in path_parts[:-1]:
# pyright: reportGeneralTypeIssues=false
if comp not in root:
root[comp] = {}
root = root.get(comp)

View File

@ -3,13 +3,8 @@ postgresql:
host: localhost
name: authentik
user: authentik
port: 5432
password: 'env://POSTGRES_PASSWORD'
web:
listen: 0.0.0.0:9000
listen_tls: 0.0.0.0:9443
redis:
host: localhost
password: ''
@ -39,15 +34,14 @@ email:
from: authentik@localhost
outposts:
# Placeholders:
# %(type)s: Outpost type; proxy, ldap, etc
# %(version)s: Current version; 2021.4.1
# %(build_hash)s: Build hash if you're running a beta version
docker_image_base: "ghcr.io/goauthentik/%(type)s:%(version)s"
docker_image_base: "beryju/authentik" # this is prepended to -proxy:version
authentik:
avatars: gravatar # gravatar or none
geoip: "./GeoLite2-City.mmdb"
geoip: ""
branding:
title: authentik
logo: /static/dist/assets/icons/icon_left_brand.svg
# Optionally add links to the footer on the login page
footer_links:
- name: Documentation

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