diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 2d2f68396..9756a3f68 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -27,7 +27,7 @@ values =
[bumpversion:file:.github/workflows/release.yml]
-[bumpversion:file:passbook/__init__.py]
+[bumpversion:file:authentik/__init__.py]
[bumpversion:file:proxy/pkg/version.go]
diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index 01241f2d7..000000000
--- a/.coveragerc
+++ /dev/null
@@ -1,33 +0,0 @@
-[run]
-source = passbook
-relative_files = true
-omit =
- */asgi.py
- manage.py
- */migrations/*
- */apps.py
- website/
-
-[report]
-sort = Cover
-skip_covered = True
-precision = 2
-exclude_lines =
- pragma: no cover
-
- # Don't complain about missing debug-only code:
- def __unicode__
- def __str__
- def __repr__
- if self\.debug
- if TYPE_CHECKING
-
- # Don't complain if tests don't hit defensive assertion code:
- raise AssertionError
- raise NotImplementedError
-
- # Don't complain if non-runnable code isn't run:
- if 0:
- if __name__ == .__main__.:
-
-show_missing = True
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 21e7a1a8d..79b68b549 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -27,7 +27,7 @@ If applicable, add screenshots to help explain your problem.
Output of docker-compose logs or kubectl logs respectively
**Version and Deployment (please complete the following information):**
- - passbook version: [e.g. 0.10.0-stable]
+ - authentik version: [e.g. 0.10.0-stable]
- Deployment: [e.g. docker-compose, helm]
**Additional context**
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9cdacf527..cfc172e63 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,4 +1,4 @@
-name: passbook-on-release
+name: authentik-on-release
on:
release:
@@ -18,13 +18,13 @@ jobs:
- name: Building Docker Image
run: docker build
--no-cache
- -t beryju/passbook:0.12.11-stable
- -t beryju/passbook:latest
+ -t beryju/authentik:0.12.11-stable
+ -t beryju/authentik:latest
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
- run: docker push beryju/passbook:0.12.11-stable
+ run: docker push beryju/authentik:0.12.11-stable
- name: Push Docker Container to Registry (latest)
- run: docker push beryju/passbook:latest
+ run: docker push beryju/authentik:latest
build-proxy:
runs-on: ubuntu-latest
steps:
@@ -36,7 +36,7 @@ jobs:
run: |
cd proxy
go get -u github.com/go-swagger/go-swagger/cmd/swagger
- swagger generate client -f ../swagger.yaml -A passbook -t pkg/
+ swagger generate client -f ../swagger.yaml -A authentik -t pkg/
go build -v .
- name: Docker Login Registry
env:
@@ -48,13 +48,13 @@ jobs:
cd proxy/
docker build \
--no-cache \
- -t beryju/passbook-proxy:0.12.11-stable \
- -t beryju/passbook-proxy:latest \
+ -t beryju/authentik-proxy:0.12.11-stable \
+ -t beryju/authentik-proxy:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
- run: docker push beryju/passbook-proxy:0.12.11-stable
+ run: docker push beryju/authentik-proxy:0.12.11-stable
- name: Push Docker Container to Registry (latest)
- run: docker push beryju/passbook-proxy:latest
+ run: docker push beryju/authentik-proxy:latest
build-static:
runs-on: ubuntu-latest
steps:
@@ -69,13 +69,13 @@ jobs:
cd web/
docker build \
--no-cache \
- -t beryju/passbook-static:0.12.11-stable \
- -t beryju/passbook-static:latest \
+ -t beryju/authentik-static:0.12.11-stable \
+ -t beryju/authentik-static:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
- run: docker push beryju/passbook-static:0.12.11-stable
+ run: docker push beryju/authentik-static:0.12.11-stable
- name: Push Docker Container to Registry (latest)
- run: docker push beryju/passbook-static:latest
+ run: docker push beryju/authentik-static:latest
test-release:
needs:
- build-server
@@ -87,11 +87,11 @@ jobs:
run: |
sudo apt-get install -y pwgen
echo "PG_PASS=$(pwgen 40 1)" >> .env
- echo "PASSBOOK_SECRET_KEY=$(pwgen 50 1)" >> .env
+ echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
- docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook"
+ docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
sentry-release:
needs:
- test-release
@@ -103,7 +103,7 @@ jobs:
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: beryjuorg
- SENTRY_PROJECT: passbook
+ SENTRY_PROJECT: authentik
SENTRY_URL: https://sentry.beryju.org
with:
tagName: 0.12.11-stable
diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml
index 49b31670b..58e94afaa 100644
--- a/.github/workflows/tag.yml
+++ b/.github/workflows/tag.yml
@@ -1,4 +1,4 @@
-name: passbook-on-tag
+name: authentik-on-tag
on:
push:
@@ -14,17 +14,17 @@ jobs:
- name: Pre-release test
run: |
sudo apt-get install -y pwgen
- echo "PASSBOOK_TAG=latest" >> .env
+ echo "AUTHENTIK_TAG=latest" >> .env
echo "PG_PASS=$(pwgen 40 1)" >> .env
- echo "PASSBOOK_SECRET_KEY=$(pwgen 50 1)" >> .env
+ echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env
docker-compose pull -q
docker build \
--no-cache \
- -t beryju/passbook:latest \
+ -t beryju/authentik:latest \
-f Dockerfile .
docker-compose up --no-start
docker-compose start postgresql redis
- docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook"
+ 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
@@ -33,7 +33,7 @@ jobs:
run: |
helm dependency update helm/
helm package helm/
- mv passbook-*.tgz passbook-chart.tgz
+ mv authentik-*.tgz authentik-chart.tgz
- name: Extract version number
id: get_version
uses: actions/github-script@0.2.0
@@ -58,6 +58,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./passbook-chart.tgz
- asset_name: passbook-chart.tgz
+ asset_path: ./authentik-chart.tgz
+ asset_name: authentik-chart.tgz
asset_content_type: application/gzip
diff --git a/Dockerfile b/Dockerfile
index 1ab59048d..d2d001c49 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -30,18 +30,18 @@ RUN apt-get update && \
# 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 /passbook passbook && \
- usermod -a -G docker_998 passbook && \
- usermod -a -G docker_999 passbook && \
+ 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 passbook:passbook /backups
+ chown authentik:authentik /backups
-COPY ./passbook/ /passbook
+COPY ./authentik/ /authentik
COPY ./pytest.ini /
COPY ./manage.py /
COPY ./lifecycle/ /lifecycle
-USER passbook
+USER authentik
STOPSIGNAL SIGINT
ENV TMPDIR /dev/shm/
ENTRYPOINT [ "/lifecycle/bootstrap.sh" ]
diff --git a/Makefile b/Makefile
index 3356d25e2..0d5abc4a5 100644
--- a/Makefile
+++ b/Makefile
@@ -9,30 +9,30 @@ test-e2e:
coverage run manage.py test --failfast -v 3 tests/e2e
coverage:
- coverage run manage.py test --failfast -v 3 passbook
+ coverage run manage.py test --failfast -v 3 authentik
coverage html
coverage report
lint-fix:
- isort -rc .
- black passbook tests lifecycle
+ isort -rc authentik tests lifecycle
+ black authentik tests lifecycle
lint:
- pyright passbook tests lifecycle
- bandit -r passbook tests lifecycle -x node_modules
- pylint passbook tests lifecycle
+ pyright authentik tests lifecycle
+ bandit -r authentik tests lifecycle -x node_modules
+ pylint authentik tests lifecycle
prospector
gen: coverage
./manage.py generate_swagger -o swagger.yaml -f yaml
local-stack:
- export PASSBOOK_TAG=testing
- docker build -t beryju/passbook:testng .
+ export AUTHENTIK_TAG=testing
+ docker build -t beryju/authentik:testng .
docker-compose up -d
docker-compose run --rm server migrate
build-static:
docker-compose -f scripts/ci.docker-compose.yml up -d
- docker build -t beryju/passbook-static -f static.Dockerfile --network=scripts_default .
+ docker build -t beryju/authentik-static -f static.Dockerfile --network=scripts_default .
docker-compose -f scripts/ci.docker-compose.yml down -v
diff --git a/README.md b/README.md
index 483d0e370..7842cf896 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,23 @@
-
+
-[![CI Build status](https://img.shields.io/azure-devops/build/beryjuorg/passbook/1?style=flat-square)](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=1)
-![Tests](https://img.shields.io/azure-devops/tests/beryjuorg/passbook/1?compact_message&style=flat-square)
-[![Code Coverage](https://img.shields.io/codecov/c/gh/beryju/passbook?style=flat-square)](https://codecov.io/gh/BeryJu/passbook)
-![Docker pulls](https://img.shields.io/docker/pulls/beryju/passbook.svg?style=flat-square)
-![Latest version](https://img.shields.io/docker/v/beryju/passbook?sort=semver&style=flat-square)
-![LGTM Grade](https://img.shields.io/lgtm/grade/python/github/BeryJu/passbook?style=flat-square)
+---
-## What is passbook?
+[![CI Build status](https://img.shields.io/azure-devops/build/beryjuorg/authentik/1?style=flat-square)](https://dev.azure.com/beryjuorg/authentik/_build?definitionId=1)
+[![Tests](https://img.shields.io/azure-devops/tests/beryjuorg/authentik/1?compact_message&style=flat-square)](https://dev.azure.com/beryjuorg/authentik/_build?definitionId=1)
+[![Code Coverage](https://img.shields.io/codecov/c/gh/beryju/authentik?style=flat-square)](https://codecov.io/gh/BeryJu/authentik)
+![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/BeryJu/authentik?style=flat-square)
-passbook is an open-source Identity Provider focused on flexibility and versatility. You can use passbook in an existing environment to add support for new protocols. passbook is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it.
+## What is authentik?
+
+authentik is an open-source Identity Provider focused on flexibility and versatility. You can use authentik in an existing environment to add support for new protocols. authentik is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it.
## Installation
-For small/test setups it is recommended to use docker-compose, see the [documentation](https://passbook.beryju.org/docs/installation/docker-compose/)
+For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/)
-For bigger setups, there is a Helm Chart in the `helm/` directory. This is documented [here](https://passbook.beryju.org/docs/installation/kubernetes/)
+For bigger setups, there is a Helm Chart in the `helm/` directory. This is documented [here](https://goauthentik.io/docs/installation/kubernetes/)
## Screenshots
@@ -24,7 +26,7 @@ For bigger setups, there is a Helm Chart in the `helm/` directory. This is docum
## Development
-See [Development Documentation](https://passbook.beryju.org/docs/development/local-dev-environment)
+See [Development Documentation](https://goauthentik.io/docs/development/local-dev-environment)
## Security
diff --git a/SECURITY.md b/SECURITY.md
index 03e718566..b88f63f36 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,7 +2,7 @@
## Supported Versions
-As passbook is currently in a pre-stable, only the latest "stable" version is supported. After passbook 1.0, this will change.
+As authentik is currently in a pre-stable, only the latest "stable" version is supported. After authentik 1.0, this will change.
| Version | Supported |
| -------- | ------------------ |
diff --git a/authentik/__init__.py b/authentik/__init__.py
new file mode 100644
index 000000000..3570122fa
--- /dev/null
+++ b/authentik/__init__.py
@@ -0,0 +1,2 @@
+"""authentik"""
+__version__ = "0.12.11-stable"
diff --git a/passbook/admin/__init__.py b/authentik/admin/__init__.py
similarity index 100%
rename from passbook/admin/__init__.py
rename to authentik/admin/__init__.py
diff --git a/passbook/admin/api/__init__.py b/authentik/admin/api/__init__.py
similarity index 100%
rename from passbook/admin/api/__init__.py
rename to authentik/admin/api/__init__.py
diff --git a/authentik/admin/api/overview.py b/authentik/admin/api/overview.py
new file mode 100644
index 000000000..e61068abc
--- /dev/null
+++ b/authentik/admin/api/overview.py
@@ -0,0 +1,79 @@
+"""authentik administration overview"""
+from django.core.cache import cache
+from drf_yasg2.utils import swagger_auto_schema
+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.serializers import Serializer
+from rest_framework.viewsets import ViewSet
+
+from authentik import __version__
+from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
+from authentik.core.models import Provider
+from authentik.policies.models import Policy
+from authentik.root.celery import CELERY_APP
+
+
+class AdministrationOverviewSerializer(Serializer):
+ """Overview View"""
+
+ version = SerializerMethodField()
+ version_latest = SerializerMethodField()
+ worker_count = SerializerMethodField()
+ providers_without_application = SerializerMethodField()
+ policies_without_binding = SerializerMethodField()
+ cached_policies = SerializerMethodField()
+ cached_flows = SerializerMethodField()
+
+ def get_version(self, _) -> str:
+ """Get current version"""
+ return __version__
+
+ def get_version_latest(self, _) -> str:
+ """Get latest version from cache"""
+ version_in_cache = cache.get(VERSION_CACHE_KEY)
+ if not version_in_cache:
+ update_latest_version.delay()
+ return __version__
+ return version_in_cache
+
+ def get_worker_count(self, _) -> int:
+ """Ping workers"""
+ return len(CELERY_APP.control.ping(timeout=0.5))
+
+ def get_providers_without_application(self, _) -> int:
+ """Count of providers without application"""
+ return len(Provider.objects.filter(application=None))
+
+ def get_policies_without_binding(self, _) -> int:
+ """Count of policies not bound or use in prompt stages"""
+ return len(
+ Policy.objects.filter(bindings__isnull=True, promptstage__isnull=True)
+ )
+
+ def get_cached_policies(self, _) -> int:
+ """Get cached policy count"""
+ return len(cache.keys("policy_*"))
+
+ def get_cached_flows(self, _) -> int:
+ """Get cached flow count"""
+ return len(cache.keys("flow_*"))
+
+ def create(self, request: Request) -> Response:
+ raise NotImplementedError
+
+ def update(self, request: Request) -> Response:
+ raise NotImplementedError
+
+
+class AdministrationOverviewViewSet(ViewSet):
+ """Return single instance of AdministrationOverviewSerializer"""
+
+ permission_classes = [IsAdminUser]
+
+ @swagger_auto_schema(responses={200: AdministrationOverviewSerializer(many=True)})
+ def list(self, request: Request) -> Response:
+ """Return single instance of AdministrationOverviewSerializer"""
+ serializer = AdministrationOverviewSerializer(True)
+ return Response(serializer.data)
diff --git a/authentik/admin/api/overview_metrics.py b/authentik/admin/api/overview_metrics.py
new file mode 100644
index 000000000..e1c29e54f
--- /dev/null
+++ b/authentik/admin/api/overview_metrics.py
@@ -0,0 +1,79 @@
+"""authentik administration overview"""
+import time
+from collections import Counter
+from datetime import timedelta
+from typing import Dict, List
+
+from django.db.models import Count, ExpressionWrapper, F
+from django.db.models.fields import DurationField
+from django.db.models.functions import ExtractHour
+from django.http import response
+from django.utils.timezone import now
+from drf_yasg2.utils import swagger_auto_schema
+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.serializers import Serializer
+from rest_framework.viewsets import ViewSet
+
+from authentik.audit.models import Event, EventAction
+
+
+def get_events_per_1h(**filter_kwargs) -> List[Dict[str, int]]:
+ """Get event count by hour in the last day, fill with zeros"""
+ date_from = now() - timedelta(days=1)
+ result = (
+ Event.objects.filter(created__gte=date_from, **filter_kwargs)
+ .annotate(
+ age=ExpressionWrapper(now() - F("created"), output_field=DurationField())
+ )
+ .annotate(age_hours=ExtractHour("age"))
+ .values("age_hours")
+ .annotate(count=Count("pk"))
+ .order_by("age_hours")
+ )
+ data = Counter({d["age_hours"]: d["count"] for d in result})
+ results = []
+ _now = now()
+ for hour in range(0, -24, -1):
+ results.append(
+ {
+ "x": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
+ "y": data[hour * -1],
+ }
+ )
+ return results
+
+
+class AdministrationMetricsSerializer(Serializer):
+ """Overview View"""
+
+ logins_per_1h = SerializerMethodField()
+ logins_failed_per_1h = SerializerMethodField()
+
+ def get_logins_per_1h(self, _):
+ """Get successful logins per hour for the last 24 hours"""
+ return get_events_per_1h(action=EventAction.LOGIN)
+
+ 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)
+
+ def create(self, request: Request) -> response:
+ raise NotImplementedError
+
+ def update(self, request: Request) -> Response:
+ raise NotImplementedError
+
+
+class AdministrationMetricsViewSet(ViewSet):
+ """Return single instance of AdministrationMetricsSerializer"""
+
+ permission_classes = [IsAdminUser]
+
+ @swagger_auto_schema(responses={200: AdministrationMetricsSerializer(many=True)})
+ def list(self, request: Request) -> Response:
+ """Return single instance of AdministrationMetricsSerializer"""
+ serializer = AdministrationMetricsSerializer(True)
+ return Response(serializer.data)
diff --git a/authentik/admin/api/tasks.py b/authentik/admin/api/tasks.py
new file mode 100644
index 000000000..dbaf5d5e1
--- /dev/null
+++ b/authentik/admin/api/tasks.py
@@ -0,0 +1,72 @@
+"""Tasks API"""
+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_yasg2.utils import swagger_auto_schema
+from rest_framework.decorators import action
+from rest_framework.fields import CharField, DateTimeField, IntegerField, ListField
+from rest_framework.permissions import IsAdminUser
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import Serializer
+from rest_framework.viewsets import ViewSet
+
+from authentik.lib.tasks import TaskInfo
+
+
+class TaskSerializer(Serializer):
+ """Serialize TaskInfo and TaskResult"""
+
+ task_name = CharField()
+ task_description = CharField()
+ task_finish_timestamp = DateTimeField(source="finish_timestamp")
+
+ status = IntegerField(source="result.status.value")
+ messages = ListField(source="result.messages")
+
+ def create(self, request: Request) -> Response:
+ raise NotImplementedError
+
+ def update(self, request: Request) -> Response:
+ raise NotImplementedError
+
+
+class TaskViewSet(ViewSet):
+ """Read-only view set that returns all background tasks"""
+
+ permission_classes = [IsAdminUser]
+
+ @swagger_auto_schema(responses={200: TaskSerializer(many=True)})
+ def list(self, request: Request) -> Response:
+ """List current messages and pass into Serializer"""
+ return Response(TaskSerializer(TaskInfo.all().values(), many=True).data)
+
+ @action(detail=True, methods=["post"])
+ # pylint: disable=invalid-name
+ def retry(self, request: Request, pk=None) -> Response:
+ """Retry task"""
+ task = TaskInfo.by_name(pk)
+ if not task:
+ raise Http404
+ try:
+ task_module = import_module(task.task_call_module)
+ task_func = getattr(task_module, task.task_call_func)
+ task_func.delay(*task.task_call_args, **task.task_call_kwargs)
+ messages.success(
+ self.request,
+ _(
+ "Successfully re-scheduled Task %(name)s!"
+ % {"name": task.task_name}
+ ),
+ )
+ return Response(
+ {
+ "successful": True,
+ }
+ )
+ except ImportError:
+ # if we get an import error, the module path has probably changed
+ task.delete()
+ return Response({"successful": False})
diff --git a/authentik/admin/apps.py b/authentik/admin/apps.py
new file mode 100644
index 000000000..db05f4daa
--- /dev/null
+++ b/authentik/admin/apps.py
@@ -0,0 +1,11 @@
+"""authentik admin app config"""
+from django.apps import AppConfig
+
+
+class AuthentikAdminConfig(AppConfig):
+ """authentik admin app config"""
+
+ name = "authentik.admin"
+ label = "authentik_admin"
+ mountpoint = "administration/"
+ verbose_name = "authentik Admin"
diff --git a/passbook/admin/fields.py b/authentik/admin/fields.py
similarity index 100%
rename from passbook/admin/fields.py
rename to authentik/admin/fields.py
diff --git a/passbook/admin/forms/__init__.py b/authentik/admin/forms/__init__.py
similarity index 100%
rename from passbook/admin/forms/__init__.py
rename to authentik/admin/forms/__init__.py
diff --git a/passbook/admin/forms/overview.py b/authentik/admin/forms/overview.py
similarity index 100%
rename from passbook/admin/forms/overview.py
rename to authentik/admin/forms/overview.py
diff --git a/authentik/admin/forms/policies.py b/authentik/admin/forms/policies.py
new file mode 100644
index 000000000..17112b588
--- /dev/null
+++ b/authentik/admin/forms/policies.py
@@ -0,0 +1,12 @@
+"""authentik administration forms"""
+from django import forms
+
+from authentik.admin.fields import CodeMirrorWidget, YAMLField
+from authentik.core.models import User
+
+
+class PolicyTestForm(forms.Form):
+ """Form to test policies against user"""
+
+ user = forms.ModelChoiceField(queryset=User.objects.all())
+ context = YAMLField(widget=CodeMirrorWidget(), required=False, initial=dict)
diff --git a/authentik/admin/forms/source.py b/authentik/admin/forms/source.py
new file mode 100644
index 000000000..5e5d04393
--- /dev/null
+++ b/authentik/admin/forms/source.py
@@ -0,0 +1,17 @@
+"""authentik core source form fields"""
+
+SOURCE_FORM_FIELDS = [
+ "name",
+ "slug",
+ "enabled",
+ "authentication_flow",
+ "enrollment_flow",
+]
+SOURCE_SERIALIZER_FIELDS = [
+ "pk",
+ "name",
+ "slug",
+ "enabled",
+ "authentication_flow",
+ "enrollment_flow",
+]
diff --git a/authentik/admin/forms/users.py b/authentik/admin/forms/users.py
new file mode 100644
index 000000000..b7c3cc8d7
--- /dev/null
+++ b/authentik/admin/forms/users.py
@@ -0,0 +1,22 @@
+"""authentik administrative user forms"""
+
+from django import forms
+
+from authentik.admin.fields import CodeMirrorWidget, YAMLField
+from authentik.core.models import User
+
+
+class UserForm(forms.ModelForm):
+ """Update User Details"""
+
+ class Meta:
+
+ model = User
+ fields = ["username", "name", "email", "is_active", "attributes"]
+ widgets = {
+ "name": forms.TextInput,
+ "attributes": CodeMirrorWidget,
+ }
+ field_classes = {
+ "attributes": YAMLField,
+ }
diff --git a/authentik/admin/mixins.py b/authentik/admin/mixins.py
new file mode 100644
index 000000000..97ee3c53a
--- /dev/null
+++ b/authentik/admin/mixins.py
@@ -0,0 +1,9 @@
+"""authentik admin mixins"""
+from django.contrib.auth.mixins import UserPassesTestMixin
+
+
+class AdminRequiredMixin(UserPassesTestMixin):
+ """Make sure user is administrator"""
+
+ def test_func(self):
+ return self.request.user.is_superuser
diff --git a/authentik/admin/settings.py b/authentik/admin/settings.py
new file mode 100644
index 000000000..57e0de6e6
--- /dev/null
+++ b/authentik/admin/settings.py
@@ -0,0 +1,10 @@
+"""authentik admin settings"""
+from celery.schedules import crontab
+
+CELERY_BEAT_SCHEDULE = {
+ "admin_latest_version": {
+ "task": "authentik.admin.tasks.update_latest_version",
+ "schedule": crontab(minute=0), # Run every hour
+ "options": {"queue": "authentik_scheduled"},
+ }
+}
diff --git a/authentik/admin/tasks.py b/authentik/admin/tasks.py
new file mode 100644
index 000000000..2bff1ecba
--- /dev/null
+++ b/authentik/admin/tasks.py
@@ -0,0 +1,30 @@
+"""authentik admin tasks"""
+from django.core.cache import cache
+from requests import RequestException, get
+from structlog import get_logger
+
+from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
+from authentik.root.celery import CELERY_APP
+
+LOGGER = get_logger()
+VERSION_CACHE_KEY = "authentik_latest_version"
+VERSION_CACHE_TIMEOUT = 2 * 60 * 60 # 2 hours
+
+
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def update_latest_version(self: MonitoredTask):
+ """Update latest version info"""
+ try:
+ response = get("https://api.github.com/repos/beryju/authentik/releases/latest")
+ response.raise_for_status()
+ data = response.json()
+ tag_name = data.get("tag_name")
+ cache.set(VERSION_CACHE_KEY, tag_name.split("/")[1], VERSION_CACHE_TIMEOUT)
+ self.set_status(
+ TaskResult(
+ TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"]
+ )
+ )
+ 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))
diff --git a/authentik/admin/templates/administration/application/list.html b/authentik/admin/templates/administration/application/list.html
new file mode 100644
index 000000000..843fa6c15
--- /dev/null
+++ b/authentik/admin/templates/administration/application/list.html
@@ -0,0 +1,121 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Applications' %}
+
+
{% trans "External Applications which use authentik as Identity-Provider, utilizing protocols like OAuth2 and SAML." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Applications.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any application." %}
+ {% else %}
+ {% trans 'Currently no applications exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/passbook/admin/templates/administration/base.html b/authentik/admin/templates/administration/base.html
similarity index 100%
rename from passbook/admin/templates/administration/base.html
rename to authentik/admin/templates/administration/base.html
diff --git a/authentik/admin/templates/administration/certificatekeypair/list.html b/authentik/admin/templates/administration/certificatekeypair/list.html
new file mode 100644
index 000000000..8acfcb016
--- /dev/null
+++ b/authentik/admin/templates/administration/certificatekeypair/list.html
@@ -0,0 +1,116 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Certificate-Key Pairs' %}
+
+
{% trans "Import certificates of external providers or create certificates to sign requests with." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Name' %}
+ {% trans 'Private Key available' %}
+ {% trans 'Fingerprint' %}
+
+
+
+
+ {% for kp in object_list %}
+
+
+
+
+
+
+ {% if kp.key_data is not None %}
+ {% trans 'Yes' %}
+ {% else %}
+ {% trans 'No' %}
+ {% endif %}
+
+
+
+ {{ kp.fingerprint }}
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Certificates.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any certificates." %}
+ {% else %}
+ {% trans 'Currently no certificates exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/passbook/admin/templates/administration/flow/import.html b/authentik/admin/templates/administration/flow/import.html
similarity index 100%
rename from passbook/admin/templates/administration/flow/import.html
rename to authentik/admin/templates/administration/flow/import.html
diff --git a/authentik/admin/templates/administration/flow/list.html b/authentik/admin/templates/administration/flow/list.html
new file mode 100644
index 000000000..f1a27a0d3
--- /dev/null
+++ b/authentik/admin/templates/administration/flow/list.html
@@ -0,0 +1,135 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Flows' %}
+
+
{% trans "Flows describe a chain of Stages to authenticate, enroll or recover a user. Stages are chosen based on policies applied to them." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Identifier' %}
+ {% trans 'Designation' %}
+ {% trans 'Stages' %}
+ {% trans 'Policies' %}
+
+
+
+
+ {% for flow in object_list %}
+
+
+
+
{{ flow.slug }}
+
{{ flow.name }}
+
+
+
+
+ {{ flow.designation }}
+
+
+
+
+ {{ flow.stages.all|length }}
+
+
+
+
+ {{ flow.policies.all|length }}
+
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+ {% trans 'Execute' %}
+ {% trans 'Export' %}
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Flows.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any flows." %}
+ {% else %}
+ {% trans 'Currently no flows exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+ {% trans 'Import' %}
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/group/list.html b/authentik/admin/templates/administration/group/list.html
new file mode 100644
index 000000000..3d1b8eb0f
--- /dev/null
+++ b/authentik/admin/templates/administration/group/list.html
@@ -0,0 +1,114 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Groups' %}
+
+
{% trans "Group users together and give them permissions based on the membership." %}
+
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Name' %}
+ {% trans 'Parent' %}
+ {% trans 'Members' %}
+
+
+
+
+ {% for group in object_list %}
+
+
+
+ {{ group.name }}
+
+
+
+
+ {{ group.parent }}
+
+
+
+
+ {{ group.users.all|length }}
+
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Groups.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any groups." %}
+ {% else %}
+ {% trans 'Currently no group exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/outpost/list.html b/authentik/admin/templates/administration/outpost/list.html
new file mode 100644
index 000000000..2af849395
--- /dev/null
+++ b/authentik/admin/templates/administration/outpost/list.html
@@ -0,0 +1,149 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load humanize %}
+{% load authentik_utils %}
+{% load admin_reflection %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Outposts' %}
+
+
{% trans "Outposts are deployments of authentik components to support different environments and protocols, like reverse proxies." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Name' %}
+ {% trans 'Providers' %}
+ {% trans 'Health' %}
+ {% trans 'Version' %}
+
+
+
+
+ {% for outpost in object_list %}
+
+
+ {{ outpost.name }}
+
+
+
+ {{ outpost.providers.all.select_subclasses|join:", " }}
+
+
+ {% with states=outpost.state %}
+ {% if states|length > 0 %}
+
+ {% for state in states %}
+
+ {% if state.last_seen %}
+ {{ state.last_seen|naturaltime }}
+ {% else %}
+ {% trans 'Unhealthy' %}
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% for state in states %}
+
+ {% if not state.version %}
+
+ {% elif state.version_outdated %}
+ {% blocktrans with is=state.version should=state.version_should %}{{ is }}, should be {{ should }}{% endblocktrans %}
+ {% else %}
+ {{ state.version }}
+ {% endif %}
+
+ {% endfor %}
+
+ {% else %}
+
+
+
+
+
+
+ {% endif %}
+ {% endwith %}
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+ {% get_htmls outpost as htmls %}
+ {% for html in htmls %}
+ {{ html|safe }}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Outposts.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any outposts." %}
+ {% else %}
+ {% trans 'Currently no outposts exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/outpost_service_connection/list.html b/authentik/admin/templates/administration/outpost_service_connection/list.html
new file mode 100644
index 000000000..79cee25d0
--- /dev/null
+++ b/authentik/admin/templates/administration/outpost_service_connection/list.html
@@ -0,0 +1,154 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load humanize %}
+{% load authentik_utils %}
+{% load admin_reflection %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Outpost Service-Connections' %}
+
+
{% trans "Outpost Service-Connections define how authentik connects to external platforms to manage and deploy Outposts." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Name' %}
+ {% trans 'Type' %}
+ {% trans 'Local?' %}
+ {% trans 'Status' %}
+
+
+
+
+ {% for sc in object_list %}
+
+
+ {{ sc.name }}
+
+
+
+ {{ sc|verbose_name }}
+
+
+
+
+ {{ sc.local|yesno:"Yes,No" }}
+
+
+
+
+ {% if sc.state.healthy %}
+ {{ sc.state.version }}
+ {% else %}
+ {% trans 'Unhealthy' %}
+ {% endif %}
+
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Outpost Service Connections.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any outposts." %}
+ {% else %}
+ {% trans 'Currently no service connections exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/overview.html b/authentik/admin/templates/administration/overview.html
new file mode 100644
index 000000000..ee38a9575
--- /dev/null
+++ b/authentik/admin/templates/administration/overview.html
@@ -0,0 +1,230 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load static %}
+
+{% block content %}
+
+
+
{% trans 'System Overview' %}
+
+
+
+
+
+
+
+
+
+
+
+ {% trans 'Application' %}
+ {% trans 'Logins' %}
+
+
+
+
+ {% for app in most_used_applications %}
+
+
+ {{ app.application.name }}
+
+
+ {{ app.total_logins }}
+
+
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+ {% if providers_without_application.exists %}
+
+ {{ provider_count }}
+
+
{% trans 'Warning: At least one Provider has no application assigned.' %}
+ {% else %}
+
+ {{ provider_count }}
+
+ {% endif %}
+
+
+
+
+
+
+ {% if policies_without_binding %}
+
+ {{ policy_count }}
+
+
{% trans 'Policies without binding exist.' %}
+ {% else %}
+
+ {{ policy_count }}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+ {% if version >= version_latest %}
+ {{ version }}
+ {% else %}
+ {{ version }}
+ {% endif %}
+
+ {% if version >= version_latest %}
+ {% blocktrans %}
+ Up-to-date!
+ {% endblocktrans %}
+ {% else %}
+ {% blocktrans with latest=version_latest %}
+ {{ latest }} is available!
+ {% endblocktrans %}
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
{% trans 'No workers connected.' %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if cached_policies < 1 %}
+
+ {{ cached_policies }}
+
+
{% trans 'No policies cached. Users may experience slow response times.' %}
+ {% else %}
+
+ {{ cached_policies }}
+
+ {% endif %}
+
+
+
+
+
+
+ {% if cached_flows < 1 %}
+
+ {{ cached_flows }}
+
+
{% trans 'No flows cached.' %}
+ {% else %}
+
+ {{ cached_flows }}
+
+ {% endif %}
+
+
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/policy/list.html b/authentik/admin/templates/administration/policy/list.html
new file mode 100644
index 000000000..618013c5f
--- /dev/null
+++ b/authentik/admin/templates/administration/policy/list.html
@@ -0,0 +1,148 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Policies' %}
+
+
{% trans "Allow users to use Applications based on properties, enforce Password Criteria and selectively apply Stages." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Name' %}
+ {% trans 'Type' %}
+
+
+
+
+ {% for policy in object_list %}
+
+
+
+
{{ policy.name }}
+ {% if not policy.bindings.exists and not policy.promptstage_set.exists %}
+
+
{% trans 'Warning: Policy is not assigned.' %}
+ {% else %}
+
+
{% blocktrans with object_count=policy.bindings.all|length %}Assigned to {{ object_count }} objects.{% endblocktrans %}
+ {% endif %}
+
+
+
+
+ {{ policy|verbose_name }}
+
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Test' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Policies.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any policies." %}
+ {% else %}
+ {% trans 'Currently no policies exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/passbook/admin/templates/administration/policy/test.html b/authentik/admin/templates/administration/policy/test.html
similarity index 100%
rename from passbook/admin/templates/administration/policy/test.html
rename to authentik/admin/templates/administration/policy/test.html
diff --git a/authentik/admin/templates/administration/policy_binding/list.html b/authentik/admin/templates/administration/policy_binding/list.html
new file mode 100644
index 000000000..ee581d6c8
--- /dev/null
+++ b/authentik/admin/templates/administration/policy_binding/list.html
@@ -0,0 +1,119 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Policy Bindings' %}
+
+
{% trans "Bind existing Policies to Models accepting policies." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Policy' %}
+ {% trans 'Enabled' %}
+ {% trans 'Order' %}
+ {% trans 'Timeout' %}
+
+
+
+
+ {% for pbm in object_list %}
+
+
+ {{ pbm }}
+
+ {{ pbm|fieldtype }}
+
+
+
+
+
+
+
+ {% for binding in pbm.bindings %}
+
+
+ {{ binding.policy }}
+
+ {{ binding.policy|fieldtype }}
+
+
+
+ {{ binding.enabled }}
+
+
+ {{ binding.order }}
+
+
+ {{ binding.timeout }}
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+
+
+ {% endfor %}
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+ {% trans 'No Policy Bindings.' %}
+
+
+ {% trans 'Currently no policy bindings exist. Click the button below to create one.' %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/property_mapping/list.html b/authentik/admin/templates/administration/property_mapping/list.html
new file mode 100644
index 000000000..6b1f72ec4
--- /dev/null
+++ b/authentik/admin/templates/administration/property_mapping/list.html
@@ -0,0 +1,139 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Property Mappings' %}
+
+
{% trans "Control how authentik exposes and interprets information." %}
+
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Name' %}
+ {% trans 'Type' %}
+
+
+
+
+ {% for property_mapping in object_list %}
+
+
+
+ {{ property_mapping.name }}
+
+
+
+
+ {{ property_mapping|verbose_name }}
+
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Property Mappings.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any property mappings." %}
+ {% else %}
+ {% trans 'Currently no property mappings exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/provider/list.html b/authentik/admin/templates/administration/provider/list.html
new file mode 100644
index 000000000..572dc7caf
--- /dev/null
+++ b/authentik/admin/templates/administration/provider/list.html
@@ -0,0 +1,159 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+{% load admin_reflection %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Providers' %}
+
+
{% trans "Provide support for protocols like SAML and OAuth to assigned applications." %}
+
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Name' %}
+ {% trans 'Type' %}
+
+
+
+
+ {% for provider in object_list %}
+
+
+
+
{{ provider.name }}
+ {% if not provider.application %}
+
+
{% trans 'Warning: Provider not assigned to any application.' %}
+ {% else %}
+
+
+ {% blocktrans with app=provider.application %}
+ Assigned to application {{ app }}.
+ {% endblocktrans %}
+
+ {% endif %}
+
+
+
+
+ {{ provider|verbose_name }}
+
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+ {% get_links provider as links %}
+ {% for name, href in links.items %}
+ {% trans name %}
+ {% endfor %}
+ {% get_htmls provider as htmls %}
+ {% for html in htmls %}
+ {{ html|safe }}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Providers.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any providers." %}
+ {% else %}
+ {% trans 'Currently no providers exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/source/list.html b/authentik/admin/templates/administration/source/list.html
new file mode 100644
index 000000000..a4a01442e
--- /dev/null
+++ b/authentik/admin/templates/administration/source/list.html
@@ -0,0 +1,153 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+{% load admin_reflection %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Source' %}
+
+
{% trans "External Sources which can be used to get Identities into authentik, for example Social Providers like Twiter and GitHub or Enterprise Providers like ADFS and LDAP." %}
+
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Name' %}
+ {% trans 'Type' %}
+ {% trans 'Additional Info' %}
+
+
+
+
+ {% for source in object_list %}
+
+
+
+
{{ source.name }}
+ {% if not source.enabled %}
+
{% trans 'Disabled' %}
+ {% endif %}
+
+
+
+
+ {{ source|fieldtype }}
+
+
+
+
+ {{ source.ui_additional_info|default:""|safe }}
+
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+ {% get_links source as links %}
+ {% for name, href in links %}
+ {% trans name %}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Sources.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any sources." %}
+ {% else %}
+ {% trans 'Currently no sources exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/stage/list.html b/authentik/admin/templates/administration/stage/list.html
new file mode 100644
index 000000000..4fe3d48ff
--- /dev/null
+++ b/authentik/admin/templates/administration/stage/list.html
@@ -0,0 +1,148 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+{% load admin_reflection %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Stages' %}
+
+
{% trans "Stages are single steps of a Flow that a user is guided through." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Name' %}
+ {% trans 'Flows' %}
+
+
+
+
+ {% for stage in object_list %}
+
+
+
+
{{ stage.name }}
+
{{ stage|verbose_name }}
+
+
+
+
+ {% for flow in stage.flow_set.all %}
+ {{ flow.slug }}<
+ {% empty %}
+ -
+ {% endfor %}
+
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+ {% get_links stage as links %}
+ {% for name, href in links.items %}
+ {% trans name %}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Stages.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any stages." %}
+ {% else %}
+ {% trans 'Currently no stages exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/stage_binding/list.html b/authentik/admin/templates/administration/stage_binding/list.html
new file mode 100644
index 000000000..c4a772a62
--- /dev/null
+++ b/authentik/admin/templates/administration/stage_binding/list.html
@@ -0,0 +1,125 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Stage Bindings' %}
+
+
{% trans "Bind existing Stages to Flows." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Order' %}
+ {% trans 'Name' %}
+ {% trans 'Stage Type' %}
+
+
+
+
+ {% regroup object_list by target as grouped_bindings %}
+ {% for flow in grouped_bindings %}
+
+
+ {% blocktrans with slug=flow.grouper.slug %}
+ Flow {{ slug }}
+ {% endblocktrans %}
+
+
+
+
+
+ {% for binding in flow.list %}
+
+
+
+ {{ binding.order }}
+
+
+
+
+
{{ binding.target.slug }}
+
+ {{ binding.target.name }}
+
+
+
+
+
+
+ {{ binding.stage.name }}
+
+
+ {{ binding.stage }}
+
+
+
+
+
+
+ {% trans 'Update' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+
+
+ {% endfor %}
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+ {% trans 'No Flow-Stage Bindings.' %}
+
+
+ {% trans 'Currently no flow-stage bindings exist. Click the button below to create one.' %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/stage_invitation/list.html b/authentik/admin/templates/administration/stage_invitation/list.html
new file mode 100644
index 000000000..109c755d4
--- /dev/null
+++ b/authentik/admin/templates/administration/stage_invitation/list.html
@@ -0,0 +1,103 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Invitations' %}
+
+
{% trans "Create Invitation Links to enroll Users, and optionally force specific attributes of their account." %}
+
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Expiry' %}
+ {% trans 'Link' %}
+
+
+
+
+ {% for invitation in object_list %}
+
+
+
+ {{ invitation.expiry }}
+
+
+
+
+ {{ invitation.Link }}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Invitations.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any invitations." %}
+ {% else %}
+ {% trans 'Currently no invitations exist. Click the button below to create one.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/stage_prompt/list.html b/authentik/admin/templates/administration/stage_prompt/list.html
new file mode 100644
index 000000000..45ca2e5bc
--- /dev/null
+++ b/authentik/admin/templates/administration/stage_prompt/list.html
@@ -0,0 +1,130 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+{% load admin_reflection %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Prompts' %}
+
+
{% trans "Single Prompts that can be used for Prompt Stages." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Field' %}
+ {% trans 'Label' %}
+ {% trans 'Type' %}
+ {% trans 'Order' %}
+ {% trans 'Flows' %}
+
+
+
+
+ {% for prompt in object_list %}
+
+
+
+
{{ prompt.field_key }}
+
+
+
+
+ {{ prompt.label }}
+
+
+
+
+ {{ prompt.type }}
+
+
+
+
+ {{ prompt.order }}
+
+
+
+
+ {% for flow in prompt.flow_set.all %}
+ {{ flow.slug }}
+ {% empty %}
+ -
+ {% endfor %}
+
+
+
+
+
+ {% trans 'Update' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+ {% get_links prompt as links %}
+ {% for name, href in links.items %}
+ {% trans name %}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Stage Prompts.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any stage prompts." %}
+ {% else %}
+ {% trans 'Currently no stage prompts exist. Click the button below to create one.' %}
+ {% endif %}
+
+
{% trans 'Create' %}
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/task/list.html b/authentik/admin/templates/administration/task/list.html
new file mode 100644
index 000000000..7f0ef9ea5
--- /dev/null
+++ b/authentik/admin/templates/administration/task/list.html
@@ -0,0 +1,84 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load humanize %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'System Tasks' %}
+
+
{% trans "Long-running operations which authentik executes in the background." %}
+
+
+
+
+
+
+
+
+ {% trans 'Identifier' %}
+ {% trans 'Description' %}
+ {% trans 'Last Run' %}
+ {% trans 'Status' %}
+ {% trans 'Messages' %}
+
+
+
+
+ {% for task in object_list %}
+
+
+ {{ task.task_name }}
+
+
+
+ {{ task.task_description }}
+
+
+
+
+ {{ task.finish_timestamp|naturaltime }}
+
+
+
+
+ {% if task.result.status == task_successful %}
+ {% trans 'Successful' %}
+ {% elif task.result.status == task_warning %}
+ {% trans 'Warning' %}
+ {% elif task.result.status == task_error %}
+ {% trans 'Error' %}
+ {% else %}
+ {% trans 'Unknown' %}
+ {% endif %}
+
+
+
+ {% for message in task.result.messages %}
+
+ {{ message }}
+
+ {% endfor %}
+
+
+
+ {% trans 'Retry Task' %}
+
+
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/token/list.html b/authentik/admin/templates/administration/token/list.html
new file mode 100644
index 000000000..9eb7667a4
--- /dev/null
+++ b/authentik/admin/templates/administration/token/list.html
@@ -0,0 +1,102 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Tokens' %}
+
+
{% trans "Tokens are used throughout authentik for Email validation stages, Recovery keys and API access." %}
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Identifier' %}
+ {% trans 'User' %}
+ {% trans 'Expires?' %}
+ {% trans 'Expiry Date' %}
+
+
+
+
+ {% for token in object_list %}
+
+
+ {{ token.identifier }}
+
+
+
+ {{ token.user }}
+
+
+
+
+ {{ token.expiring|yesno:"Yes,No" }}
+
+
+
+
+ {% if not token.expiring %}
+ -
+ {% else %}
+ {{ token.expires }}
+ {% endif %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+
+ {% trans 'Copy token' %}
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Tokens.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any token." %}
+ {% else %}
+ {% trans 'Currently no tokens exist.' %}
+ {% endif %}
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/user/disable.html b/authentik/admin/templates/administration/user/disable.html
new file mode 100644
index 000000000..3069d8809
--- /dev/null
+++ b/authentik/admin/templates/administration/user/disable.html
@@ -0,0 +1,42 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+ {% block above_form %}
+
+ {% blocktrans with object_type=object|verbose_name %}
+ Disable {{ object_type }}
+ {% endblocktrans %}
+
+ {% endblock %}
+
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/administration/user/list.html b/authentik/admin/templates/administration/user/list.html
new file mode 100644
index 000000000..fe291cb51
--- /dev/null
+++ b/authentik/admin/templates/administration/user/list.html
@@ -0,0 +1,125 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'Users' %}
+
+
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Name' %}
+ {% trans 'Active' %}
+ {% trans 'Last Login' %}
+
+
+
+
+ {% for user in object_list %}
+
+
+
+
{{ user.username }}
+
{{ user.name }}
+
+
+
+
+ {{ user.is_active }}
+
+
+
+
+ {{ user.last_login }}
+
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+ {% if user.is_active %}
+
+
+ {% trans 'Disable' %}
+
+
+
+ {% else %}
+
+
+ {% trans 'Enable' %}
+
+
+
+ {% endif %}
+ {% trans 'Reset Password' %}
+ {% trans 'Impersonate' %}
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+
+ {% trans 'No Users.' %}
+
+
+ {% if request.GET.search != "" %}
+ {% trans "Your search query doesn't match any users." %}
+ {% else %}
+ {% trans 'Currently no users exist. How did you even get here.' %}
+ {% endif %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/authentik/admin/templates/fields/codemirror.html b/authentik/admin/templates/fields/codemirror.html
new file mode 100644
index 000000000..19040059d
--- /dev/null
+++ b/authentik/admin/templates/fields/codemirror.html
@@ -0,0 +1 @@
+
diff --git a/authentik/admin/templates/generic/create.html b/authentik/admin/templates/generic/create.html
new file mode 100644
index 000000000..c1cbe91f1
--- /dev/null
+++ b/authentik/admin/templates/generic/create.html
@@ -0,0 +1,18 @@
+{% extends base_template|default:"generic/form.html" %}
+
+{% load authentik_utils %}
+{% load i18n %}
+
+{% block above_form %}
+
+ {% blocktrans with type=form|form_verbose_name %}
+ Create {{ type }}
+ {% endblocktrans %}
+
+{% endblock %}
+
+{% block action %}
+{% blocktrans with type=form|form_verbose_name %}
+Create {{ type }}
+{% endblocktrans %}
+{% endblock %}
diff --git a/authentik/admin/templates/generic/form.html b/authentik/admin/templates/generic/form.html
new file mode 100644
index 000000000..d7208c69f
--- /dev/null
+++ b/authentik/admin/templates/generic/form.html
@@ -0,0 +1,38 @@
+{% extends container_template|default:"administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+{% load static %}
+
+{% block content %}
+
+
+ {% block above_form %}
+ {% endblock %}
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+{{ block.super }}
+{{ form.media.js }}
+{% endblock %}
diff --git a/authentik/admin/templates/generic/form_non_model.html b/authentik/admin/templates/generic/form_non_model.html
new file mode 100644
index 000000000..6223e33c7
--- /dev/null
+++ b/authentik/admin/templates/generic/form_non_model.html
@@ -0,0 +1,20 @@
+{% extends base_template|default:"generic/form.html" %}
+
+{% load authentik_utils %}
+{% load i18n %}
+
+{% block above_form %}
+
+ {% trans form.title %}
+
+{% endblock %}
+
+{% block beneath_form %}
+
+ {% trans form.body %}
+
+{% endblock %}
+
+{% block action %}
+{% trans 'Confirm' %}
+{% endblock %}
diff --git a/authentik/admin/templates/generic/update.html b/authentik/admin/templates/generic/update.html
new file mode 100644
index 000000000..7b46d40f9
--- /dev/null
+++ b/authentik/admin/templates/generic/update.html
@@ -0,0 +1,18 @@
+{% extends base_template|default:"generic/form.html" %}
+
+{% load authentik_utils %}
+{% load i18n %}
+
+{% block above_form %}
+
+ {% blocktrans with type=form|form_verbose_name|title inst=form.instance %}
+ Update {{ inst }}
+ {% endblocktrans %}
+
+{% endblock %}
+
+{% block action %}
+{% blocktrans with type=form|form_verbose_name %}
+Update {{ type }}
+{% endblocktrans %}
+{% endblock %}
diff --git a/passbook/admin/templatetags/__init__.py b/authentik/admin/templatetags/__init__.py
similarity index 100%
rename from passbook/admin/templatetags/__init__.py
rename to authentik/admin/templatetags/__init__.py
diff --git a/authentik/admin/templatetags/admin_reflection.py b/authentik/admin/templatetags/admin_reflection.py
new file mode 100644
index 000000000..33c334dc9
--- /dev/null
+++ b/authentik/admin/templatetags/admin_reflection.py
@@ -0,0 +1,62 @@
+"""authentik admin templatetags"""
+from django import template
+from django.db.models import Model
+from django.utils.html import mark_safe
+from structlog import get_logger
+
+register = template.Library()
+LOGGER = get_logger()
+
+
+@register.simple_tag()
+def get_links(model_instance):
+ """Find all link_ methods on an object instance, run them and return as dict"""
+ prefix = "link_"
+ links = {}
+
+ if not isinstance(model_instance, Model):
+ LOGGER.warning("Model is not instance of Model", model_instance=model_instance)
+ return links
+
+ try:
+ for name in dir(model_instance):
+ if not name.startswith(prefix):
+ continue
+ value = getattr(model_instance, name)
+ if not callable(value):
+ continue
+ human_name = name.replace(prefix, "").replace("_", " ").capitalize()
+ link = value()
+ if link:
+ links[human_name] = link
+ except NotImplementedError:
+ pass
+
+ return links
+
+
+@register.simple_tag(takes_context=True)
+def get_htmls(context, model_instance):
+ """Find all html_ methods on an object instance, run them and return as dict"""
+ prefix = "html_"
+ htmls = []
+
+ if not isinstance(model_instance, Model):
+ LOGGER.warning("Model is not instance of Model", model_instance=model_instance)
+ return htmls
+
+ try:
+ for name in dir(model_instance):
+ if not name.startswith(prefix):
+ continue
+ value = getattr(model_instance, name)
+ if not callable(value):
+ continue
+ if name.startswith(prefix):
+ html = value(context.get("request"))
+ if html:
+ htmls.append(mark_safe(html))
+ except NotImplementedError:
+ pass
+
+ return htmls
diff --git a/authentik/admin/tests.py b/authentik/admin/tests.py
new file mode 100644
index 000000000..5f9399a07
--- /dev/null
+++ b/authentik/admin/tests.py
@@ -0,0 +1,66 @@
+"""admin tests"""
+from importlib import import_module
+from typing import Callable
+
+from django.forms import ModelForm
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.urls.exceptions import NoReverseMatch
+
+from authentik.admin.urls import urlpatterns
+from authentik.core.models import Group, User
+from authentik.lib.utils.reflection import get_apps
+
+
+class TestAdmin(TestCase):
+ """Generic admin tests"""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username="test")
+ self.user.ak_groups.add(Group.objects.filter(is_superuser=True).first())
+ self.user.save()
+ self.client = Client()
+ self.client.force_login(self.user)
+
+
+def generic_view_tester(view_name: str) -> Callable:
+ """This is used instead of subTest for better visibility"""
+
+ def tester(self: TestAdmin):
+ try:
+ full_url = reverse(f"authentik_admin:{view_name}")
+ response = self.client.get(full_url)
+ self.assertTrue(response.status_code < 500)
+ except NoReverseMatch:
+ pass
+
+ return tester
+
+
+for url in urlpatterns:
+ method_name = url.name.replace("-", "_")
+ setattr(TestAdmin, f"test_view_{method_name}", generic_view_tester(url.name))
+
+
+def generic_form_tester(form: ModelForm) -> Callable:
+ """Test a form"""
+
+ def tester(self: TestAdmin):
+ form_inst = form()
+ self.assertFalse(form_inst.is_valid())
+
+ return tester
+
+
+# Load the forms module from every app, so we have all forms loaded
+for app in get_apps():
+ module = app.__module__.replace(".apps", ".forms")
+ try:
+ import_module(module)
+ except ImportError:
+ pass
+
+for form_class in ModelForm.__subclasses__():
+ setattr(
+ TestAdmin, f"test_form_{form_class.__name__}", generic_form_tester(form_class)
+ )
diff --git a/authentik/admin/urls.py b/authentik/admin/urls.py
new file mode 100644
index 000000000..ecd81d8d6
--- /dev/null
+++ b/authentik/admin/urls.py
@@ -0,0 +1,353 @@
+"""authentik URL Configuration"""
+from django.urls import path
+
+from authentik.admin.views import (
+ applications,
+ certificate_key_pair,
+ flows,
+ groups,
+ outposts,
+ outposts_service_connections,
+ overview,
+ policies,
+ policies_bindings,
+ property_mappings,
+ providers,
+ sources,
+ stages,
+ stages_bindings,
+ stages_invitations,
+ stages_prompts,
+ tasks,
+ tokens,
+ users,
+)
+
+urlpatterns = [
+ path(
+ "overview/cache/flow/",
+ overview.FlowCacheClearView.as_view(),
+ name="overview-clear-flow-cache",
+ ),
+ path(
+ "overview/cache/policy/",
+ overview.PolicyCacheClearView.as_view(),
+ name="overview-clear-policy-cache",
+ ),
+ path("overview/", overview.AdministrationOverviewView.as_view(), name="overview"),
+ # Applications
+ path(
+ "applications/", applications.ApplicationListView.as_view(), name="applications"
+ ),
+ path(
+ "applications/create/",
+ applications.ApplicationCreateView.as_view(),
+ name="application-create",
+ ),
+ path(
+ "applications//update/",
+ applications.ApplicationUpdateView.as_view(),
+ name="application-update",
+ ),
+ path(
+ "applications//delete/",
+ applications.ApplicationDeleteView.as_view(),
+ name="application-delete",
+ ),
+ # Tokens
+ path("tokens/", tokens.TokenListView.as_view(), name="tokens"),
+ path(
+ "tokens//delete/",
+ tokens.TokenDeleteView.as_view(),
+ name="token-delete",
+ ),
+ # Sources
+ path("sources/", sources.SourceListView.as_view(), name="sources"),
+ path("sources/create/", sources.SourceCreateView.as_view(), name="source-create"),
+ path(
+ "sources//update/",
+ sources.SourceUpdateView.as_view(),
+ name="source-update",
+ ),
+ path(
+ "sources//delete/",
+ sources.SourceDeleteView.as_view(),
+ name="source-delete",
+ ),
+ # Policies
+ path("policies/", policies.PolicyListView.as_view(), name="policies"),
+ path("policies/create/", policies.PolicyCreateView.as_view(), name="policy-create"),
+ path(
+ "policies//update/",
+ policies.PolicyUpdateView.as_view(),
+ name="policy-update",
+ ),
+ path(
+ "policies//delete/",
+ policies.PolicyDeleteView.as_view(),
+ name="policy-delete",
+ ),
+ path(
+ "policies//test/",
+ policies.PolicyTestView.as_view(),
+ name="policy-test",
+ ),
+ # Policy bindings
+ path(
+ "policies/bindings/",
+ policies_bindings.PolicyBindingListView.as_view(),
+ name="policies-bindings",
+ ),
+ path(
+ "policies/bindings/create/",
+ policies_bindings.PolicyBindingCreateView.as_view(),
+ name="policy-binding-create",
+ ),
+ path(
+ "policies/bindings//update/",
+ policies_bindings.PolicyBindingUpdateView.as_view(),
+ name="policy-binding-update",
+ ),
+ path(
+ "policies/bindings//delete/",
+ policies_bindings.PolicyBindingDeleteView.as_view(),
+ name="policy-binding-delete",
+ ),
+ # Providers
+ path("providers/", providers.ProviderListView.as_view(), name="providers"),
+ path(
+ "providers/create/",
+ providers.ProviderCreateView.as_view(),
+ name="provider-create",
+ ),
+ path(
+ "providers//update/",
+ providers.ProviderUpdateView.as_view(),
+ name="provider-update",
+ ),
+ path(
+ "providers//delete/",
+ providers.ProviderDeleteView.as_view(),
+ name="provider-delete",
+ ),
+ # Stages
+ path("stages/", stages.StageListView.as_view(), name="stages"),
+ path("stages/create/", stages.StageCreateView.as_view(), name="stage-create"),
+ path(
+ "stages//update/",
+ stages.StageUpdateView.as_view(),
+ name="stage-update",
+ ),
+ path(
+ "stages//delete/",
+ stages.StageDeleteView.as_view(),
+ name="stage-delete",
+ ),
+ # Stage bindings
+ path(
+ "stages/bindings/",
+ stages_bindings.StageBindingListView.as_view(),
+ name="stage-bindings",
+ ),
+ path(
+ "stages/bindings/create/",
+ stages_bindings.StageBindingCreateView.as_view(),
+ name="stage-binding-create",
+ ),
+ path(
+ "stages/bindings//update/",
+ stages_bindings.StageBindingUpdateView.as_view(),
+ name="stage-binding-update",
+ ),
+ path(
+ "stages/bindings//delete/",
+ stages_bindings.StageBindingDeleteView.as_view(),
+ name="stage-binding-delete",
+ ),
+ # Stage Prompts
+ path(
+ "stages/prompts/",
+ stages_prompts.PromptListView.as_view(),
+ name="stage-prompts",
+ ),
+ path(
+ "stages/prompts/create/",
+ stages_prompts.PromptCreateView.as_view(),
+ name="stage-prompt-create",
+ ),
+ path(
+ "stages/prompts//update/",
+ stages_prompts.PromptUpdateView.as_view(),
+ name="stage-prompt-update",
+ ),
+ path(
+ "stages/prompts//delete/",
+ stages_prompts.PromptDeleteView.as_view(),
+ name="stage-prompt-delete",
+ ),
+ # Stage Invitations
+ path(
+ "stages/invitations/",
+ stages_invitations.InvitationListView.as_view(),
+ name="stage-invitations",
+ ),
+ path(
+ "stages/invitations/create/",
+ stages_invitations.InvitationCreateView.as_view(),
+ name="stage-invitation-create",
+ ),
+ path(
+ "stages/invitations//delete/",
+ stages_invitations.InvitationDeleteView.as_view(),
+ name="stage-invitation-delete",
+ ),
+ # Flows
+ path("flows/", flows.FlowListView.as_view(), name="flows"),
+ path(
+ "flows/create/",
+ flows.FlowCreateView.as_view(),
+ name="flow-create",
+ ),
+ path(
+ "flows/import/",
+ flows.FlowImportView.as_view(),
+ name="flow-import",
+ ),
+ path(
+ "flows//update/",
+ flows.FlowUpdateView.as_view(),
+ name="flow-update",
+ ),
+ path(
+ "flows//execute/",
+ flows.FlowDebugExecuteView.as_view(),
+ name="flow-execute",
+ ),
+ path(
+ "flows//export/",
+ flows.FlowExportView.as_view(),
+ name="flow-export",
+ ),
+ path(
+ "flows//delete/",
+ flows.FlowDeleteView.as_view(),
+ name="flow-delete",
+ ),
+ # Property Mappings
+ path(
+ "property-mappings/",
+ property_mappings.PropertyMappingListView.as_view(),
+ name="property-mappings",
+ ),
+ path(
+ "property-mappings/create/",
+ property_mappings.PropertyMappingCreateView.as_view(),
+ name="property-mapping-create",
+ ),
+ path(
+ "property-mappings//update/",
+ property_mappings.PropertyMappingUpdateView.as_view(),
+ name="property-mapping-update",
+ ),
+ path(
+ "property-mappings//delete/",
+ property_mappings.PropertyMappingDeleteView.as_view(),
+ name="property-mapping-delete",
+ ),
+ # Users
+ path("users/", users.UserListView.as_view(), name="users"),
+ path("users/create/", users.UserCreateView.as_view(), name="user-create"),
+ path("users//update/", users.UserUpdateView.as_view(), name="user-update"),
+ path("users//delete/", users.UserDeleteView.as_view(), name="user-delete"),
+ path(
+ "users//disable/", users.UserDisableView.as_view(), name="user-disable"
+ ),
+ path("users//enable/", users.UserEnableView.as_view(), name="user-enable"),
+ path(
+ "users//reset/",
+ users.UserPasswordResetView.as_view(),
+ name="user-password-reset",
+ ),
+ # Groups
+ path("groups/", groups.GroupListView.as_view(), name="groups"),
+ path("groups/create/", groups.GroupCreateView.as_view(), name="group-create"),
+ path(
+ "groups//update/",
+ groups.GroupUpdateView.as_view(),
+ name="group-update",
+ ),
+ path(
+ "groups//delete/",
+ groups.GroupDeleteView.as_view(),
+ name="group-delete",
+ ),
+ # Certificate-Key Pairs
+ path(
+ "crypto/certificates/",
+ certificate_key_pair.CertificateKeyPairListView.as_view(),
+ name="certificate_key_pair",
+ ),
+ path(
+ "crypto/certificates/create/",
+ certificate_key_pair.CertificateKeyPairCreateView.as_view(),
+ name="certificatekeypair-create",
+ ),
+ path(
+ "crypto/certificates//update/",
+ certificate_key_pair.CertificateKeyPairUpdateView.as_view(),
+ name="certificatekeypair-update",
+ ),
+ path(
+ "crypto/certificates//delete/",
+ certificate_key_pair.CertificateKeyPairDeleteView.as_view(),
+ name="certificatekeypair-delete",
+ ),
+ # Outposts
+ path(
+ "outposts/",
+ outposts.OutpostListView.as_view(),
+ name="outposts",
+ ),
+ path(
+ "outposts/create/",
+ outposts.OutpostCreateView.as_view(),
+ name="outpost-create",
+ ),
+ path(
+ "outposts//update/",
+ outposts.OutpostUpdateView.as_view(),
+ name="outpost-update",
+ ),
+ path(
+ "outposts//delete/",
+ outposts.OutpostDeleteView.as_view(),
+ name="outpost-delete",
+ ),
+ # Outpost Service Connections
+ path(
+ "outposts/service_connections/",
+ outposts_service_connections.OutpostServiceConnectionListView.as_view(),
+ name="outpost-service-connections",
+ ),
+ path(
+ "outposts/service_connections/create/",
+ outposts_service_connections.OutpostServiceConnectionCreateView.as_view(),
+ name="outpost-service-connection-create",
+ ),
+ path(
+ "outposts/service_connections//update/",
+ outposts_service_connections.OutpostServiceConnectionUpdateView.as_view(),
+ name="outpost-service-connection-update",
+ ),
+ path(
+ "outposts/service_connections//delete/",
+ outposts_service_connections.OutpostServiceConnectionDeleteView.as_view(),
+ name="outpost-service-connection-delete",
+ ),
+ # Tasks
+ path(
+ "tasks/",
+ tasks.TaskListView.as_view(),
+ name="tasks",
+ ),
+]
diff --git a/passbook/admin/views/__init__.py b/authentik/admin/views/__init__.py
similarity index 100%
rename from passbook/admin/views/__init__.py
rename to authentik/admin/views/__init__.py
diff --git a/authentik/admin/views/applications.py b/authentik/admin/views/applications.py
new file mode 100644
index 000000000..4d440227b
--- /dev/null
+++ b/authentik/admin/views/applications.py
@@ -0,0 +1,93 @@
+"""authentik Application administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import ListView, UpdateView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.core.forms.applications import ApplicationForm
+from authentik.core.models import Application
+from authentik.lib.views import CreateAssignPermView
+
+
+class ApplicationListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ ListView,
+):
+ """Show list of all applications"""
+
+ model = Application
+ permission_required = "authentik_core.view_application"
+ ordering = "name"
+ template_name = "administration/application/list.html"
+
+ search_fields = [
+ "name",
+ "slug",
+ "meta_launch_url",
+ "meta_icon_url",
+ "meta_description",
+ "meta_publisher",
+ ]
+
+
+class ApplicationCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new Application"""
+
+ model = Application
+ form_class = ApplicationForm
+ permission_required = "authentik_core.add_application"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:applications")
+ success_message = _("Successfully created Application")
+
+
+class ApplicationUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ UpdateView,
+):
+ """Update application"""
+
+ model = Application
+ form_class = ApplicationForm
+ permission_required = "authentik_core.change_application"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:applications")
+ success_message = _("Successfully updated Application")
+
+
+class ApplicationDeleteView(
+ LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView
+):
+ """Delete application"""
+
+ model = Application
+ permission_required = "authentik_core.delete_application"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:applications")
+ success_message = _("Successfully deleted Application")
diff --git a/authentik/admin/views/certificate_key_pair.py b/authentik/admin/views/certificate_key_pair.py
new file mode 100644
index 000000000..09e154cf4
--- /dev/null
+++ b/authentik/admin/views/certificate_key_pair.py
@@ -0,0 +1,86 @@
+"""authentik CertificateKeyPair administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import ListView, UpdateView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.crypto.forms import CertificateKeyPairForm
+from authentik.crypto.models import CertificateKeyPair
+from authentik.lib.views import CreateAssignPermView
+
+
+class CertificateKeyPairListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ ListView,
+):
+ """Show list of all keypairs"""
+
+ model = CertificateKeyPair
+ permission_required = "authentik_crypto.view_certificatekeypair"
+ ordering = "name"
+ template_name = "administration/certificatekeypair/list.html"
+
+ search_fields = ["name"]
+
+
+class CertificateKeyPairCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new CertificateKeyPair"""
+
+ model = CertificateKeyPair
+ form_class = CertificateKeyPairForm
+ permission_required = "authentik_crypto.add_certificatekeypair"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:certificate_key_pair")
+ success_message = _("Successfully created CertificateKeyPair")
+
+
+class CertificateKeyPairUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ UpdateView,
+):
+ """Update certificatekeypair"""
+
+ model = CertificateKeyPair
+ form_class = CertificateKeyPairForm
+ permission_required = "authentik_crypto.change_certificatekeypair"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:certificate_key_pair")
+ success_message = _("Successfully updated Certificate-Key Pair")
+
+
+class CertificateKeyPairDeleteView(
+ LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView
+):
+ """Delete certificatekeypair"""
+
+ model = CertificateKeyPair
+ permission_required = "authentik_crypto.delete_certificatekeypair"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:certificate_key_pair")
+ success_message = _("Successfully deleted Certificate-Key Pair")
diff --git a/authentik/admin/views/flows.py b/authentik/admin/views/flows.py
new file mode 100644
index 000000000..1578615fb
--- /dev/null
+++ b/authentik/admin/views/flows.py
@@ -0,0 +1,151 @@
+"""authentik Flow administration"""
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.http import HttpRequest, HttpResponse, JsonResponse
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import DetailView, FormView, ListView, UpdateView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.flows.forms import FlowForm, FlowImportForm
+from authentik.flows.models import Flow
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.transfer.common import DataclassEncoder
+from authentik.flows.transfer.exporter import FlowExporter
+from authentik.flows.transfer.importer import FlowImporter
+from authentik.flows.views import SESSION_KEY_PLAN, FlowPlanner
+from authentik.lib.utils.urls import redirect_with_qs
+from authentik.lib.views import CreateAssignPermView
+
+
+class FlowListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ ListView,
+):
+ """Show list of all flows"""
+
+ model = Flow
+ permission_required = "authentik_flows.view_flow"
+ ordering = "name"
+ template_name = "administration/flow/list.html"
+ search_fields = ["name", "slug", "designation", "title"]
+
+
+class FlowCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new Flow"""
+
+ model = Flow
+ form_class = FlowForm
+ permission_required = "authentik_flows.add_flow"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:flows")
+ success_message = _("Successfully created Flow")
+
+
+class FlowUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ UpdateView,
+):
+ """Update flow"""
+
+ model = Flow
+ form_class = FlowForm
+ permission_required = "authentik_flows.change_flow"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:flows")
+ success_message = _("Successfully updated Flow")
+
+
+class FlowDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
+ """Delete flow"""
+
+ model = Flow
+ permission_required = "authentik_flows.delete_flow"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:flows")
+ success_message = _("Successfully deleted Flow")
+
+
+class FlowDebugExecuteView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
+ """Debug exectue flow, setting the current user as pending user"""
+
+ model = Flow
+ permission_required = "authentik_flows.view_flow"
+
+ # pylint: disable=unused-argument
+ def get(self, request: HttpRequest, pk: str) -> HttpResponse:
+ """Debug exectue flow, setting the current user as pending user"""
+ flow: Flow = self.get_object()
+ planner = FlowPlanner(flow)
+ planner.use_cache = False
+ plan = planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: request.user})
+ self.request.session[SESSION_KEY_PLAN] = plan
+ return redirect_with_qs(
+ "authentik_flows:flow-executor-shell",
+ self.request.GET,
+ flow_slug=flow.slug,
+ )
+
+
+class FlowImportView(LoginRequiredMixin, FormView):
+ """Import flow from JSON Export; only allowed for superusers
+ as these flows can contain python code"""
+
+ form_class = FlowImportForm
+ template_name = "administration/flow/import.html"
+ success_url = reverse_lazy("authentik_admin:flows")
+
+ def dispatch(self, request, *args, **kwargs):
+ if not request.user.is_superuser:
+ return self.handle_no_permission()
+ return super().dispatch(request, *args, **kwargs)
+
+ def form_valid(self, form: FlowImportForm) -> HttpResponse:
+ importer = FlowImporter(form.cleaned_data["flow"].read().decode())
+ successful = importer.apply()
+ if not successful:
+ messages.error(self.request, _("Failed to import flow."))
+ else:
+ messages.success(self.request, _("Successfully imported flow."))
+ return super().form_valid(form)
+
+
+class FlowExportView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
+ """Export Flow"""
+
+ model = Flow
+ permission_required = "authentik_flows.export_flow"
+
+ # pylint: disable=unused-argument
+ def get(self, request: HttpRequest, pk: str) -> HttpResponse:
+ """Debug exectue flow, setting the current user as pending user"""
+ flow: Flow = self.get_object()
+ exporter = FlowExporter(flow)
+ response = JsonResponse(exporter.export(), encoder=DataclassEncoder, safe=False)
+ response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"'
+ return response
diff --git a/authentik/admin/views/groups.py b/authentik/admin/views/groups.py
new file mode 100644
index 000000000..bebd3bdb1
--- /dev/null
+++ b/authentik/admin/views/groups.py
@@ -0,0 +1,83 @@
+"""authentik Group administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import ListView, UpdateView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.core.forms.groups import GroupForm
+from authentik.core.models import Group
+from authentik.lib.views import CreateAssignPermView
+
+
+class GroupListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ ListView,
+):
+ """Show list of all groups"""
+
+ model = Group
+ permission_required = "authentik_core.view_group"
+ ordering = "name"
+ template_name = "administration/group/list.html"
+ search_fields = ["name", "attributes"]
+
+
+class GroupCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new Group"""
+
+ model = Group
+ form_class = GroupForm
+ permission_required = "authentik_core.add_group"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:groups")
+ success_message = _("Successfully created Group")
+
+
+class GroupUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ UpdateView,
+):
+ """Update group"""
+
+ model = Group
+ form_class = GroupForm
+ permission_required = "authentik_core.change_group"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:groups")
+ success_message = _("Successfully updated Group")
+
+
+class GroupDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
+ """Delete group"""
+
+ model = Group
+ permission_required = "authentik_flows.delete_group"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:groups")
+ success_message = _("Successfully deleted Group")
diff --git a/authentik/admin/views/outposts.py b/authentik/admin/views/outposts.py
new file mode 100644
index 000000000..1e54ca5e8
--- /dev/null
+++ b/authentik/admin/views/outposts.py
@@ -0,0 +1,93 @@
+"""authentik Outpost administration"""
+from dataclasses import asdict
+from typing import Any, Dict
+
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import ListView, UpdateView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.lib.views import CreateAssignPermView
+from authentik.outposts.forms import OutpostForm
+from authentik.outposts.models import Outpost, OutpostConfig
+
+
+class OutpostListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ ListView,
+):
+ """Show list of all outposts"""
+
+ model = Outpost
+ permission_required = "authentik_outposts.view_outpost"
+ ordering = "name"
+ template_name = "administration/outpost/list.html"
+ search_fields = ["name", "_config"]
+
+
+class OutpostCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new Outpost"""
+
+ model = Outpost
+ form_class = OutpostForm
+ permission_required = "authentik_outposts.add_outpost"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:outposts")
+ success_message = _("Successfully created Outpost")
+
+ def get_initial(self) -> Dict[str, Any]:
+ return {
+ "_config": asdict(
+ OutpostConfig(authentik_host=self.request.build_absolute_uri("/"))
+ )
+ }
+
+
+class OutpostUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ UpdateView,
+):
+ """Update outpost"""
+
+ model = Outpost
+ form_class = OutpostForm
+ permission_required = "authentik_outposts.change_outpost"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:outposts")
+ success_message = _("Successfully updated Outpost")
+
+
+class OutpostDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
+ """Delete outpost"""
+
+ model = Outpost
+ permission_required = "authentik_outposts.delete_outpost"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:outposts")
+ success_message = _("Successfully deleted Outpost")
diff --git a/authentik/admin/views/outposts_service_connections.py b/authentik/admin/views/outposts_service_connections.py
new file mode 100644
index 000000000..a1aded022
--- /dev/null
+++ b/authentik/admin/views/outposts_service_connections.py
@@ -0,0 +1,83 @@
+"""authentik OutpostServiceConnection administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ InheritanceCreateView,
+ InheritanceListView,
+ InheritanceUpdateView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.outposts.models import OutpostServiceConnection
+
+
+class OutpostServiceConnectionListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ InheritanceListView,
+):
+ """Show list of all outpost-service-connections"""
+
+ model = OutpostServiceConnection
+ permission_required = "authentik_outposts.add_outpostserviceconnection"
+ template_name = "administration/outpost_service_connection/list.html"
+ ordering = "pk"
+ search_fields = ["pk", "name"]
+
+
+class OutpostServiceConnectionCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ InheritanceCreateView,
+):
+ """Create new OutpostServiceConnection"""
+
+ model = OutpostServiceConnection
+ permission_required = "authentik_outposts.add_outpostserviceconnection"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:outpost-service-connections")
+ success_message = _("Successfully created OutpostServiceConnection")
+
+
+class OutpostServiceConnectionUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ InheritanceUpdateView,
+):
+ """Update outpostserviceconnection"""
+
+ model = OutpostServiceConnection
+ permission_required = "authentik_outposts.change_outpostserviceconnection"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:outpost-service-connections")
+ success_message = _("Successfully updated OutpostServiceConnection")
+
+
+class OutpostServiceConnectionDeleteView(
+ LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView
+):
+ """Delete outpostserviceconnection"""
+
+ model = OutpostServiceConnection
+ permission_required = "authentik_outposts.delete_outpostserviceconnection"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:outpost-service-connections")
+ success_message = _("Successfully deleted OutpostServiceConnection")
diff --git a/authentik/admin/views/overview.py b/authentik/admin/views/overview.py
new file mode 100644
index 000000000..bded396d8
--- /dev/null
+++ b/authentik/admin/views/overview.py
@@ -0,0 +1,85 @@
+"""authentik administration overview"""
+from typing import Union
+
+from django.conf import settings
+from django.contrib.messages.views import SuccessMessageMixin
+from django.core.cache import cache
+from django.http.request import HttpRequest
+from django.http.response import HttpResponse
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import FormView, TemplateView
+from packaging.version import LegacyVersion, Version, parse
+from structlog import get_logger
+
+from authentik import __version__
+from authentik.admin.forms.overview import FlowCacheClearForm, PolicyCacheClearForm
+from authentik.admin.mixins import AdminRequiredMixin
+from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
+from authentik.core.models import Provider, User
+from authentik.policies.models import Policy
+
+LOGGER = get_logger()
+
+
+class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
+ """Overview View"""
+
+ template_name = "administration/overview.html"
+
+ def get_latest_version(self) -> Union[LegacyVersion, Version]:
+ """Get latest version from cache"""
+ version_in_cache = cache.get(VERSION_CACHE_KEY)
+ if not version_in_cache:
+ if not settings.DEBUG:
+ update_latest_version.delay()
+ return parse(__version__)
+ return parse(version_in_cache)
+
+ def get_context_data(self, **kwargs):
+ kwargs["policy_count"] = len(Policy.objects.all())
+ kwargs["user_count"] = len(User.objects.all()) - 1 # Remove anonymous user
+ kwargs["provider_count"] = len(Provider.objects.all())
+ kwargs["version"] = parse(__version__)
+ kwargs["version_latest"] = self.get_latest_version()
+ kwargs["providers_without_application"] = Provider.objects.filter(
+ application=None
+ )
+ kwargs["policies_without_binding"] = len(
+ Policy.objects.filter(bindings__isnull=True, promptstage__isnull=True)
+ )
+ kwargs["cached_policies"] = len(cache.keys("policy_*"))
+ kwargs["cached_flows"] = len(cache.keys("flow_*"))
+ return super().get_context_data(**kwargs)
+
+
+class PolicyCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView):
+ """View to clear Policy cache"""
+
+ form_class = PolicyCacheClearForm
+
+ template_name = "generic/form_non_model.html"
+ success_url = reverse_lazy("authentik_admin:overview")
+ success_message = _("Successfully cleared Policy cache")
+
+ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ keys = cache.keys("policy_*")
+ cache.delete_many(keys)
+ LOGGER.debug("Cleared Policy cache", keys=len(keys))
+ return super().post(request, *args, **kwargs)
+
+
+class FlowCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView):
+ """View to clear Flow cache"""
+
+ form_class = FlowCacheClearForm
+
+ template_name = "generic/form_non_model.html"
+ success_url = reverse_lazy("authentik_admin:overview")
+ success_message = _("Successfully cleared Flow cache")
+
+ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ keys = cache.keys("flow_*")
+ cache.delete_many(keys)
+ LOGGER.debug("Cleared flow cache", keys=len(keys))
+ return super().post(request, *args, **kwargs)
diff --git a/authentik/admin/views/policies.py b/authentik/admin/views/policies.py
new file mode 100644
index 000000000..43499991d
--- /dev/null
+++ b/authentik/admin/views/policies.py
@@ -0,0 +1,129 @@
+"""authentik Policy administration"""
+from typing import Any, Dict
+
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.db.models import QuerySet
+from django.http import HttpResponse
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import FormView
+from django.views.generic.detail import DetailView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.forms.policies import PolicyTestForm
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ InheritanceCreateView,
+ InheritanceListView,
+ InheritanceUpdateView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.policies.models import Policy, PolicyBinding
+from authentik.policies.process import PolicyProcess, PolicyRequest
+
+
+class PolicyListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ InheritanceListView,
+):
+ """Show list of all policies"""
+
+ model = Policy
+ permission_required = "authentik_policies.view_policy"
+ ordering = "name"
+ template_name = "administration/policy/list.html"
+ search_fields = ["name"]
+
+
+class PolicyCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ InheritanceCreateView,
+):
+ """Create new Policy"""
+
+ model = Policy
+ permission_required = "authentik_policies.add_policy"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:policies")
+ success_message = _("Successfully created Policy")
+
+
+class PolicyUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ InheritanceUpdateView,
+):
+ """Update policy"""
+
+ model = Policy
+ permission_required = "authentik_policies.change_policy"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:policies")
+ success_message = _("Successfully updated Policy")
+
+
+class PolicyDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
+ """Delete policy"""
+
+ model = Policy
+ permission_required = "authentik_policies.delete_policy"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:policies")
+ success_message = _("Successfully deleted Policy")
+
+
+class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, FormView):
+ """View to test policy(s)"""
+
+ model = Policy
+ form_class = PolicyTestForm
+ permission_required = "authentik_policies.view_policy"
+ template_name = "administration/policy/test.html"
+ object = None
+
+ def get_object(self, queryset=None) -> QuerySet:
+ return (
+ Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
+ )
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ kwargs["policy"] = self.get_object()
+ return super().get_context_data(**kwargs)
+
+ def post(self, *args, **kwargs) -> HttpResponse:
+ self.object = self.get_object()
+ return super().post(*args, **kwargs)
+
+ def form_valid(self, form: PolicyTestForm) -> HttpResponse:
+ policy = self.get_object()
+ user = form.cleaned_data.get("user")
+
+ p_request = PolicyRequest(user)
+ p_request.http_request = self.request
+ p_request.context = form.cleaned_data
+
+ proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None)
+ result = proc.execute()
+ if result.passing:
+ messages.success(self.request, _("User successfully passed policy."))
+ else:
+ messages.error(self.request, _("User didn't pass policy."))
+ return self.render_to_response(self.get_context_data(form=form, result=result))
diff --git a/authentik/admin/views/policies_bindings.py b/authentik/admin/views/policies_bindings.py
new file mode 100644
index 000000000..9f7a8f97a
--- /dev/null
+++ b/authentik/admin/views/policies_bindings.py
@@ -0,0 +1,99 @@
+"""authentik PolicyBinding administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.db.models import QuerySet
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import ListView, UpdateView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+from guardian.shortcuts import get_objects_for_user
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ UserPaginateListMixin,
+)
+from authentik.lib.views import CreateAssignPermView
+from authentik.policies.forms import PolicyBindingForm
+from authentik.policies.models import PolicyBinding
+
+
+class PolicyBindingListView(
+ LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
+):
+ """Show list of all policies"""
+
+ model = PolicyBinding
+ permission_required = "authentik_policies.view_policybinding"
+ ordering = ["order", "target"]
+ template_name = "administration/policy_binding/list.html"
+
+ def get_queryset(self) -> QuerySet:
+ # Since `select_subclasses` does not work with a foreign key, we have to do two queries here
+ # First, get all pbm objects that have bindings attached
+ objects = (
+ get_objects_for_user(
+ self.request.user, "authentik_policies.view_policybindingmodel"
+ )
+ .filter(policies__isnull=False)
+ .select_subclasses()
+ .select_related()
+ .order_by("pk")
+ )
+ for pbm in objects:
+ pbm.bindings = get_objects_for_user(
+ self.request.user, self.permission_required
+ ).filter(target__pk=pbm.pbm_uuid)
+ return objects
+
+
+class PolicyBindingCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new PolicyBinding"""
+
+ model = PolicyBinding
+ permission_required = "authentik_policies.add_policybinding"
+ form_class = PolicyBindingForm
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:policies-bindings")
+ success_message = _("Successfully created PolicyBinding")
+
+
+class PolicyBindingUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ UpdateView,
+):
+ """Update policybinding"""
+
+ model = PolicyBinding
+ permission_required = "authentik_policies.change_policybinding"
+ form_class = PolicyBindingForm
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:policies-bindings")
+ success_message = _("Successfully updated PolicyBinding")
+
+
+class PolicyBindingDeleteView(
+ LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView
+):
+ """Delete policybinding"""
+
+ model = PolicyBinding
+ permission_required = "authentik_policies.delete_policybinding"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:policies-bindings")
+ success_message = _("Successfully deleted PolicyBinding")
diff --git a/authentik/admin/views/property_mappings.py b/authentik/admin/views/property_mappings.py
new file mode 100644
index 000000000..522b26622
--- /dev/null
+++ b/authentik/admin/views/property_mappings.py
@@ -0,0 +1,83 @@
+"""authentik PropertyMapping administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ InheritanceCreateView,
+ InheritanceListView,
+ InheritanceUpdateView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.core.models import PropertyMapping
+
+
+class PropertyMappingListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ InheritanceListView,
+):
+ """Show list of all property_mappings"""
+
+ model = PropertyMapping
+ permission_required = "authentik_core.view_propertymapping"
+ template_name = "administration/property_mapping/list.html"
+ ordering = "name"
+ search_fields = ["name", "expression"]
+
+
+class PropertyMappingCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ InheritanceCreateView,
+):
+ """Create new PropertyMapping"""
+
+ model = PropertyMapping
+ permission_required = "authentik_core.add_propertymapping"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:property-mappings")
+ success_message = _("Successfully created Property Mapping")
+
+
+class PropertyMappingUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ InheritanceUpdateView,
+):
+ """Update property_mapping"""
+
+ model = PropertyMapping
+ permission_required = "authentik_core.change_propertymapping"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:property-mappings")
+ success_message = _("Successfully updated Property Mapping")
+
+
+class PropertyMappingDeleteView(
+ LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView
+):
+ """Delete property_mapping"""
+
+ model = PropertyMapping
+ permission_required = "authentik_core.delete_propertymapping"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:property-mappings")
+ success_message = _("Successfully deleted Property Mapping")
diff --git a/authentik/admin/views/providers.py b/authentik/admin/views/providers.py
new file mode 100644
index 000000000..ed4accf41
--- /dev/null
+++ b/authentik/admin/views/providers.py
@@ -0,0 +1,83 @@
+"""authentik Provider administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ InheritanceCreateView,
+ InheritanceListView,
+ InheritanceUpdateView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.core.models import Provider
+
+
+class ProviderListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ InheritanceListView,
+):
+ """Show list of all providers"""
+
+ model = Provider
+ permission_required = "authentik_core.add_provider"
+ template_name = "administration/provider/list.html"
+ ordering = "pk"
+ search_fields = ["pk", "name"]
+
+
+class ProviderCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ InheritanceCreateView,
+):
+ """Create new Provider"""
+
+ model = Provider
+ permission_required = "authentik_core.add_provider"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:providers")
+ success_message = _("Successfully created Provider")
+
+
+class ProviderUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ InheritanceUpdateView,
+):
+ """Update provider"""
+
+ model = Provider
+ permission_required = "authentik_core.change_provider"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:providers")
+ success_message = _("Successfully updated Provider")
+
+
+class ProviderDeleteView(
+ LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView
+):
+ """Delete provider"""
+
+ model = Provider
+ permission_required = "authentik_core.delete_provider"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:providers")
+ success_message = _("Successfully deleted Provider")
diff --git a/authentik/admin/views/sources.py b/authentik/admin/views/sources.py
new file mode 100644
index 000000000..23fc5d0b9
--- /dev/null
+++ b/authentik/admin/views/sources.py
@@ -0,0 +1,81 @@
+"""authentik Source administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ InheritanceCreateView,
+ InheritanceListView,
+ InheritanceUpdateView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.core.models import Source
+
+
+class SourceListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ InheritanceListView,
+):
+ """Show list of all sources"""
+
+ model = Source
+ permission_required = "authentik_core.view_source"
+ ordering = "name"
+ template_name = "administration/source/list.html"
+ search_fields = ["name", "slug"]
+
+
+class SourceCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ InheritanceCreateView,
+):
+ """Create new Source"""
+
+ model = Source
+ permission_required = "authentik_core.add_source"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:sources")
+ success_message = _("Successfully created Source")
+
+
+class SourceUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ InheritanceUpdateView,
+):
+ """Update source"""
+
+ model = Source
+ permission_required = "authentik_core.change_source"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:sources")
+ success_message = _("Successfully updated Source")
+
+
+class SourceDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
+ """Delete source"""
+
+ model = Source
+ permission_required = "authentik_core.delete_source"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:sources")
+ success_message = _("Successfully deleted Source")
diff --git a/authentik/admin/views/stages.py b/authentik/admin/views/stages.py
new file mode 100644
index 000000000..55e7623dd
--- /dev/null
+++ b/authentik/admin/views/stages.py
@@ -0,0 +1,79 @@
+"""authentik Stage administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ InheritanceCreateView,
+ InheritanceListView,
+ InheritanceUpdateView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.flows.models import Stage
+
+
+class StageListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ InheritanceListView,
+):
+ """Show list of all stages"""
+
+ model = Stage
+ template_name = "administration/stage/list.html"
+ permission_required = "authentik_flows.view_stage"
+ ordering = "name"
+ search_fields = ["name"]
+
+
+class StageCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ InheritanceCreateView,
+):
+ """Create new Stage"""
+
+ model = Stage
+ template_name = "generic/create.html"
+ permission_required = "authentik_flows.add_stage"
+
+ success_url = reverse_lazy("authentik_admin:stages")
+ success_message = _("Successfully created Stage")
+
+
+class StageUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ InheritanceUpdateView,
+):
+ """Update stage"""
+
+ model = Stage
+ permission_required = "authentik_flows.update_application"
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:stages")
+ success_message = _("Successfully updated Stage")
+
+
+class StageDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
+ """Delete stage"""
+
+ model = Stage
+ template_name = "generic/delete.html"
+ permission_required = "authentik_flows.delete_stage"
+ success_url = reverse_lazy("authentik_admin:stages")
+ success_message = _("Successfully deleted Stage")
diff --git a/authentik/admin/views/stages_bindings.py b/authentik/admin/views/stages_bindings.py
new file mode 100644
index 000000000..d048764e8
--- /dev/null
+++ b/authentik/admin/views/stages_bindings.py
@@ -0,0 +1,79 @@
+"""authentik StageBinding administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import ListView, UpdateView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ UserPaginateListMixin,
+)
+from authentik.flows.forms import FlowStageBindingForm
+from authentik.flows.models import FlowStageBinding
+from authentik.lib.views import CreateAssignPermView
+
+
+class StageBindingListView(
+ LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
+):
+ """Show list of all flows"""
+
+ model = FlowStageBinding
+ permission_required = "authentik_flows.view_flowstagebinding"
+ ordering = ["target", "order"]
+ template_name = "administration/stage_binding/list.html"
+
+
+class StageBindingCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new StageBinding"""
+
+ model = FlowStageBinding
+ permission_required = "authentik_flows.add_flowstagebinding"
+ form_class = FlowStageBindingForm
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:stage-bindings")
+ success_message = _("Successfully created StageBinding")
+
+
+class StageBindingUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ UpdateView,
+):
+ """Update FlowStageBinding"""
+
+ model = FlowStageBinding
+ permission_required = "authentik_flows.change_flowstagebinding"
+ form_class = FlowStageBindingForm
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:stage-bindings")
+ success_message = _("Successfully updated StageBinding")
+
+
+class StageBindingDeleteView(
+ LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView
+):
+ """Delete FlowStageBinding"""
+
+ model = FlowStageBinding
+ permission_required = "authentik_flows.delete_flowstagebinding"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:stage-bindings")
+ success_message = _("Successfully deleted FlowStageBinding")
diff --git a/authentik/admin/views/stages_invitations.py b/authentik/admin/views/stages_invitations.py
new file mode 100644
index 000000000..b914c16d7
--- /dev/null
+++ b/authentik/admin/views/stages_invitations.py
@@ -0,0 +1,76 @@
+"""authentik Invitation administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.http import HttpResponseRedirect
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import ListView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.lib.views import CreateAssignPermView
+from authentik.stages.invitation.forms import InvitationForm
+from authentik.stages.invitation.models import Invitation
+from authentik.stages.invitation.signals import invitation_created
+
+
+class InvitationListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ ListView,
+):
+ """Show list of all invitations"""
+
+ model = Invitation
+ permission_required = "authentik_stages_invitation.view_invitation"
+ template_name = "administration/stage_invitation/list.html"
+ ordering = "-expires"
+ search_fields = ["created_by__username", "expires", "fixed_data"]
+
+
+class InvitationCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new Invitation"""
+
+ model = Invitation
+ form_class = InvitationForm
+ permission_required = "authentik_stages_invitation.add_invitation"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:stage-invitations")
+ success_message = _("Successfully created Invitation")
+
+ def form_valid(self, form):
+ obj = form.save(commit=False)
+ obj.created_by = self.request.user
+ obj.save()
+ invitation_created.send(sender=self, request=self.request, invitation=obj)
+ return HttpResponseRedirect(self.success_url)
+
+
+class InvitationDeleteView(
+ LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView
+):
+ """Delete invitation"""
+
+ model = Invitation
+ permission_required = "authentik_stages_invitation.delete_invitation"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:stage-invitations")
+ success_message = _("Successfully deleted Invitation")
diff --git a/authentik/admin/views/stages_prompts.py b/authentik/admin/views/stages_prompts.py
new file mode 100644
index 000000000..cc59a2ba5
--- /dev/null
+++ b/authentik/admin/views/stages_prompts.py
@@ -0,0 +1,88 @@
+"""authentik Prompt administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import ListView, UpdateView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.lib.views import CreateAssignPermView
+from authentik.stages.prompt.forms import PromptAdminForm
+from authentik.stages.prompt.models import Prompt
+
+
+class PromptListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ ListView,
+):
+ """Show list of all prompts"""
+
+ model = Prompt
+ permission_required = "authentik_stages_prompt.view_prompt"
+ ordering = "order"
+ template_name = "administration/stage_prompt/list.html"
+ search_fields = [
+ "field_key",
+ "label",
+ "type",
+ "placeholder",
+ ]
+
+
+class PromptCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new Prompt"""
+
+ model = Prompt
+ form_class = PromptAdminForm
+ permission_required = "authentik_stages_prompt.add_prompt"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:stage-prompts")
+ success_message = _("Successfully created Prompt")
+
+
+class PromptUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ UpdateView,
+):
+ """Update prompt"""
+
+ model = Prompt
+ form_class = PromptAdminForm
+ permission_required = "authentik_stages_prompt.change_prompt"
+
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:stage-prompts")
+ success_message = _("Successfully updated Prompt")
+
+
+class PromptDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
+ """Delete prompt"""
+
+ model = Prompt
+ permission_required = "authentik_stages_prompt.delete_prompt"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:stage-prompts")
+ success_message = _("Successfully deleted Prompt")
diff --git a/authentik/admin/views/tasks.py b/authentik/admin/views/tasks.py
new file mode 100644
index 000000000..44b96c8e9
--- /dev/null
+++ b/authentik/admin/views/tasks.py
@@ -0,0 +1,23 @@
+"""authentik Tasks List"""
+from typing import Any, Dict
+
+from django.views.generic.base import TemplateView
+
+from authentik.admin.mixins import AdminRequiredMixin
+from authentik.lib.tasks import TaskInfo, TaskResultStatus
+
+
+class TaskListView(AdminRequiredMixin, TemplateView):
+ """Show list of all background tasks"""
+
+ template_name = "administration/task/list.html"
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ kwargs["object_list"] = sorted(
+ TaskInfo.all().values(), key=lambda x: x.task_name
+ )
+ kwargs["task_successful"] = TaskResultStatus.SUCCESSFUL
+ kwargs["task_warning"] = TaskResultStatus.WARNING
+ kwargs["task_error"] = TaskResultStatus.ERROR
+ return kwargs
diff --git a/authentik/admin/views/tokens.py b/authentik/admin/views/tokens.py
new file mode 100644
index 000000000..126dac064
--- /dev/null
+++ b/authentik/admin/views/tokens.py
@@ -0,0 +1,45 @@
+"""authentik Token administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import ListView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+
+from authentik.admin.views.utils import (
+ DeleteMessageView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.core.models import Token
+
+
+class TokenListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ ListView,
+):
+ """Show list of all tokens"""
+
+ model = Token
+ permission_required = "authentik_core.view_token"
+ ordering = "expires"
+ template_name = "administration/token/list.html"
+ search_fields = [
+ "identifier",
+ "intent",
+ "user__username",
+ "description",
+ ]
+
+
+class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
+ """Delete token"""
+
+ model = Token
+ permission_required = "authentik_core.delete_token"
+
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:tokens")
+ success_message = _("Successfully deleted Token")
diff --git a/authentik/admin/views/users.py b/authentik/admin/views/users.py
new file mode 100644
index 000000000..434a8c0c2
--- /dev/null
+++ b/authentik/admin/views/users.py
@@ -0,0 +1,168 @@
+"""authentik User administration"""
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.http import HttpRequest, HttpResponse
+from django.http.response import HttpResponseRedirect
+from django.shortcuts import redirect
+from django.urls import reverse, reverse_lazy
+from django.utils.http import urlencode
+from django.utils.translation import gettext as _
+from django.views.generic import DetailView, ListView, UpdateView
+from guardian.mixins import (
+ PermissionListMixin,
+ PermissionRequiredMixin,
+ get_anonymous_user,
+)
+
+from authentik.admin.forms.users import UserForm
+from authentik.admin.views.utils import (
+ BackSuccessUrlMixin,
+ DeleteMessageView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.core.models import Token, User
+from authentik.lib.views import CreateAssignPermView
+
+
+class UserListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ ListView,
+):
+ """Show list of all users"""
+
+ model = User
+ permission_required = "authentik_core.view_user"
+ ordering = "username"
+ template_name = "administration/user/list.html"
+ search_fields = ["username", "name", "attributes"]
+
+ def get_queryset(self):
+ return super().get_queryset().exclude(pk=get_anonymous_user().pk)
+
+
+class UserCreateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create user"""
+
+ model = User
+ form_class = UserForm
+ permission_required = "authentik_core.add_user"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_admin:users")
+ success_message = _("Successfully created User")
+
+
+class UserUpdateView(
+ SuccessMessageMixin,
+ BackSuccessUrlMixin,
+ LoginRequiredMixin,
+ PermissionRequiredMixin,
+ UpdateView,
+):
+ """Update user"""
+
+ model = User
+ form_class = UserForm
+ permission_required = "authentik_core.change_user"
+
+ # By default the object's name is user which is used by other checks
+ context_object_name = "object"
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_admin:users")
+ success_message = _("Successfully updated User")
+
+
+class UserDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
+ """Delete user"""
+
+ model = User
+ permission_required = "authentik_core.delete_user"
+
+ # By default the object's name is user which is used by other checks
+ context_object_name = "object"
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_admin:users")
+ success_message = _("Successfully deleted User")
+
+
+class UserDisableView(
+ LoginRequiredMixin, PermissionRequiredMixin, BackSuccessUrlMixin, DeleteMessageView
+):
+ """Disable user"""
+
+ object: User
+
+ model = User
+ permission_required = "authentik_core.update_user"
+
+ # By default the object's name is user which is used by other checks
+ context_object_name = "object"
+ template_name = "administration/user/disable.html"
+ success_url = reverse_lazy("authentik_admin:users")
+ success_message = _("Successfully disabled User")
+
+ def delete(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ self.object: User = self.get_object()
+ success_url = self.get_success_url()
+ self.object.is_active = False
+ self.object.save()
+ return HttpResponseRedirect(success_url)
+
+
+class UserEnableView(
+ LoginRequiredMixin, PermissionRequiredMixin, BackSuccessUrlMixin, DetailView
+):
+ """Enable user"""
+
+ object: User
+
+ model = User
+ permission_required = "authentik_core.update_user"
+
+ # By default the object's name is user which is used by other checks
+ context_object_name = "object"
+ success_url = reverse_lazy("authentik_admin:users")
+ success_message = _("Successfully enabled User")
+
+ def get(self, request: HttpRequest, *args, **kwargs):
+ self.object: User = self.get_object()
+ success_url = self.get_success_url()
+ self.object.is_active = True
+ self.object.save()
+ return HttpResponseRedirect(success_url)
+
+
+class UserPasswordResetView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
+ """Get Password reset link for user"""
+
+ model = User
+ permission_required = "authentik_core.reset_user_password"
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Create token for user and return link"""
+ super().get(request, *args, **kwargs)
+ token, __ = Token.objects.get_or_create(
+ identifier="password-reset-temp", user=self.object
+ )
+ querystring = urlencode({"token": token.key})
+ link = request.build_absolute_uri(
+ reverse("authentik_flows:default-recovery") + f"?{querystring}"
+ )
+ messages.success(
+ request, _("Password reset link: %(link)s " % {"link": link})
+ )
+ return redirect("authentik_admin:users")
diff --git a/authentik/admin/views/utils.py b/authentik/admin/views/utils.py
new file mode 100644
index 000000000..bec33e4c3
--- /dev/null
+++ b/authentik/admin/views/utils.py
@@ -0,0 +1,124 @@
+"""authentik admin util views"""
+from typing import Any, Dict, List, Optional
+from urllib.parse import urlparse
+
+from django.contrib import messages
+from django.contrib.messages.views import SuccessMessageMixin
+from django.contrib.postgres.search import SearchQuery, SearchVector
+from django.db.models import QuerySet
+from django.http import Http404
+from django.http.request import HttpRequest
+from django.views.generic import DeleteView, ListView, UpdateView
+from django.views.generic.list import MultipleObjectMixin
+
+from authentik.lib.utils.reflection import all_subclasses
+from authentik.lib.views import CreateAssignPermView
+
+
+class DeleteMessageView(SuccessMessageMixin, DeleteView):
+ """DeleteView which shows `self.success_message` on successful deletion"""
+
+ def delete(self, request, *args, **kwargs):
+ messages.success(self.request, self.success_message)
+ return super().delete(request, *args, **kwargs)
+
+
+class InheritanceListView(ListView):
+ """ListView for objects using InheritanceManager"""
+
+ def get_context_data(self, **kwargs):
+ kwargs["types"] = {x.__name__: x for x in all_subclasses(self.model)}
+ return super().get_context_data(**kwargs)
+
+ def get_queryset(self):
+ return super().get_queryset().select_subclasses()
+
+
+class SearchListMixin(MultipleObjectMixin):
+ """Accept search query using `search` querystring parameter. Requires self.search_fields,
+ a list of all fields to search. Can contain special lookups like __icontains"""
+
+ search_fields: List[str]
+
+ def get_queryset(self) -> QuerySet:
+ queryset = super().get_queryset()
+ if "search" in self.request.GET:
+ raw_query = self.request.GET["search"]
+ if raw_query == "":
+ # Empty query, don't search at all
+ return queryset
+ search = SearchQuery(raw_query, search_type="websearch")
+ return queryset.annotate(search=SearchVector(*self.search_fields)).filter(
+ search=search
+ )
+ return queryset
+
+
+class InheritanceCreateView(CreateAssignPermView):
+ """CreateView for objects using InheritanceManager"""
+
+ def get_form_class(self):
+ provider_type = self.request.GET.get("type")
+ try:
+ model = next(
+ x for x in all_subclasses(self.model) if x.__name__ == provider_type
+ )
+ except StopIteration as exc:
+ raise Http404 from exc
+ return model().form
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ form_cls = self.get_form_class()
+ if hasattr(form_cls, "template_name"):
+ kwargs["base_template"] = form_cls.template_name
+ return kwargs
+
+
+class InheritanceUpdateView(UpdateView):
+ """UpdateView for objects using InheritanceManager"""
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ form_cls = self.get_form_class()
+ if hasattr(form_cls, "template_name"):
+ kwargs["base_template"] = form_cls.template_name
+ return kwargs
+
+ def get_form_class(self):
+ return self.get_object().form
+
+ def get_object(self, queryset=None):
+ return (
+ self.model.objects.filter(pk=self.kwargs.get("pk"))
+ .select_subclasses()
+ .first()
+ )
+
+
+class BackSuccessUrlMixin:
+ """Checks if a relative URL has been given as ?back param, and redirect to it. Otherwise
+ default to self.success_url."""
+
+ request: HttpRequest
+
+ success_url: Optional[str]
+
+ def get_success_url(self) -> str:
+ """get_success_url from FormMixin"""
+ back_param = self.request.GET.get("back")
+ if back_param:
+ if not bool(urlparse(back_param).netloc):
+ return back_param
+ return str(self.success_url)
+
+
+class UserPaginateListMixin:
+ """Get paginate_by value from user's attributes, defaulting to 15"""
+
+ request: HttpRequest
+
+ # pylint: disable=unused-argument
+ def get_paginate_by(self, queryset: QuerySet) -> int:
+ """get_paginate_by Function of ListView"""
+ return self.request.user.attributes.get("paginate_by", 15)
diff --git a/passbook/api/__init__.py b/authentik/api/__init__.py
similarity index 100%
rename from passbook/api/__init__.py
rename to authentik/api/__init__.py
diff --git a/authentik/api/apps.py b/authentik/api/apps.py
new file mode 100644
index 000000000..8ae859a54
--- /dev/null
+++ b/authentik/api/apps.py
@@ -0,0 +1,12 @@
+"""authentik API AppConfig"""
+
+from django.apps import AppConfig
+
+
+class AuthentikAPIConfig(AppConfig):
+ """authentik API Config"""
+
+ name = "authentik.api"
+ label = "authentik_api"
+ mountpoint = "api/"
+ verbose_name = "authentik API"
diff --git a/authentik/api/auth.py b/authentik/api/auth.py
new file mode 100644
index 000000000..c3a6bb3ae
--- /dev/null
+++ b/authentik/api/auth.py
@@ -0,0 +1,57 @@
+"""API Authentication"""
+from base64 import b64decode
+from typing import Any, Optional, Tuple, Union
+
+from rest_framework.authentication import BaseAuthentication, get_authorization_header
+from rest_framework.request import Request
+from structlog import get_logger
+
+from authentik.core.models import Token, TokenIntents, User
+
+LOGGER = get_logger()
+
+
+def token_from_header(raw_header: bytes) -> Optional[Token]:
+ """raw_header in the Format of `Basic dGVzdDp0ZXN0`"""
+ auth_credentials = raw_header.decode()
+ # Accept headers with Type format and without
+ if " " in auth_credentials:
+ auth_type, auth_credentials = auth_credentials.split()
+ if auth_type.lower() != "basic":
+ LOGGER.debug(
+ "Unsupported authentication type, denying", type=auth_type.lower()
+ )
+ return None
+ try:
+ auth_credentials = b64decode(auth_credentials.encode()).decode()
+ except UnicodeDecodeError:
+ return None
+ # Accept credentials with username and without
+ if ":" in auth_credentials:
+ _, password = auth_credentials.split(":")
+ else:
+ password = auth_credentials
+ if password == "":
+ return None
+ tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API)
+ if not tokens.exists():
+ LOGGER.debug("Token not found")
+ return None
+ return tokens.first()
+
+
+class AuthentikTokenAuthentication(BaseAuthentication):
+ """Token-based authentication using HTTP Basic authentication"""
+
+ def authenticate(self, request: Request) -> Union[Tuple[User, Any], None]:
+ """Token-based authentication using HTTP Basic authentication"""
+ auth = get_authorization_header(request)
+
+ token = token_from_header(auth)
+ if not token:
+ return None
+
+ return (token.user, None)
+
+ def authenticate_header(self, request: Request) -> str:
+ return 'Basic realm="authentik"'
diff --git a/passbook/api/pagination.py b/authentik/api/pagination.py
similarity index 100%
rename from passbook/api/pagination.py
rename to authentik/api/pagination.py
diff --git a/authentik/api/templates/rest_framework/api.html b/authentik/api/templates/rest_framework/api.html
new file mode 100644
index 000000000..aa3e2c319
--- /dev/null
+++ b/authentik/api/templates/rest_framework/api.html
@@ -0,0 +1,7 @@
+{% extends "rest_framework/base.html" %}
+
+{% block branding %}
+
+ authentik
+
+{% endblock %}
diff --git a/authentik/api/urls.py b/authentik/api/urls.py
new file mode 100644
index 000000000..b4c7791b9
--- /dev/null
+++ b/authentik/api/urls.py
@@ -0,0 +1,8 @@
+"""authentik api urls"""
+from django.urls import include, path
+
+from authentik.api.v2.urls import urlpatterns as v2_urls
+
+urlpatterns = [
+ path("v2beta/", include(v2_urls)),
+]
diff --git a/passbook/api/v2/__init__.py b/authentik/api/v2/__init__.py
similarity index 100%
rename from passbook/api/v2/__init__.py
rename to authentik/api/v2/__init__.py
diff --git a/authentik/api/v2/config.py b/authentik/api/v2/config.py
new file mode 100644
index 000000000..89ec46b1f
--- /dev/null
+++ b/authentik/api/v2/config.py
@@ -0,0 +1,46 @@
+"""core Configs API"""
+from drf_yasg2.utils import swagger_auto_schema
+from rest_framework.permissions import AllowAny
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import ReadOnlyField, Serializer
+from rest_framework.viewsets import ViewSet
+
+from authentik.lib.config import CONFIG
+
+
+class ConfigSerializer(Serializer):
+ """Serialize authentik Config into DRF Object"""
+
+ branding_logo = ReadOnlyField()
+ branding_title = ReadOnlyField()
+
+ error_reporting_enabled = ReadOnlyField()
+ error_reporting_environment = ReadOnlyField()
+ error_reporting_send_pii = ReadOnlyField()
+
+ def create(self, request: Request) -> Response:
+ raise NotImplementedError
+
+ def update(self, request: Request) -> Response:
+ raise NotImplementedError
+
+
+class ConfigsViewSet(ViewSet):
+ """Read-only view set that returns the current session's Configs"""
+
+ permission_classes = [AllowAny]
+
+ @swagger_auto_schema(responses={200: ConfigSerializer(many=True)})
+ 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"),
+ }
+ )
+ return Response(config.data)
diff --git a/passbook/api/v2/messages.py b/authentik/api/v2/messages.py
similarity index 100%
rename from passbook/api/v2/messages.py
rename to authentik/api/v2/messages.py
diff --git a/authentik/api/v2/urls.py b/authentik/api/v2/urls.py
new file mode 100644
index 000000000..bfbbc92ac
--- /dev/null
+++ b/authentik/api/v2/urls.py
@@ -0,0 +1,160 @@
+"""api v2 urls"""
+from django.urls import path, re_path
+from drf_yasg2 import openapi
+from drf_yasg2.views import get_schema_view
+from rest_framework import routers
+from rest_framework.permissions import AllowAny
+
+from authentik.admin.api.overview import AdministrationOverviewViewSet
+from authentik.admin.api.overview_metrics import AdministrationMetricsViewSet
+from authentik.admin.api.tasks import TaskViewSet
+from authentik.api.v2.config import ConfigsViewSet
+from authentik.api.v2.messages import MessagesViewSet
+from authentik.audit.api import EventViewSet
+from authentik.core.api.applications import ApplicationViewSet
+from authentik.core.api.groups import GroupViewSet
+from authentik.core.api.propertymappings import PropertyMappingViewSet
+from authentik.core.api.providers import ProviderViewSet
+from authentik.core.api.sources import SourceViewSet
+from authentik.core.api.tokens import TokenViewSet
+from authentik.core.api.users import UserViewSet
+from authentik.crypto.api import CertificateKeyPairViewSet
+from authentik.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet
+from authentik.outposts.api import (
+ DockerServiceConnectionViewSet,
+ KubernetesServiceConnectionViewSet,
+ OutpostViewSet,
+)
+from authentik.policies.api import PolicyBindingViewSet, PolicyViewSet
+from authentik.policies.dummy.api import DummyPolicyViewSet
+from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
+from authentik.policies.expression.api import ExpressionPolicyViewSet
+from authentik.policies.group_membership.api import GroupMembershipPolicyViewSet
+from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
+from authentik.policies.password.api import PasswordPolicyViewSet
+from authentik.policies.reputation.api import ReputationPolicyViewSet
+from authentik.providers.oauth2.api import OAuth2ProviderViewSet, ScopeMappingViewSet
+from authentik.providers.proxy.api import (
+ ProxyOutpostConfigViewSet,
+ ProxyProviderViewSet,
+)
+from authentik.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet
+from authentik.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
+from authentik.sources.oauth.api import OAuthSourceViewSet
+from authentik.sources.saml.api import SAMLSourceViewSet
+from authentik.stages.captcha.api import CaptchaStageViewSet
+from authentik.stages.consent.api import ConsentStageViewSet
+from authentik.stages.dummy.api import DummyStageViewSet
+from authentik.stages.email.api import EmailStageViewSet
+from authentik.stages.identification.api import IdentificationStageViewSet
+from authentik.stages.invitation.api import InvitationStageViewSet, InvitationViewSet
+from authentik.stages.otp_static.api import OTPStaticStageViewSet
+from authentik.stages.otp_time.api import OTPTimeStageViewSet
+from authentik.stages.otp_validate.api import OTPValidateStageViewSet
+from authentik.stages.password.api import PasswordStageViewSet
+from authentik.stages.prompt.api import PromptStageViewSet, PromptViewSet
+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
+
+router = routers.DefaultRouter()
+
+router.register("root/messages", MessagesViewSet, basename="messages")
+router.register("root/config", ConfigsViewSet, basename="configs")
+
+router.register(
+ "admin/overview", AdministrationOverviewViewSet, basename="admin_overview"
+)
+router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics")
+router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks")
+
+router.register("core/applications", ApplicationViewSet)
+router.register("core/groups", GroupViewSet)
+router.register("core/users", UserViewSet)
+router.register("core/tokens", TokenViewSet)
+
+router.register("outposts/outposts", OutpostViewSet)
+router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet)
+router.register(
+ "outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet
+)
+router.register("outposts/proxy", ProxyOutpostConfigViewSet)
+
+router.register("flows/instances", FlowViewSet)
+router.register("flows/bindings", FlowStageBindingViewSet)
+
+router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet)
+
+router.register("audit/events", EventViewSet)
+
+router.register("sources/all", SourceViewSet)
+router.register("sources/ldap", LDAPSourceViewSet)
+router.register("sources/saml", SAMLSourceViewSet)
+router.register("sources/oauth", OAuthSourceViewSet)
+
+router.register("policies/all", PolicyViewSet)
+router.register("policies/bindings", PolicyBindingViewSet)
+router.register("policies/expression", ExpressionPolicyViewSet)
+router.register("policies/group_membership", GroupMembershipPolicyViewSet)
+router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
+router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
+router.register("policies/password", PasswordPolicyViewSet)
+router.register("policies/reputation", ReputationPolicyViewSet)
+
+router.register("providers/all", ProviderViewSet)
+router.register("providers/proxy", ProxyProviderViewSet)
+router.register("providers/oauth2", OAuth2ProviderViewSet)
+router.register("providers/saml", SAMLProviderViewSet)
+
+router.register("propertymappings/all", PropertyMappingViewSet)
+router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
+router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
+router.register("propertymappings/scope", ScopeMappingViewSet)
+
+router.register("stages/all", StageViewSet)
+router.register("stages/captcha", CaptchaStageViewSet)
+router.register("stages/consent", ConsentStageViewSet)
+router.register("stages/email", EmailStageViewSet)
+router.register("stages/identification", IdentificationStageViewSet)
+router.register("stages/invitation", InvitationStageViewSet)
+router.register("stages/invitation/invitations", InvitationViewSet)
+router.register("stages/otp_static", OTPStaticStageViewSet)
+router.register("stages/otp_time", OTPTimeStageViewSet)
+router.register("stages/otp_validate", OTPValidateStageViewSet)
+router.register("stages/password", PasswordStageViewSet)
+router.register("stages/prompt/prompts", PromptViewSet)
+router.register("stages/prompt/stages", PromptStageViewSet)
+router.register("stages/user_delete", UserDeleteStageViewSet)
+router.register("stages/user_login", UserLoginStageViewSet)
+router.register("stages/user_logout", UserLogoutStageViewSet)
+router.register("stages/user_write", UserWriteStageViewSet)
+
+router.register("stages/dummy", DummyStageViewSet)
+router.register("policies/dummy", DummyPolicyViewSet)
+
+info = openapi.Info(
+ title="authentik API",
+ default_version="v2",
+ contact=openapi.Contact(email="hello@beryju.org"),
+ license=openapi.License(name="MIT License"),
+)
+SchemaView = get_schema_view(
+ info,
+ public=True,
+ permission_classes=(AllowAny,),
+)
+
+urlpatterns = [
+ re_path(
+ r"^swagger(?P\.json|\.yaml)$",
+ SchemaView.without_ui(cache_timeout=0),
+ name="schema-json",
+ ),
+ path(
+ "swagger/",
+ SchemaView.with_ui("swagger", cache_timeout=0),
+ name="schema-swagger-ui",
+ ),
+ path("redoc/", SchemaView.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
+] + router.urls
diff --git a/passbook/audit/__init__.py b/authentik/audit/__init__.py
similarity index 100%
rename from passbook/audit/__init__.py
rename to authentik/audit/__init__.py
diff --git a/authentik/audit/api.py b/authentik/audit/api.py
new file mode 100644
index 000000000..c2c165773
--- /dev/null
+++ b/authentik/audit/api.py
@@ -0,0 +1,70 @@
+"""Audit API Views"""
+from django.db.models.aggregates import Count
+from django.db.models.fields.json import KeyTextTransform
+from drf_yasg2.utils import swagger_auto_schema
+from rest_framework.decorators import action
+from rest_framework.fields import DictField, IntegerField
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import ModelSerializer, Serializer
+from rest_framework.viewsets import ReadOnlyModelViewSet
+
+from authentik.audit.models import Event, EventAction
+
+
+class EventSerializer(ModelSerializer):
+ """Event Serializer"""
+
+ class Meta:
+
+ model = Event
+ fields = [
+ "pk",
+ "user",
+ "action",
+ "app",
+ "context",
+ "client_ip",
+ "created",
+ ]
+
+
+class EventTopPerUserSerialier(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 EventViewSet(ReadOnlyModelViewSet):
+ """Event Read-Only Viewset"""
+
+ queryset = Event.objects.all()
+ serializer_class = EventSerializer
+
+ @swagger_auto_schema(
+ method="GET", responses={200: EventTopPerUserSerialier(many=True)}
+ )
+ @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("filter_action", EventAction.LOGIN)
+ top_n = request.query_params.get("top_n", 15)
+ return Response(
+ Event.objects.filter(action=filtered_action)
+ .exclude(context__authorized_application=None)
+ .annotate(application=KeyTextTransform("authorized_application", "context"))
+ .annotate(user_pk=KeyTextTransform("pk", "user"))
+ .values("application")
+ .annotate(counted_events=Count("application"))
+ .annotate(unique_users=Count("user_pk", distinct=True))
+ .values("unique_users", "application", "counted_events")
+ .order_by("-counted_events")[:top_n]
+ )
diff --git a/authentik/audit/apps.py b/authentik/audit/apps.py
new file mode 100644
index 000000000..a88e89640
--- /dev/null
+++ b/authentik/audit/apps.py
@@ -0,0 +1,16 @@
+"""authentik audit app"""
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class AuthentikAuditConfig(AppConfig):
+ """authentik audit app"""
+
+ name = "authentik.audit"
+ label = "authentik_audit"
+ verbose_name = "authentik Audit"
+ mountpoint = "audit/"
+
+ def ready(self):
+ import_module("authentik.audit.signals")
diff --git a/authentik/audit/middleware.py b/authentik/audit/middleware.py
new file mode 100644
index 000000000..7c192a568
--- /dev/null
+++ b/authentik/audit/middleware.py
@@ -0,0 +1,85 @@
+"""Audit middleware"""
+from functools import partial
+from typing import Callable
+
+from django.contrib.auth.models import User
+from django.db.models import Model
+from django.db.models.signals import post_save, pre_delete
+from django.http import HttpRequest, HttpResponse
+
+from authentik.audit.models import Event, EventAction, model_to_dict
+from authentik.audit.signals import EventNewThread
+from authentik.core.middleware import LOCAL
+
+
+class AuditMiddleware:
+ """Register handlers for duration of request-response that log creation/update/deletion
+ of models"""
+
+ get_response: Callable[[HttpRequest], HttpResponse]
+
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
+ self.get_response = get_response
+
+ def __call__(self, request: HttpRequest) -> HttpResponse:
+ # Connect signal for automatic logging
+ if hasattr(request, "user") and getattr(
+ request.user, "is_authenticated", False
+ ):
+ post_save_handler = partial(
+ self.post_save_handler, user=request.user, request=request
+ )
+ pre_delete_handler = partial(
+ self.pre_delete_handler, user=request.user, request=request
+ )
+ post_save.connect(
+ post_save_handler,
+ dispatch_uid=LOCAL.authentik["request_id"],
+ weak=False,
+ )
+ pre_delete.connect(
+ pre_delete_handler,
+ dispatch_uid=LOCAL.authentik["request_id"],
+ weak=False,
+ )
+
+ response = self.get_response(request)
+
+ post_save.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
+ pre_delete.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
+
+ return response
+
+ # pylint: disable=unused-argument
+ def process_exception(self, request: HttpRequest, exception: Exception):
+ """Unregister handlers in case of exception"""
+ post_save.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
+ pre_delete.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
+
+ @staticmethod
+ # pylint: disable=unused-argument
+ def post_save_handler(
+ user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
+ ):
+ """Signal handler for all object's post_save"""
+ if isinstance(instance, Event):
+ return
+
+ action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
+ EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
+
+ @staticmethod
+ # pylint: disable=unused-argument
+ def pre_delete_handler(
+ user: User, request: HttpRequest, sender, instance: Model, **_
+ ):
+ """Signal handler for all object's pre_delete"""
+ if isinstance(instance, Event):
+ return
+
+ EventNewThread(
+ EventAction.MODEL_DELETED,
+ request,
+ user=user,
+ model=model_to_dict(instance),
+ ).run()
diff --git a/passbook/audit/migrations/0001_initial.py b/authentik/audit/migrations/0001_initial.py
similarity index 100%
rename from passbook/audit/migrations/0001_initial.py
rename to authentik/audit/migrations/0001_initial.py
diff --git a/authentik/audit/migrations/0002_auto_20200918_2116.py b/authentik/audit/migrations/0002_auto_20200918_2116.py
new file mode 100644
index 000000000..a6fcabf06
--- /dev/null
+++ b/authentik/audit/migrations/0002_auto_20200918_2116.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1.1 on 2020-09-18 21:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_audit", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="event",
+ name="action",
+ field=models.TextField(
+ choices=[
+ ("LOGIN", "login"),
+ ("LOGIN_FAILED", "login_failed"),
+ ("LOGOUT", "logout"),
+ ("AUTHORIZE_APPLICATION", "authorize_application"),
+ ("SUSPICIOUS_REQUEST", "suspicious_request"),
+ ("SIGN_UP", "sign_up"),
+ ("PASSWORD_RESET", "password_reset"),
+ ("INVITE_CREATED", "invitation_created"),
+ ("INVITE_USED", "invitation_used"),
+ ("IMPERSONATION_STARTED", "impersonation_started"),
+ ("IMPERSONATION_ENDED", "impersonation_ended"),
+ ("CUSTOM", "custom"),
+ ]
+ ),
+ ),
+ ]
diff --git a/authentik/audit/migrations/0003_auto_20200917_1155.py b/authentik/audit/migrations/0003_auto_20200917_1155.py
new file mode 100644
index 000000000..6163fe305
--- /dev/null
+++ b/authentik/audit/migrations/0003_auto_20200917_1155.py
@@ -0,0 +1,64 @@
+# Generated by Django 3.1.1 on 2020-09-17 11:55
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+import authentik.audit.models
+
+
+def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ Event = apps.get_model("authentik_audit", "Event")
+
+ db_alias = schema_editor.connection.alias
+ for event in Event.objects.all():
+ event.delete()
+ # Because event objects cannot be updated, we have to re-create them
+ event.pk = None
+ event.user_json = (
+ authentik.audit.models.get_user(event.user) if event.user else {}
+ )
+ event._state.adding = True
+ event.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_audit", "0002_auto_20200918_2116"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="event",
+ name="action",
+ field=models.TextField(
+ choices=[
+ ("LOGIN", "login"),
+ ("LOGIN_FAILED", "login_failed"),
+ ("LOGOUT", "logout"),
+ ("AUTHORIZE_APPLICATION", "authorize_application"),
+ ("SUSPICIOUS_REQUEST", "suspicious_request"),
+ ("SIGN_UP", "sign_up"),
+ ("PASSWORD_RESET", "password_reset"),
+ ("INVITE_CREATED", "invitation_created"),
+ ("INVITE_USED", "invitation_used"),
+ ("IMPERSONATION_STARTED", "impersonation_started"),
+ ("IMPERSONATION_ENDED", "impersonation_ended"),
+ ("CUSTOM", "custom"),
+ ]
+ ),
+ ),
+ migrations.AddField(
+ model_name="event",
+ name="user_json",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.RunPython(convert_user_to_json),
+ migrations.RemoveField(
+ model_name="event",
+ name="user",
+ ),
+ migrations.RenameField(
+ model_name="event", old_name="user_json", new_name="user"
+ ),
+ ]
diff --git a/authentik/audit/migrations/0004_auto_20200921_1829.py b/authentik/audit/migrations/0004_auto_20200921_1829.py
new file mode 100644
index 000000000..df4f64ab2
--- /dev/null
+++ b/authentik/audit/migrations/0004_auto_20200921_1829.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.1.1 on 2020-09-21 18:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_audit", "0003_auto_20200917_1155"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="event",
+ name="action",
+ field=models.TextField(
+ choices=[
+ ("login", "Login"),
+ ("login_failed", "Login Failed"),
+ ("logout", "Logout"),
+ ("sign_up", "Sign Up"),
+ ("authorize_application", "Authorize Application"),
+ ("suspicious_request", "Suspicious Request"),
+ ("password_set", "Password Set"),
+ ("invitation_created", "Invite Created"),
+ ("invitation_used", "Invite Used"),
+ ("source_linked", "Source Linked"),
+ ("impersonation_started", "Impersonation Started"),
+ ("impersonation_ended", "Impersonation Ended"),
+ ("model_created", "Model Created"),
+ ("model_updated", "Model Updated"),
+ ("model_deleted", "Model Deleted"),
+ ("custom_", "Custom Prefix"),
+ ]
+ ),
+ ),
+ ]
diff --git a/authentik/audit/migrations/0005_auto_20201005_2139.py b/authentik/audit/migrations/0005_auto_20201005_2139.py
new file mode 100644
index 000000000..3a2881172
--- /dev/null
+++ b/authentik/audit/migrations/0005_auto_20201005_2139.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.1.2 on 2020-10-05 21:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_audit", "0004_auto_20200921_1829"),
+ ]
+
+ 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"),
+ ("invitation_created", "Invite Created"),
+ ("invitation_used", "Invite Used"),
+ ("authorize_application", "Authorize Application"),
+ ("source_linked", "Source Linked"),
+ ("impersonation_started", "Impersonation Started"),
+ ("impersonation_ended", "Impersonation Ended"),
+ ("model_created", "Model Created"),
+ ("model_updated", "Model Updated"),
+ ("model_deleted", "Model Deleted"),
+ ("custom_", "Custom Prefix"),
+ ]
+ ),
+ ),
+ ]
diff --git a/authentik/audit/migrations/0006_auto_20201017_2024.py b/authentik/audit/migrations/0006_auto_20201017_2024.py
new file mode 100644
index 000000000..ec242f6bd
--- /dev/null
+++ b/authentik/audit/migrations/0006_auto_20201017_2024.py
@@ -0,0 +1,42 @@
+# Generated by Django 3.1.2 on 2020-10-17 20:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_audit", "0005_auto_20201005_2139"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="event",
+ name="date",
+ ),
+ 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"),
+ ("token_view", "Token View"),
+ ("invitation_created", "Invite Created"),
+ ("invitation_used", "Invite Used"),
+ ("authorize_application", "Authorize Application"),
+ ("source_linked", "Source Linked"),
+ ("impersonation_started", "Impersonation Started"),
+ ("impersonation_ended", "Impersonation Ended"),
+ ("model_created", "Model Created"),
+ ("model_updated", "Model Updated"),
+ ("model_deleted", "Model Deleted"),
+ ("custom_", "Custom Prefix"),
+ ]
+ ),
+ ),
+ ]
diff --git a/passbook/audit/migrations/__init__.py b/authentik/audit/migrations/__init__.py
similarity index 100%
rename from passbook/audit/migrations/__init__.py
rename to authentik/audit/migrations/__init__.py
diff --git a/authentik/audit/models.py b/authentik/audit/models.py
new file mode 100644
index 000000000..e07a6b766
--- /dev/null
+++ b/authentik/audit/models.py
@@ -0,0 +1,199 @@
+"""authentik audit models"""
+from inspect import getmodule, stack
+from typing import Any, Dict, Optional, Union
+from uuid import UUID, uuid4
+
+from django.conf import settings
+from django.contrib.auth.models import AnonymousUser
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.db.models.base import Model
+from django.http import HttpRequest
+from django.utils.translation import gettext as _
+from django.views.debug import SafeExceptionReporterFilter
+from guardian.utils import get_anonymous_user
+from structlog import get_logger
+
+from authentik.core.middleware import (
+ SESSION_IMPERSONATE_ORIGINAL_USER,
+ SESSION_IMPERSONATE_USER,
+)
+from authentik.core.models import User
+from authentik.lib.utils.http import get_client_ip
+
+LOGGER = get_logger("authentik.audit")
+
+
+def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
+ """Cleanse a dictionary, recursively"""
+ final_dict = {}
+ for key, value in source.items():
+ try:
+ if SafeExceptionReporterFilter.hidden_settings.search(key):
+ final_dict[key] = SafeExceptionReporterFilter.cleansed_substitute
+ else:
+ final_dict[key] = value
+ except TypeError:
+ final_dict[key] = value
+ if isinstance(value, dict):
+ final_dict[key] = cleanse_dict(value)
+ return final_dict
+
+
+def model_to_dict(model: Model) -> Dict[str, Any]:
+ """Convert model to dict"""
+ name = str(model)
+ if hasattr(model, "name"):
+ name = model.name
+ return {
+ "app": model._meta.app_label,
+ "model_name": model._meta.model_name,
+ "pk": model.pk,
+ "name": name,
+ }
+
+
+def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]:
+ """Convert user object to dictionary, optionally including the original user"""
+ if isinstance(user, AnonymousUser):
+ user = get_anonymous_user()
+ user_data = {
+ "username": user.username,
+ "pk": user.pk,
+ "email": user.email,
+ }
+ if original_user:
+ original_data = get_user(original_user)
+ original_data["on_behalf_of"] = user_data
+ return original_data
+ return user_data
+
+
+def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
+ """clean source of all Models that would interfere with the JSONField.
+ Models are replaced with a dictionary of {
+ app: str,
+ name: str,
+ pk: Any
+ }"""
+ final_dict = {}
+ for key, value in source.items():
+ if isinstance(value, dict):
+ final_dict[key] = sanitize_dict(value)
+ elif isinstance(value, models.Model):
+ final_dict[key] = sanitize_dict(model_to_dict(value))
+ elif isinstance(value, UUID):
+ final_dict[key] = value.hex
+ else:
+ final_dict[key] = value
+ return final_dict
+
+
+class EventAction(models.TextChoices):
+ """All possible actions to save into the audit log"""
+
+ LOGIN = "login"
+ LOGIN_FAILED = "login_failed"
+ LOGOUT = "logout"
+
+ USER_WRITE = "user_write"
+ SUSPICIOUS_REQUEST = "suspicious_request"
+ PASSWORD_SET = "password_set" # noqa # nosec
+
+ TOKEN_VIEW = "token_view"
+
+ INVITE_CREATED = "invitation_created"
+ INVITE_USED = "invitation_used"
+
+ AUTHORIZE_APPLICATION = "authorize_application"
+ SOURCE_LINKED = "source_linked"
+
+ IMPERSONATION_STARTED = "impersonation_started"
+ IMPERSONATION_ENDED = "impersonation_ended"
+
+ MODEL_CREATED = "model_created"
+ MODEL_UPDATED = "model_updated"
+ MODEL_DELETED = "model_deleted"
+
+ CUSTOM_PREFIX = "custom_"
+
+
+class Event(models.Model):
+ """An individual audit log event"""
+
+ event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+ user = models.JSONField(default=dict)
+ action = models.TextField(choices=EventAction.choices)
+ app = models.TextField()
+ context = models.JSONField(default=dict, blank=True)
+ client_ip = models.GenericIPAddressField(null=True)
+ created = models.DateTimeField(auto_now_add=True)
+
+ @staticmethod
+ def _get_app_from_request(request: HttpRequest) -> str:
+ if not isinstance(request, HttpRequest):
+ return ""
+ return request.resolver_match.app_name
+
+ @staticmethod
+ def new(
+ action: Union[str, EventAction],
+ app: Optional[str] = None,
+ _inspect_offset: int = 1,
+ **kwargs,
+ ) -> "Event":
+ """Create new Event instance from arguments. Instance is NOT saved."""
+ if not isinstance(action, EventAction):
+ action = EventAction.CUSTOM_PREFIX + action
+ if not app:
+ app = getmodule(stack()[_inspect_offset][0]).__name__
+ cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
+ event = Event(action=action, app=app, context=cleaned_kwargs)
+ return event
+
+ def from_http(
+ self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None
+ ) -> "Event":
+ """Add data from a Django-HttpRequest, allowing the creation of
+ Events independently from requests.
+ `user` arguments optionally overrides user from requests."""
+ if hasattr(request, "user"):
+ self.user = get_user(
+ request.user,
+ request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None),
+ )
+ if user:
+ self.user = get_user(user)
+ # Check if we're currently impersonating, and add that user
+ if hasattr(request, "session"):
+ if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
+ self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER])
+ self.user["on_behalf_of"] = get_user(
+ request.session[SESSION_IMPERSONATE_USER]
+ )
+ # User 255.255.255.255 as fallback if IP cannot be determined
+ self.client_ip = get_client_ip(request) or "255.255.255.255"
+ # If there's no app set, we get it from the requests too
+ if not self.app:
+ self.app = Event._get_app_from_request(request)
+ self.save()
+ return self
+
+ def save(self, *args, **kwargs):
+ if not self._state.adding:
+ raise ValidationError(
+ "you may not edit an existing %s" % self._meta.model_name
+ )
+ LOGGER.debug(
+ "Created Audit event",
+ action=self.action,
+ context=self.context,
+ client_ip=self.client_ip,
+ user=self.user,
+ )
+ return super().save(*args, **kwargs)
+
+ class Meta:
+
+ verbose_name = _("Audit Event")
+ verbose_name_plural = _("Audit Events")
diff --git a/authentik/audit/signals.py b/authentik/audit/signals.py
new file mode 100644
index 000000000..88d769a8b
--- /dev/null
+++ b/authentik/audit/signals.py
@@ -0,0 +1,107 @@
+"""authentik audit signal listener"""
+from threading import Thread
+from typing import Any, Dict, Optional
+
+from django.contrib.auth.signals import (
+ user_logged_in,
+ user_logged_out,
+ user_login_failed,
+)
+from django.dispatch import receiver
+from django.http import HttpRequest
+
+from authentik.audit.models import Event, EventAction
+from authentik.core.models import User
+from authentik.core.signals import password_changed
+from authentik.stages.invitation.models import Invitation
+from authentik.stages.invitation.signals import invitation_created, invitation_used
+from authentik.stages.user_write.signals import user_write
+
+
+class EventNewThread(Thread):
+ """Create Event in background thread"""
+
+ action: str
+ request: HttpRequest
+ kwargs: Dict[str, Any]
+ user: Optional[User] = None
+
+ def __init__(
+ self, action: str, request: HttpRequest, user: Optional[User] = None, **kwargs
+ ):
+ super().__init__()
+ self.action = action
+ self.request = request
+ self.user = user
+ self.kwargs = kwargs
+
+ def run(self):
+ Event.new(self.action, **self.kwargs).from_http(self.request, user=self.user)
+
+
+@receiver(user_logged_in)
+# pylint: disable=unused-argument
+def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
+ """Log successful login"""
+ thread = EventNewThread(EventAction.LOGIN, request)
+ thread.user = user
+ thread.run()
+
+
+@receiver(user_logged_out)
+# pylint: disable=unused-argument
+def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
+ """Log successfully logout"""
+ thread = EventNewThread(EventAction.LOGOUT, request)
+ thread.user = user
+ thread.run()
+
+
+@receiver(user_write)
+# pylint: disable=unused-argument
+def on_user_write(
+ sender, request: HttpRequest, user: User, data: Dict[str, Any], **kwargs
+):
+ """Log User write"""
+ thread = EventNewThread(EventAction.USER_WRITE, request, **data)
+ thread.kwargs["created"] = kwargs.get("created", False)
+ thread.user = user
+ thread.run()
+
+
+@receiver(user_login_failed)
+# pylint: disable=unused-argument
+def on_user_login_failed(
+ sender, credentials: Dict[str, str], request: HttpRequest, **_
+):
+ """Failed Login"""
+ thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials)
+ thread.run()
+
+
+@receiver(invitation_created)
+# pylint: disable=unused-argument
+def on_invitation_created(sender, request: HttpRequest, invitation: Invitation, **_):
+ """Log Invitation creation"""
+ thread = EventNewThread(
+ EventAction.INVITE_CREATED, request, invitation_uuid=invitation.invite_uuid.hex
+ )
+ thread.run()
+
+
+@receiver(invitation_used)
+# pylint: disable=unused-argument
+def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_):
+ """Log Invitation usage"""
+ thread = EventNewThread(
+ EventAction.INVITE_USED, request, invitation_uuid=invitation.invite_uuid.hex
+ )
+ thread.run()
+
+
+@receiver(password_changed)
+# pylint: disable=unused-argument
+def on_password_changed(sender, user: User, password: str, **_):
+ """Log password change"""
+ thread = EventNewThread(EventAction.PASSWORD_SET, None, user=user)
+ thread.run()
diff --git a/authentik/audit/templates/audit/list.html b/authentik/audit/templates/audit/list.html
new file mode 100644
index 000000000..470f9f0f3
--- /dev/null
+++ b/authentik/audit/templates/audit/list.html
@@ -0,0 +1,90 @@
+{% extends "base/page.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block page_content %}
+
+
+
+
+
+ {% trans 'Audit Log' %}
+
+
+
+
+
+
+
+
+
+ {% trans 'Action' %}
+ {% trans 'Context' %}
+ {% trans 'User' %}
+ {% trans 'Creation Date' %}
+ {% trans 'Client IP' %}
+
+
+
+ {% for entry in object_list %}
+
+
+
+
{{ entry.action }}
+
{{ entry.app|default:'-' }}
+
+
+
+
+
+ {{ entry.context }}
+
+ {% if entry.user.on_behalf_of %}
+
+ {% blocktrans with username=entry.user.on_behalf_of.username %}
+ On behalf of {{ username }}
+ {% endblocktrans %}
+
+ {% endif %}
+
+
+
+
+
{{ entry.user.username }}
+
+ {% blocktrans with pk=entry.user.pk %}
+ ID: {{ pk }}
+ {% endblocktrans %}
+
+
+
+
+
+ {{ entry.created }}
+
+
+
+
+ {{ entry.client_ip }}
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/passbook/audit/tests/__init__.py b/authentik/audit/tests/__init__.py
similarity index 100%
rename from passbook/audit/tests/__init__.py
rename to authentik/audit/tests/__init__.py
diff --git a/authentik/audit/tests/test_event.py b/authentik/audit/tests/test_event.py
new file mode 100644
index 000000000..bf7a6b594
--- /dev/null
+++ b/authentik/audit/tests/test_event.py
@@ -0,0 +1,33 @@
+"""audit event tests"""
+
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.audit.models import Event
+from authentik.policies.dummy.models import DummyPolicy
+
+
+class TestAuditEvent(TestCase):
+ """Test Audit Event"""
+
+ def test_new_with_model(self):
+ """Create a new Event passing a model as kwarg"""
+ event = Event.new("unittest", test={"model": get_anonymous_user()})
+ event.save() # We save to ensure nothing is un-saveable
+ model_content_type = ContentType.objects.get_for_model(get_anonymous_user())
+ self.assertEqual(
+ event.context.get("test").get("model").get("app"),
+ model_content_type.app_label,
+ )
+
+ def test_new_with_uuid_model(self):
+ """Create a new Event passing a model (with UUID PK) as kwarg"""
+ temp_model = DummyPolicy.objects.create(name="test", result=True)
+ event = Event.new("unittest", model=temp_model)
+ event.save() # We save to ensure nothing is un-saveable
+ model_content_type = ContentType.objects.get_for_model(temp_model)
+ self.assertEqual(
+ event.context.get("model").get("app"), model_content_type.app_label
+ )
+ self.assertEqual(event.context.get("model").get("pk"), temp_model.pk.hex)
diff --git a/authentik/audit/urls.py b/authentik/audit/urls.py
new file mode 100644
index 000000000..13fd64dfe
--- /dev/null
+++ b/authentik/audit/urls.py
@@ -0,0 +1,9 @@
+"""authentik audit urls"""
+from django.urls import path
+
+from authentik.audit.views import EventListView
+
+urlpatterns = [
+ # Audit Log
+ path("audit/", EventListView.as_view(), name="log"),
+]
diff --git a/authentik/audit/views.py b/authentik/audit/views.py
new file mode 100644
index 000000000..c87d2fd67
--- /dev/null
+++ b/authentik/audit/views.py
@@ -0,0 +1,30 @@
+"""authentik Event administration"""
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.views.generic import ListView
+from guardian.mixins import PermissionListMixin
+
+from authentik.admin.views.utils import SearchListMixin, UserPaginateListMixin
+from authentik.audit.models import Event
+
+
+class EventListView(
+ PermissionListMixin,
+ LoginRequiredMixin,
+ SearchListMixin,
+ UserPaginateListMixin,
+ ListView,
+):
+ """Show list of all invitations"""
+
+ model = Event
+ template_name = "audit/list.html"
+ permission_required = "authentik_audit.view_event"
+ ordering = "-created"
+
+ search_fields = [
+ "user",
+ "action",
+ "app",
+ "context",
+ "client_ip",
+ ]
diff --git a/passbook/core/__init__.py b/authentik/core/__init__.py
similarity index 100%
rename from passbook/core/__init__.py
rename to authentik/core/__init__.py
diff --git a/authentik/core/admin.py b/authentik/core/admin.py
new file mode 100644
index 000000000..d30ece7e8
--- /dev/null
+++ b/authentik/core/admin.py
@@ -0,0 +1,24 @@
+"""authentik core admin"""
+
+from django.apps import AppConfig, apps
+from django.contrib import admin
+from django.contrib.admin.sites import AlreadyRegistered
+from guardian.admin import GuardedModelAdmin
+from structlog import get_logger
+
+LOGGER = get_logger()
+
+
+def admin_autoregister(app: AppConfig):
+ """Automatically register all models from app"""
+ for model in app.get_models():
+ try:
+ admin.site.register(model, GuardedModelAdmin)
+ except AlreadyRegistered:
+ pass
+
+
+for _app in apps.get_app_configs():
+ if _app.label.startswith("authentik_"):
+ LOGGER.debug("Registering application for dj-admin", application=_app.label)
+ admin_autoregister(_app)
diff --git a/passbook/core/api/__init__.py b/authentik/core/api/__init__.py
similarity index 100%
rename from passbook/core/api/__init__.py
rename to authentik/core/api/__init__.py
diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py
new file mode 100644
index 000000000..8dab69299
--- /dev/null
+++ b/authentik/core/api/applications.py
@@ -0,0 +1,81 @@
+"""Application API Views"""
+from django.db.models import QuerySet
+from rest_framework.decorators import action
+from rest_framework.fields import SerializerMethodField
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+from rest_framework_guardian.filters import ObjectPermissionsFilter
+
+from authentik.admin.api.overview_metrics import get_events_per_1h
+from authentik.audit.models import EventAction
+from authentik.core.models import Application
+from authentik.policies.engine import PolicyEngine
+
+
+class ApplicationSerializer(ModelSerializer):
+ """Application Serializer"""
+
+ launch_url = SerializerMethodField()
+
+ def get_launch_url(self, instance: Application) -> str:
+ """Get generated launch URL"""
+ return instance.get_launch_url() or ""
+
+ class Meta:
+
+ model = Application
+ fields = [
+ "pk",
+ "name",
+ "slug",
+ "provider",
+ "launch_url",
+ "meta_launch_url",
+ "meta_icon",
+ "meta_description",
+ "meta_publisher",
+ "policies",
+ ]
+
+
+class ApplicationViewSet(ModelViewSet):
+ """Application Viewset"""
+
+ queryset = Application.objects.all()
+ serializer_class = ApplicationSerializer
+ lookup_field = "slug"
+
+ 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 list(self, request: Request) -> Response:
+ """Custom list method that checks Policy based access instead of guardian"""
+ queryset = self._filter_queryset_for_list(self.get_queryset())
+ self.paginate_queryset(queryset)
+ allowed_applications = []
+ for application in queryset.order_by("name"):
+ engine = PolicyEngine(application, self.request.user, self.request)
+ engine.build()
+ if engine.passing:
+ allowed_applications.append(application)
+ serializer = self.get_serializer(allowed_applications, many=True)
+ return self.get_paginated_response(serializer.data)
+
+ @action(detail=True)
+ def metrics(self, request: Request, slug: str):
+ """Metrics for application logins"""
+ # TODO: Check app read and audit read perms
+ app = Application.objects.get(slug=slug)
+ return Response(
+ get_events_per_1h(
+ action=EventAction.AUTHORIZE_APPLICATION,
+ context__authorized_application__pk=app.pk.hex,
+ )
+ )
diff --git a/authentik/core/api/groups.py b/authentik/core/api/groups.py
new file mode 100644
index 000000000..fa1b8953d
--- /dev/null
+++ b/authentik/core/api/groups.py
@@ -0,0 +1,21 @@
+"""Groups API Viewset"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.core.models import Group
+
+
+class GroupSerializer(ModelSerializer):
+ """Group Serializer"""
+
+ class Meta:
+
+ model = Group
+ fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"]
+
+
+class GroupViewSet(ModelViewSet):
+ """Group Viewset"""
+
+ queryset = Group.objects.all()
+ serializer_class = GroupSerializer
diff --git a/authentik/core/api/propertymappings.py b/authentik/core/api/propertymappings.py
new file mode 100644
index 000000000..3394f5753
--- /dev/null
+++ b/authentik/core/api/propertymappings.py
@@ -0,0 +1,30 @@
+"""PropertyMapping API Views"""
+from rest_framework.serializers import ModelSerializer, SerializerMethodField
+from rest_framework.viewsets import ReadOnlyModelViewSet
+
+from authentik.core.models import PropertyMapping
+
+
+class PropertyMappingSerializer(ModelSerializer):
+ """PropertyMapping Serializer"""
+
+ __type__ = SerializerMethodField(method_name="get_type")
+
+ def get_type(self, obj):
+ """Get object type so that we know which API Endpoint to use to get the full object"""
+ return obj._meta.object_name.lower().replace("propertymapping", "")
+
+ class Meta:
+
+ model = PropertyMapping
+ fields = ["pk", "name", "expression", "__type__"]
+
+
+class PropertyMappingViewSet(ReadOnlyModelViewSet):
+ """PropertyMapping Viewset"""
+
+ queryset = PropertyMapping.objects.all()
+ serializer_class = PropertyMappingSerializer
+
+ def get_queryset(self):
+ return PropertyMapping.objects.select_subclasses()
diff --git a/authentik/core/api/providers.py b/authentik/core/api/providers.py
new file mode 100644
index 000000000..e0e32b074
--- /dev/null
+++ b/authentik/core/api/providers.py
@@ -0,0 +1,30 @@
+"""Provider API Views"""
+from rest_framework.serializers import ModelSerializer, SerializerMethodField
+from rest_framework.viewsets import ReadOnlyModelViewSet
+
+from authentik.core.models import Provider
+
+
+class ProviderSerializer(ModelSerializer):
+ """Provider Serializer"""
+
+ __type__ = SerializerMethodField(method_name="get_type")
+
+ def get_type(self, obj):
+ """Get object type so that we know which API Endpoint to use to get the full object"""
+ return obj._meta.object_name.lower().replace("provider", "")
+
+ class Meta:
+
+ model = Provider
+ fields = ["pk", "name", "authorization_flow", "property_mappings", "__type__"]
+
+
+class ProviderViewSet(ReadOnlyModelViewSet):
+ """Provider Viewset"""
+
+ queryset = Provider.objects.all()
+ serializer_class = ProviderSerializer
+
+ def get_queryset(self):
+ return Provider.objects.select_subclasses()
diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py
new file mode 100644
index 000000000..e19acf27e
--- /dev/null
+++ b/authentik/core/api/sources.py
@@ -0,0 +1,31 @@
+"""Source API Views"""
+from rest_framework.serializers import ModelSerializer, SerializerMethodField
+from rest_framework.viewsets import ReadOnlyModelViewSet
+
+from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
+from authentik.core.models import Source
+
+
+class SourceSerializer(ModelSerializer):
+ """Source Serializer"""
+
+ __type__ = SerializerMethodField(method_name="get_type")
+
+ def get_type(self, obj):
+ """Get object type so that we know which API Endpoint to use to get the full object"""
+ return obj._meta.object_name.lower().replace("source", "")
+
+ class Meta:
+
+ model = Source
+ fields = SOURCE_SERIALIZER_FIELDS + ["__type__"]
+
+
+class SourceViewSet(ReadOnlyModelViewSet):
+ """Source Viewset"""
+
+ queryset = Source.objects.all()
+ serializer_class = SourceSerializer
+
+ def get_queryset(self):
+ return Source.objects.select_subclasses()
diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py
new file mode 100644
index 000000000..bdaedf915
--- /dev/null
+++ b/authentik/core/api/tokens.py
@@ -0,0 +1,37 @@
+"""Tokens API Viewset"""
+from django.http.response import Http404
+from rest_framework.decorators import action
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.audit.models import Event, EventAction
+from authentik.core.models import Token
+
+
+class TokenSerializer(ModelSerializer):
+ """Token Serializer"""
+
+ class Meta:
+
+ model = Token
+ fields = ["pk", "identifier", "intent", "user", "description"]
+
+
+class TokenViewSet(ModelViewSet):
+ """Token Viewset"""
+
+ lookup_field = "identifier"
+ queryset = Token.filter_not_expired()
+ serializer_class = TokenSerializer
+
+ @action(detail=True)
+ def view_key(self, request: Request, identifier: str) -> Response:
+ """Return token key and log access"""
+ tokens = Token.filter_not_expired(identifier=identifier)
+ if not tokens.exists():
+ raise Http404
+ token = tokens.first()
+ Event.new(EventAction.TOKEN_VIEW, token=token).from_http(request)
+ return Response({"key": token.key})
diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py
new file mode 100644
index 000000000..b4fb8554b
--- /dev/null
+++ b/authentik/core/api/users.py
@@ -0,0 +1,44 @@
+"""User API Views"""
+from drf_yasg2.utils import swagger_auto_schema
+from rest_framework.decorators import action
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import (
+ BooleanField,
+ ModelSerializer,
+ SerializerMethodField,
+)
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.core.models import User
+from authentik.lib.templatetags.authentik_utils import avatar
+
+
+class UserSerializer(ModelSerializer):
+ """User Serializer"""
+
+ is_superuser = BooleanField(read_only=True)
+ avatar = SerializerMethodField()
+
+ def get_avatar(self, user: User) -> str:
+ """Add user's avatar as URL"""
+ return avatar(user)
+
+ class Meta:
+
+ model = User
+ fields = ["pk", "username", "name", "is_superuser", "email", "avatar"]
+
+
+class UserViewSet(ModelViewSet):
+ """User Viewset"""
+
+ queryset = User.objects.all()
+ serializer_class = UserSerializer
+
+ @swagger_auto_schema(responses={200: UserSerializer(many=False)})
+ @action(detail=False)
+ # pylint: disable=invalid-name
+ def me(self, request: Request) -> Response:
+ """Get information about current user"""
+ return Response(UserSerializer(request.user).data)
diff --git a/authentik/core/apps.py b/authentik/core/apps.py
new file mode 100644
index 000000000..395737f39
--- /dev/null
+++ b/authentik/core/apps.py
@@ -0,0 +1,11 @@
+"""authentik core app config"""
+from django.apps import AppConfig
+
+
+class AuthentikCoreConfig(AppConfig):
+ """authentik core app config"""
+
+ name = "authentik.core"
+ label = "authentik_core"
+ verbose_name = "authentik Core"
+ mountpoint = ""
diff --git a/authentik/core/channels.py b/authentik/core/channels.py
new file mode 100644
index 000000000..31be6ffd0
--- /dev/null
+++ b/authentik/core/channels.py
@@ -0,0 +1,32 @@
+"""Channels base classes"""
+from channels.generic.websocket import JsonWebsocketConsumer
+from structlog import get_logger
+
+from authentik.api.auth import token_from_header
+from authentik.core.models import User
+
+LOGGER = get_logger()
+
+
+class AuthJsonConsumer(JsonWebsocketConsumer):
+ """Authorize a client with a token"""
+
+ user: User
+
+ def connect(self):
+ headers = dict(self.scope["headers"])
+ if b"authorization" not in headers:
+ LOGGER.warning("WS Request without authorization header")
+ self.close()
+ return False
+
+ raw_header = headers[b"authorization"]
+
+ token = token_from_header(raw_header)
+ if not token:
+ LOGGER.warning("Failed to authenticate")
+ self.close()
+ return False
+
+ self.user = token.user
+ return True
diff --git a/authentik/core/exceptions.py b/authentik/core/exceptions.py
new file mode 100644
index 000000000..7b157fc26
--- /dev/null
+++ b/authentik/core/exceptions.py
@@ -0,0 +1,6 @@
+"""authentik core exceptions"""
+from authentik.lib.sentry import SentryIgnoredException
+
+
+class PropertyMappingExpressionException(SentryIgnoredException):
+ """Error when a PropertyMapping Exception expression could not be parsed or evaluated."""
diff --git a/authentik/core/expression.py b/authentik/core/expression.py
new file mode 100644
index 000000000..534ba4775
--- /dev/null
+++ b/authentik/core/expression.py
@@ -0,0 +1,21 @@
+"""Property Mapping Evaluator"""
+from typing import Optional
+
+from django.http import HttpRequest
+
+from authentik.core.models import User
+from authentik.lib.expression.evaluator import BaseEvaluator
+
+
+class PropertyMappingEvaluator(BaseEvaluator):
+ """Custom Evalautor that adds some different context variables."""
+
+ def set_context(
+ self, user: Optional[User], request: Optional[HttpRequest], **kwargs
+ ):
+ """Update context with context from PropertyMapping's evaluate"""
+ if user:
+ self._context["user"] = user
+ if request:
+ self._context["request"] = request
+ self._context.update(**kwargs)
diff --git a/passbook/core/forms/__init__.py b/authentik/core/forms/__init__.py
similarity index 100%
rename from passbook/core/forms/__init__.py
rename to authentik/core/forms/__init__.py
diff --git a/authentik/core/forms/applications.py b/authentik/core/forms/applications.py
new file mode 100644
index 000000000..db31e20a6
--- /dev/null
+++ b/authentik/core/forms/applications.py
@@ -0,0 +1,50 @@
+"""authentik Core Application forms"""
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from authentik.core.models import Application, Provider
+from authentik.lib.widgets import GroupedModelChoiceField
+
+
+class ApplicationForm(forms.ModelForm):
+ """Application Form"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["provider"].queryset = (
+ Provider.objects.all().order_by("pk").select_subclasses()
+ )
+
+ class Meta:
+
+ model = Application
+ fields = [
+ "name",
+ "slug",
+ "provider",
+ "meta_launch_url",
+ "meta_icon",
+ "meta_description",
+ "meta_publisher",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "meta_launch_url": forms.TextInput(),
+ "meta_publisher": forms.TextInput(),
+ "meta_icon": forms.FileInput(),
+ }
+ help_texts = {
+ "meta_launch_url": _(
+ (
+ "If left empty, authentik will try to extract the launch URL "
+ "based on the selected provider."
+ )
+ ),
+ }
+ field_classes = {"provider": GroupedModelChoiceField}
+ labels = {
+ "meta_launch_url": _("Launch URL"),
+ "meta_icon": _("Icon"),
+ "meta_description": _("Description"),
+ "meta_publisher": _("Publisher"),
+ }
diff --git a/authentik/core/forms/groups.py b/authentik/core/forms/groups.py
new file mode 100644
index 000000000..8d10f1eae
--- /dev/null
+++ b/authentik/core/forms/groups.py
@@ -0,0 +1,38 @@
+"""authentik Core Group forms"""
+from django import forms
+
+from authentik.admin.fields import CodeMirrorWidget, YAMLField
+from authentik.core.models import Group, User
+
+
+class GroupForm(forms.ModelForm):
+ """Group Form"""
+
+ members = forms.ModelMultipleChoiceField(
+ User.objects.all(),
+ required=False,
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if self.instance.pk:
+ self.initial["members"] = self.instance.users.values_list("pk", flat=True)
+
+ def save(self, *args, **kwargs):
+ instance = super().save(*args, **kwargs)
+ if instance.pk:
+ instance.users.clear()
+ instance.users.add(*self.cleaned_data["members"])
+ return instance
+
+ class Meta:
+
+ model = Group
+ fields = ["name", "is_superuser", "parent", "members", "attributes"]
+ widgets = {
+ "name": forms.TextInput(),
+ "attributes": CodeMirrorWidget,
+ }
+ field_classes = {
+ "attributes": YAMLField,
+ }
diff --git a/authentik/core/forms/token.py b/authentik/core/forms/token.py
new file mode 100644
index 000000000..9bc43aa8f
--- /dev/null
+++ b/authentik/core/forms/token.py
@@ -0,0 +1,22 @@
+"""Core user token form"""
+from django import forms
+
+from authentik.core.models import Token
+
+
+class UserTokenForm(forms.ModelForm):
+ """Token form, for tokens created by endusers"""
+
+ class Meta:
+
+ model = Token
+ fields = [
+ "identifier",
+ "expires",
+ "expiring",
+ "description",
+ ]
+ widgets = {
+ "identifier": forms.TextInput(),
+ "description": forms.TextInput(),
+ }
diff --git a/authentik/core/forms/users.py b/authentik/core/forms/users.py
new file mode 100644
index 000000000..36b5e33c5
--- /dev/null
+++ b/authentik/core/forms/users.py
@@ -0,0 +1,15 @@
+"""authentik core user forms"""
+
+from django import forms
+
+from authentik.core.models import User
+
+
+class UserDetailForm(forms.ModelForm):
+ """Update User Details"""
+
+ class Meta:
+
+ model = User
+ fields = ["username", "name", "email"]
+ widgets = {"name": forms.TextInput}
diff --git a/authentik/core/middleware.py b/authentik/core/middleware.py
new file mode 100644
index 000000000..9b43485e9
--- /dev/null
+++ b/authentik/core/middleware.py
@@ -0,0 +1,56 @@
+"""authentik admin Middleware to impersonate users"""
+from logging import Logger
+from threading import local
+from typing import Callable
+from uuid import uuid4
+
+from django.http import HttpRequest, HttpResponse
+
+SESSION_IMPERSONATE_USER = "authentik_impersonate_user"
+SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user"
+LOCAL = local()
+
+
+class ImpersonateMiddleware:
+ """Middleware to impersonate users"""
+
+ get_response: Callable[[HttpRequest], HttpResponse]
+
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
+ self.get_response = get_response
+
+ def __call__(self, request: HttpRequest) -> HttpResponse:
+ # No permission checks are done here, they need to be checked before
+ # SESSION_IMPERSONATE_USER is set.
+
+ if SESSION_IMPERSONATE_USER in request.session:
+ request.user = request.session[SESSION_IMPERSONATE_USER]
+
+ return self.get_response(request)
+
+
+class RequestIDMiddleware:
+ """Add a unique ID to every request"""
+
+ get_response: Callable[[HttpRequest], HttpResponse]
+
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
+ self.get_response = get_response
+
+ def __call__(self, request: HttpRequest) -> HttpResponse:
+ if not hasattr(request, "request_id"):
+ request_id = uuid4().hex
+ setattr(request, "request_id", request_id)
+ LOCAL.authentik = {"request_id": request_id}
+ response = self.get_response(request)
+ response["X-authentik-id"] = request.request_id
+ del LOCAL.authentik["request_id"]
+ return response
+
+
+# pylint: disable=unused-argument
+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", "")
+ return event_dict
diff --git a/authentik/core/migrations/0001_initial.py b/authentik/core/migrations/0001_initial.py
new file mode 100644
index 000000000..e79bbbfd1
--- /dev/null
+++ b/authentik/core/migrations/0001_initial.py
@@ -0,0 +1,356 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:07
+
+import uuid
+
+import django.contrib.auth.models
+import django.contrib.auth.validators
+import django.db.models.deletion
+import django.utils.timezone
+import guardian.mixins
+from django.conf import settings
+from django.db import migrations, models
+
+import authentik.core.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_policies", "0001_initial"),
+ ("auth", "0011_update_proxy_permissions"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="User",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("password", models.CharField(max_length=128, verbose_name="password")),
+ (
+ "last_login",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="last login"
+ ),
+ ),
+ (
+ "is_superuser",
+ models.BooleanField(
+ default=False,
+ help_text="Designates that this user has all permissions without explicitly assigning them.",
+ verbose_name="superuser status",
+ ),
+ ),
+ (
+ "username",
+ models.CharField(
+ error_messages={
+ "unique": "A user with that username already exists."
+ },
+ help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
+ max_length=150,
+ unique=True,
+ validators=[
+ django.contrib.auth.validators.UnicodeUsernameValidator()
+ ],
+ verbose_name="username",
+ ),
+ ),
+ (
+ "first_name",
+ models.CharField(
+ blank=True, max_length=30, verbose_name="first name"
+ ),
+ ),
+ (
+ "last_name",
+ models.CharField(
+ blank=True, max_length=150, verbose_name="last name"
+ ),
+ ),
+ (
+ "email",
+ models.EmailField(
+ blank=True, max_length=254, verbose_name="email address"
+ ),
+ ),
+ (
+ "is_staff",
+ models.BooleanField(
+ default=False,
+ help_text="Designates whether the user can log into this admin site.",
+ verbose_name="staff status",
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(
+ default=True,
+ help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
+ verbose_name="active",
+ ),
+ ),
+ (
+ "date_joined",
+ models.DateTimeField(
+ default=django.utils.timezone.now, verbose_name="date joined"
+ ),
+ ),
+ ("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
+ ("name", models.TextField(help_text="User's display name.")),
+ ("password_change_date", models.DateTimeField(auto_now_add=True)),
+ (
+ "attributes",
+ models.JSONField(blank=True, default=dict),
+ ),
+ ],
+ options={
+ "permissions": (("reset_user_password", "Reset Password"),),
+ },
+ bases=(guardian.mixins.GuardianUserMixin, models.Model),
+ managers=[
+ ("objects", django.contrib.auth.models.UserManager()),
+ ],
+ ),
+ migrations.CreateModel(
+ name="PropertyMapping",
+ fields=[
+ (
+ "pm_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("name", models.TextField()),
+ ("expression", models.TextField()),
+ ],
+ options={
+ "verbose_name": "Property Mapping",
+ "verbose_name_plural": "Property Mappings",
+ },
+ ),
+ migrations.CreateModel(
+ name="Source",
+ fields=[
+ (
+ "policybindingmodel_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_policies.PolicyBindingModel",
+ ),
+ ),
+ ("name", models.TextField(help_text="Source's display Name.")),
+ (
+ "slug",
+ models.SlugField(help_text="Internal source name, used in URLs."),
+ ),
+ ("enabled", models.BooleanField(default=True)),
+ (
+ "property_mappings",
+ models.ManyToManyField(
+ blank=True, default=None, to="authentik_core.PropertyMapping"
+ ),
+ ),
+ ],
+ bases=("authentik_policies.policybindingmodel",),
+ ),
+ migrations.CreateModel(
+ name="UserSourceConnection",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("last_updated", models.DateTimeField(auto_now=True)),
+ (
+ "source",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="authentik_core.Source",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "unique_together": {("user", "source")},
+ },
+ ),
+ migrations.CreateModel(
+ name="Token",
+ fields=[
+ (
+ "token_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ (
+ "expires",
+ models.DateTimeField(
+ default=authentik.core.models.default_token_duration
+ ),
+ ),
+ ("expiring", models.BooleanField(default=True)),
+ ("description", models.TextField(blank=True, default="")),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Token",
+ "verbose_name_plural": "Tokens",
+ },
+ ),
+ migrations.CreateModel(
+ name="Provider",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "property_mappings",
+ models.ManyToManyField(
+ blank=True, default=None, to="authentik_core.PropertyMapping"
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="Group",
+ fields=[
+ (
+ "group_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("name", models.CharField(max_length=80, verbose_name="name")),
+ (
+ "attributes",
+ models.JSONField(blank=True, default=dict),
+ ),
+ (
+ "parent",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="children",
+ to="authentik_core.Group",
+ ),
+ ),
+ ],
+ options={
+ "unique_together": {("name", "parent")},
+ },
+ ),
+ migrations.CreateModel(
+ name="Application",
+ fields=[
+ (
+ "policybindingmodel_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_policies.PolicyBindingModel",
+ ),
+ ),
+ ("name", models.TextField(help_text="Application's display Name.")),
+ (
+ "slug",
+ models.SlugField(
+ help_text="Internal application name, used in URLs."
+ ),
+ ),
+ ("skip_authorization", models.BooleanField(default=False)),
+ ("meta_launch_url", models.URLField(blank=True, default="")),
+ ("meta_icon_url", models.TextField(blank=True, default="")),
+ ("meta_description", models.TextField(blank=True, default="")),
+ ("meta_publisher", models.TextField(blank=True, default="")),
+ (
+ "provider",
+ models.OneToOneField(
+ blank=True,
+ default=None,
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ to="authentik_core.Provider",
+ ),
+ ),
+ ],
+ bases=("authentik_policies.policybindingmodel",),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="groups",
+ field=models.ManyToManyField(to="authentik_core.Group"),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="sources",
+ field=models.ManyToManyField(
+ through="authentik_core.UserSourceConnection",
+ to="authentik_core.Source",
+ ),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="user_permissions",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="Specific permissions for this user.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.Permission",
+ verbose_name="user permissions",
+ ),
+ ),
+ ]
diff --git a/authentik/core/migrations/0002_auto_20200523_1133.py b/authentik/core/migrations/0002_auto_20200523_1133.py
new file mode 100644
index 000000000..ecc0717fc
--- /dev/null
+++ b/authentik/core/migrations/0002_auto_20200523_1133.py
@@ -0,0 +1,55 @@
+# Generated by Django 3.0.6 on 2020-05-23 11:33
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0003_auto_20200523_1133"),
+ ("authentik_core", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="application",
+ name="skip_authorization",
+ ),
+ migrations.AddField(
+ model_name="source",
+ name="authentication_flow",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="Flow to use when authenticating existing users.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="source_authentication",
+ to="authentik_flows.Flow",
+ ),
+ ),
+ migrations.AddField(
+ model_name="source",
+ name="enrollment_flow",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="Flow to use when enrolling new users.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="source_enrollment",
+ to="authentik_flows.Flow",
+ ),
+ ),
+ migrations.AddField(
+ model_name="provider",
+ name="authorization_flow",
+ field=models.ForeignKey(
+ help_text="Flow used when authorizing this provider.",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="provider_authorization",
+ to="authentik_flows.Flow",
+ ),
+ ),
+ ]
diff --git a/authentik/core/migrations/0003_default_user.py b/authentik/core/migrations/0003_default_user.py
new file mode 100644
index 000000000..ffa3eee82
--- /dev/null
+++ b/authentik/core/migrations/0003_default_user.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.0.6 on 2020-05-23 16:40
+
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ # We have to use a direct import here, otherwise we get an object manager error
+ from authentik.core.models import User
+
+ db_alias = schema_editor.connection.alias
+
+ akadmin, _ = User.objects.using(db_alias).get_or_create(
+ username="akadmin", email="root@localhost", name="authentik Default Admin"
+ )
+ akadmin.set_password("akadmin", signal=False) # noqa # nosec
+ akadmin.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0002_auto_20200523_1133"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="user",
+ name="is_superuser",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="is_staff",
+ ),
+ migrations.RunPython(create_default_user),
+ migrations.AddField(
+ model_name="user",
+ name="is_superuser",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name="user", name="is_staff", field=models.BooleanField(default=False)
+ ),
+ ]
diff --git a/authentik/core/migrations/0004_auto_20200703_2213.py b/authentik/core/migrations/0004_auto_20200703_2213.py
new file mode 100644
index 000000000..e3e98bea6
--- /dev/null
+++ b/authentik/core/migrations/0004_auto_20200703_2213.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.0.7 on 2020-07-03 22:13
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0003_default_user"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="application",
+ options={
+ "verbose_name": "Application",
+ "verbose_name_plural": "Applications",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="user",
+ options={
+ "permissions": (("reset_user_password", "Reset Password"),),
+ "verbose_name": "User",
+ "verbose_name_plural": "Users",
+ },
+ ),
+ ]
diff --git a/authentik/core/migrations/0005_token_intent.py b/authentik/core/migrations/0005_token_intent.py
new file mode 100644
index 000000000..b7790106d
--- /dev/null
+++ b/authentik/core/migrations/0005_token_intent.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.0.7 on 2020-07-05 21:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0004_auto_20200703_2213"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="token",
+ name="intent",
+ field=models.TextField(
+ choices=[
+ ("verification", "Intent Verification"),
+ ("api", "Intent Api"),
+ ],
+ default="verification",
+ ),
+ ),
+ ]
diff --git a/authentik/core/migrations/0006_auto_20200709_1608.py b/authentik/core/migrations/0006_auto_20200709_1608.py
new file mode 100644
index 000000000..2dec93721
--- /dev/null
+++ b/authentik/core/migrations/0006_auto_20200709_1608.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.0.8 on 2020-07-09 16:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0005_token_intent"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="source",
+ name="slug",
+ field=models.SlugField(
+ help_text="Internal source name, used in URLs.", unique=True
+ ),
+ ),
+ ]
diff --git a/authentik/core/migrations/0007_auto_20200815_1841.py b/authentik/core/migrations/0007_auto_20200815_1841.py
new file mode 100644
index 000000000..51fe03d1e
--- /dev/null
+++ b/authentik/core/migrations/0007_auto_20200815_1841.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.1 on 2020-08-15 18:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0006_auto_20200709_1608"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="first_name",
+ field=models.CharField(
+ blank=True, max_length=150, verbose_name="first name"
+ ),
+ ),
+ ]
diff --git a/authentik/core/migrations/0008_auto_20200824_1532.py b/authentik/core/migrations/0008_auto_20200824_1532.py
new file mode 100644
index 000000000..13ba0d3d1
--- /dev/null
+++ b/authentik/core/migrations/0008_auto_20200824_1532.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.1 on 2020-08-24 15:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ("authentik_core", "0007_auto_20200815_1841"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="user",
+ name="groups",
+ field=models.ManyToManyField(to="authentik_core.Group"),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="groups",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.Group",
+ verbose_name="groups",
+ ),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="pb_groups",
+ field=models.ManyToManyField(to="authentik_core.Group"),
+ ),
+ ]
diff --git a/authentik/core/migrations/0009_group_is_superuser.py b/authentik/core/migrations/0009_group_is_superuser.py
new file mode 100644
index 000000000..37133587e
--- /dev/null
+++ b/authentik/core/migrations/0009_group_is_superuser.py
@@ -0,0 +1,61 @@
+# Generated by Django 3.1.1 on 2020-09-15 19:53
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+import authentik.core.models
+
+
+def create_default_admin_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ db_alias = schema_editor.connection.alias
+ Group = apps.get_model("authentik_core", "Group")
+ User = apps.get_model("authentik_core", "User")
+
+ # Creates a default admin group
+ group, _ = Group.objects.using(db_alias).get_or_create(
+ is_superuser=True,
+ defaults={
+ "name": "authentik Admins",
+ },
+ )
+ group.users.set(User.objects.filter(username="akadmin"))
+ group.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0008_auto_20200824_1532"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="user",
+ name="is_superuser",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="is_staff",
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="pb_groups",
+ field=models.ManyToManyField(
+ related_name="users", to="authentik_core.Group"
+ ),
+ ),
+ migrations.AddField(
+ model_name="group",
+ name="is_superuser",
+ field=models.BooleanField(
+ default=False, help_text="Users added to this group will be superusers."
+ ),
+ ),
+ migrations.RunPython(create_default_admin_group),
+ migrations.AlterModelManagers(
+ name="user",
+ managers=[
+ ("objects", authentik.core.models.UserManager()),
+ ],
+ ),
+ ]
diff --git a/authentik/core/migrations/0010_auto_20200917_1021.py b/authentik/core/migrations/0010_auto_20200917_1021.py
new file mode 100644
index 000000000..d9e670dac
--- /dev/null
+++ b/authentik/core/migrations/0010_auto_20200917_1021.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1.1 on 2020-09-17 10:21
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0009_group_is_superuser"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="user",
+ options={
+ "permissions": (
+ ("reset_user_password", "Reset Password"),
+ ("impersonate", "Can impersonate other users"),
+ ),
+ "verbose_name": "User",
+ "verbose_name_plural": "Users",
+ },
+ ),
+ ]
diff --git a/authentik/core/migrations/0011_provider_name_temp.py b/authentik/core/migrations/0011_provider_name_temp.py
new file mode 100644
index 000000000..9b38c50d2
--- /dev/null
+++ b/authentik/core/migrations/0011_provider_name_temp.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1.2 on 2020-10-03 17:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0010_auto_20200917_1021"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="provider",
+ name="name_temp",
+ field=models.TextField(default=""),
+ preserve_default=False,
+ ),
+ ]
diff --git a/authentik/core/migrations/0012_auto_20201003_1737.py b/authentik/core/migrations/0012_auto_20201003_1737.py
new file mode 100644
index 000000000..8ec00aa24
--- /dev/null
+++ b/authentik/core/migrations/0012_auto_20201003_1737.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.1.2 on 2020-10-03 17:37
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0011_provider_name_temp"),
+ ("authentik_providers_oauth2", "0006_remove_oauth2provider_name"),
+ ("authentik_providers_saml", "0006_remove_samlprovider_name"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="provider",
+ old_name="name_temp",
+ new_name="name",
+ ),
+ ]
diff --git a/authentik/core/migrations/0013_auto_20201003_2132.py b/authentik/core/migrations/0013_auto_20201003_2132.py
new file mode 100644
index 000000000..9ed9b3624
--- /dev/null
+++ b/authentik/core/migrations/0013_auto_20201003_2132.py
@@ -0,0 +1,35 @@
+# Generated by Django 3.1.2 on 2020-10-03 21:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0012_auto_20201003_1737"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="token",
+ name="identifier",
+ field=models.TextField(default=""),
+ preserve_default=False,
+ ),
+ migrations.AlterField(
+ model_name="token",
+ name="intent",
+ field=models.TextField(
+ choices=[
+ ("verification", "Intent Verification"),
+ ("api", "Intent Api"),
+ ("recovery", "Intent Recovery"),
+ ],
+ default="verification",
+ ),
+ ),
+ migrations.AlterUniqueTogether(
+ name="token",
+ unique_together={("identifier", "user")},
+ ),
+ ]
diff --git a/authentik/core/migrations/0014_auto_20201018_1158.py b/authentik/core/migrations/0014_auto_20201018_1158.py
new file mode 100644
index 000000000..0f3f9dc96
--- /dev/null
+++ b/authentik/core/migrations/0014_auto_20201018_1158.py
@@ -0,0 +1,50 @@
+# Generated by Django 3.1.2 on 2020-10-18 11:58
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+import authentik.core.models
+
+
+def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ db_alias = schema_editor.connection.alias
+ Token = apps.get_model("authentik_core", "Token")
+
+ for token in Token.objects.using(db_alias).all():
+ token.key = token.pk.hex
+ token.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0013_auto_20201003_2132"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="token",
+ name="key",
+ field=models.TextField(default=authentik.core.models.default_token_key),
+ ),
+ migrations.AlterUniqueTogether(
+ name="token",
+ unique_together=set(),
+ ),
+ migrations.AlterField(
+ model_name="token",
+ name="identifier",
+ field=models.SlugField(max_length=255),
+ ),
+ migrations.AddIndex(
+ model_name="token",
+ index=models.Index(fields=["key"], name="authentik_co_key_e45007_idx"),
+ ),
+ migrations.AddIndex(
+ model_name="token",
+ index=models.Index(
+ fields=["identifier"], name="authentik_co_identif_1a34a8_idx"
+ ),
+ ),
+ migrations.RunPython(set_default_token_key),
+ ]
diff --git a/authentik/core/migrations/0015_application_icon.py b/authentik/core/migrations/0015_application_icon.py
new file mode 100644
index 000000000..4ea6ac2c8
--- /dev/null
+++ b/authentik/core/migrations/0015_application_icon.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1.3 on 2020-11-23 17:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0014_auto_20201018_1158"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="application",
+ name="meta_icon_url",
+ ),
+ migrations.AddField(
+ model_name="application",
+ name="meta_icon",
+ field=models.FileField(
+ blank=True, default="", upload_to="application-icons/"
+ ),
+ ),
+ ]
diff --git a/authentik/core/migrations/0016_auto_20201202_2234.py b/authentik/core/migrations/0016_auto_20201202_2234.py
new file mode 100644
index 000000000..e03ab30e0
--- /dev/null
+++ b/authentik/core/migrations/0016_auto_20201202_2234.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.1.3 on 2020-12-02 22:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0015_application_icon"),
+ ]
+
+ operations = [
+ migrations.RemoveIndex(
+ model_name="token",
+ name="authentik_co_key_e45007_idx",
+ ),
+ migrations.RemoveIndex(
+ model_name="token",
+ name="authentik_co_identif_1a34a8_idx",
+ ),
+ migrations.RenameField(
+ model_name="user",
+ old_name="pb_groups",
+ new_name="ak_groups",
+ ),
+ migrations.AddIndex(
+ model_name="token",
+ index=models.Index(
+ fields=["identifier"], name="authentik_c_identif_d9d032_idx"
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="token",
+ index=models.Index(fields=["key"], name="authentik_c_key_f71355_idx"),
+ ),
+ ]
diff --git a/passbook/core/migrations/__init__.py b/authentik/core/migrations/__init__.py
similarity index 100%
rename from passbook/core/migrations/__init__.py
rename to authentik/core/migrations/__init__.py
diff --git a/authentik/core/models.py b/authentik/core/models.py
new file mode 100644
index 000000000..b55acac51
--- /dev/null
+++ b/authentik/core/models.py
@@ -0,0 +1,371 @@
+"""authentik core models"""
+from datetime import timedelta
+from typing import Any, Dict, Optional, Type
+from uuid import uuid4
+
+from django.contrib.auth.models import AbstractUser
+from django.contrib.auth.models import UserManager as DjangoUserManager
+from django.db import models
+from django.db.models import Q, QuerySet
+from django.forms import ModelForm
+from django.http import HttpRequest
+from django.utils.functional import cached_property
+from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _
+from guardian.mixins import GuardianUserMixin
+from model_utils.managers import InheritanceManager
+from structlog import get_logger
+
+from authentik.core.exceptions import PropertyMappingExpressionException
+from authentik.core.signals import password_changed
+from authentik.core.types import UILoginButton
+from authentik.flows.models import Flow
+from authentik.lib.models import CreatedUpdatedModel
+from authentik.policies.models import PolicyBindingModel
+
+LOGGER = get_logger()
+USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
+USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
+
+
+def default_token_duration():
+ """Default duration a Token is valid"""
+ return now() + timedelta(minutes=30)
+
+
+def default_token_key():
+ """Default token key"""
+ return uuid4().hex
+
+
+class Group(models.Model):
+ """Custom Group model which supports a basic hierarchy"""
+
+ group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+
+ name = models.CharField(_("name"), max_length=80)
+ is_superuser = models.BooleanField(
+ default=False, help_text=_("Users added to this group will be superusers.")
+ )
+
+ parent = models.ForeignKey(
+ "Group",
+ blank=True,
+ null=True,
+ on_delete=models.SET_NULL,
+ related_name="children",
+ )
+ attributes = models.JSONField(default=dict, blank=True)
+
+ def __str__(self):
+ return f"Group {self.name}"
+
+ class Meta:
+
+ unique_together = (
+ (
+ "name",
+ "parent",
+ ),
+ )
+
+
+class UserManager(DjangoUserManager):
+ """Custom user manager that doesn't assign is_superuser and is_staff"""
+
+ def create_user(self, username, email=None, password=None, **extra_fields):
+ """Custom user manager that doesn't assign is_superuser and is_staff"""
+ return self._create_user(username, email, password, **extra_fields)
+
+
+class User(GuardianUserMixin, AbstractUser):
+ """Custom User model to allow easier adding o f user-based settings"""
+
+ uuid = models.UUIDField(default=uuid4, editable=False)
+ name = models.TextField(help_text=_("User's display name."))
+
+ sources = models.ManyToManyField("Source", through="UserSourceConnection")
+ ak_groups = models.ManyToManyField("Group", related_name="users")
+ password_change_date = models.DateTimeField(auto_now_add=True)
+
+ attributes = models.JSONField(default=dict, blank=True)
+
+ objects = UserManager()
+
+ def group_attributes(self) -> Dict[str, Any]:
+ """Get a dictionary containing the attributes from all groups the user belongs to,
+ including the users attributes"""
+ final_attributes = {}
+ for group in self.ak_groups.all().order_by("name"):
+ final_attributes.update(group.attributes)
+ final_attributes.update(self.attributes)
+ return final_attributes
+
+ @cached_property
+ def is_superuser(self) -> bool:
+ """Get supseruser status based on membership in a group with superuser status"""
+ return self.ak_groups.filter(is_superuser=True).exists()
+
+ @property
+ def is_staff(self) -> bool:
+ """superuser == staff user"""
+ return self.is_superuser # type: ignore
+
+ def set_password(self, password, signal=True):
+ if self.pk and signal:
+ password_changed.send(sender=self, user=self, password=password)
+ self.password_change_date = now()
+ return super().set_password(password)
+
+ class Meta:
+
+ permissions = (
+ ("reset_user_password", "Reset Password"),
+ ("impersonate", "Can impersonate other users"),
+ )
+ verbose_name = _("User")
+ verbose_name_plural = _("Users")
+
+
+class Provider(models.Model):
+ """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
+
+ name = models.TextField()
+
+ authorization_flow = models.ForeignKey(
+ Flow,
+ on_delete=models.CASCADE,
+ help_text=_("Flow used when authorizing this provider."),
+ related_name="provider_authorization",
+ )
+
+ property_mappings = models.ManyToManyField(
+ "PropertyMapping", default=None, blank=True
+ )
+
+ objects = InheritanceManager()
+
+ @property
+ def launch_url(self) -> Optional[str]:
+ """URL to this provider and initiate authorization for the user.
+ Can return None for providers that are not URL-based"""
+ return None
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ """Return Form class used to edit this object"""
+ raise NotImplementedError
+
+ def __str__(self):
+ return self.name
+
+
+class Application(PolicyBindingModel):
+ """Every Application which uses authentik for authentication/identification/authorization
+ needs an Application record. Other authentication types can subclass this Model to
+ 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."))
+ provider = models.OneToOneField(
+ "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
+ )
+
+ 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="", blank=True)
+ meta_description = models.TextField(default="", blank=True)
+ meta_publisher = models.TextField(default="", blank=True)
+
+ 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:
+ return self.meta_launch_url
+ if self.provider:
+ return self.get_provider().launch_url
+ return None
+
+ def get_provider(self) -> Optional[Provider]:
+ """Get casted provider instance"""
+ if not self.provider:
+ return None
+ return Provider.objects.get_subclass(pk=self.provider.pk)
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+
+ verbose_name = _("Application")
+ verbose_name_plural = _("Applications")
+
+
+class Source(PolicyBindingModel):
+ """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
+
+ name = models.TextField(help_text=_("Source's display Name."))
+ slug = models.SlugField(
+ help_text=_("Internal source name, used in URLs."), unique=True
+ )
+
+ enabled = models.BooleanField(default=True)
+ property_mappings = models.ManyToManyField(
+ "PropertyMapping", default=None, blank=True
+ )
+
+ authentication_flow = models.ForeignKey(
+ Flow,
+ blank=True,
+ null=True,
+ default=None,
+ on_delete=models.SET_NULL,
+ help_text=_("Flow to use when authenticating existing users."),
+ related_name="source_authentication",
+ )
+ enrollment_flow = models.ForeignKey(
+ Flow,
+ blank=True,
+ null=True,
+ default=None,
+ on_delete=models.SET_NULL,
+ help_text=_("Flow to use when enrolling new users."),
+ related_name="source_enrollment",
+ )
+
+ objects = InheritanceManager()
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ """Return Form class used to edit this object"""
+ raise NotImplementedError
+
+ @property
+ def ui_login_button(self) -> Optional[UILoginButton]:
+ """If source uses a http-based flow, return UI Information about the login
+ button. If source doesn't use http-based flow, return None."""
+ return None
+
+ @property
+ def ui_additional_info(self) -> Optional[str]:
+ """Return additional Info, such as a callback URL. Show in the administration interface."""
+ return None
+
+ @property
+ def ui_user_settings(self) -> Optional[str]:
+ """Entrypoint to integrate with User settings. Can either return None if no
+ user settings are available, or a string with the URL to fetch."""
+ return None
+
+ def __str__(self):
+ return self.name
+
+
+class UserSourceConnection(CreatedUpdatedModel):
+ """Connection between User and Source."""
+
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+ source = models.ForeignKey(Source, on_delete=models.CASCADE)
+
+ class Meta:
+
+ unique_together = (("user", "source"),)
+
+
+class ExpiringModel(models.Model):
+ """Base Model which can expire, and is automatically cleaned up."""
+
+ expires = models.DateTimeField(default=default_token_duration)
+ expiring = models.BooleanField(default=True)
+
+ @classmethod
+ def filter_not_expired(cls, **kwargs) -> QuerySet:
+ """Filer for tokens which are not expired yet or are not expiring,
+ and match filters in `kwargs`"""
+ expired = Q(expires__lt=now(), expiring=True)
+ return cls.objects.exclude(expired).filter(**kwargs)
+
+ @property
+ def is_expired(self) -> bool:
+ """Check if token is expired yet."""
+ return now() > self.expires
+
+ class Meta:
+
+ abstract = True
+
+
+class TokenIntents(models.TextChoices):
+ """Intents a Token can be created for."""
+
+ # Single use token
+ INTENT_VERIFICATION = "verification"
+
+ # Allow access to API
+ INTENT_API = "api"
+
+ # Recovery use for the recovery app
+ INTENT_RECOVERY = "recovery"
+
+
+class Token(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)
+ key = models.TextField(default=default_token_key)
+ intent = models.TextField(
+ choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION
+ )
+ user = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+")
+ description = models.TextField(default="", blank=True)
+
+ def __str__(self):
+ description = f"{self.identifier}"
+ if self.expiring:
+ description += f" (expires={self.expires})"
+ return description
+
+ class Meta:
+
+ verbose_name = _("Token")
+ verbose_name_plural = _("Tokens")
+ indexes = [
+ models.Index(fields=["identifier"]),
+ models.Index(fields=["key"]),
+ ]
+
+
+class PropertyMapping(models.Model):
+ """User-defined key -> x mapping which can be used by providers to expose extra data."""
+
+ pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+ name = models.TextField()
+ expression = models.TextField()
+
+ objects = InheritanceManager()
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ """Return Form class used to edit this object"""
+ raise NotImplementedError
+
+ def evaluate(
+ self, user: Optional[User], request: Optional[HttpRequest], **kwargs
+ ) -> Any:
+ """Evaluate `self.expression` using `**kwargs` as Context."""
+ from authentik.core.expression import PropertyMappingEvaluator
+
+ evaluator = PropertyMappingEvaluator()
+ evaluator.set_context(user, request, **kwargs)
+ try:
+ return evaluator.evaluate(self.expression)
+ except (ValueError, SyntaxError) as exc:
+ raise PropertyMappingExpressionException from exc
+
+ def __str__(self):
+ return f"Property Mapping {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Property Mapping")
+ verbose_name_plural = _("Property Mappings")
diff --git a/authentik/core/signals.py b/authentik/core/signals.py
new file mode 100644
index 000000000..ef493518f
--- /dev/null
+++ b/authentik/core/signals.py
@@ -0,0 +1,5 @@
+"""authentik core signals"""
+from django.core.signals import Signal
+
+# Arguments: user: User, password: str
+password_changed = Signal()
diff --git a/authentik/core/tasks.py b/authentik/core/tasks.py
new file mode 100644
index 000000000..d7c5fa091
--- /dev/null
+++ b/authentik/core/tasks.py
@@ -0,0 +1,63 @@
+"""authentik core tasks"""
+from datetime import datetime
+from io import StringIO
+
+from boto3.exceptions import Boto3Error
+from botocore.exceptions import BotoCoreError, ClientError
+from dbbackup.db.exceptions import CommandConnectorError
+from django.contrib.humanize.templatetags.humanize import naturaltime
+from django.core import management
+from django.utils.timezone import now
+from structlog import get_logger
+
+from authentik.core.models import ExpiringModel
+from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
+from authentik.root.celery import CELERY_APP
+
+LOGGER = get_logger()
+
+
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def clean_expired_models(self: MonitoredTask):
+ """Remove expired objects"""
+ messages = []
+ for cls in ExpiringModel.__subclasses__():
+ cls: ExpiringModel
+ amount, _ = (
+ cls.objects.all()
+ .exclude(expiring=False)
+ .exclude(expiring=True, expires__gt=now())
+ .delete()
+ )
+ LOGGER.debug("Deleted expired models", model=cls, amount=amount)
+ messages.append(f"Deleted {amount} expired {cls._meta.verbose_name_plural}")
+ self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
+
+
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def backup_database(self: MonitoredTask): # pragma: no cover
+ """Database backup"""
+ self.result_timeout_hours = 25
+ try:
+ start = datetime.now()
+ out = StringIO()
+ management.call_command("dbbackup", quiet=True, stdout=out)
+ self.set_status(
+ TaskResult(
+ TaskResultStatus.SUCCESSFUL,
+ [
+ f"Successfully finished database backup {naturaltime(start)}",
+ out.getvalue(),
+ ],
+ )
+ )
+ LOGGER.info("Successfully backed up database.")
+ except (
+ IOError,
+ BotoCoreError,
+ ClientError,
+ Boto3Error,
+ PermissionError,
+ CommandConnectorError,
+ ) as exc:
+ self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
diff --git a/authentik/core/templates/403_csrf.html b/authentik/core/templates/403_csrf.html
new file mode 100644
index 000000000..518b8705c
--- /dev/null
+++ b/authentik/core/templates/403_csrf.html
@@ -0,0 +1,27 @@
+{% extends 'login/base.html' %}
+
+{% load static %}
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block card_title %}
+{{ title }} (403)
+{% endblock %}
+
+{% block card %}
+
+{% endblock %}
diff --git a/authentik/core/templates/base/page.html b/authentik/core/templates/base/page.html
new file mode 100644
index 000000000..90f9ed892
--- /dev/null
+++ b/authentik/core/templates/base/page.html
@@ -0,0 +1,12 @@
+{% extends "base/skeleton.html" %}
+
+{% load i18n %}
+
+{% block body %}
+
+
+
{% trans 'Skip to content' %}
+ {% block page_content %}
+ {% endblock %}
+
+{% endblock %}
diff --git a/authentik/core/templates/base/skeleton.html b/authentik/core/templates/base/skeleton.html
new file mode 100644
index 000000000..d26ccd0cc
--- /dev/null
+++ b/authentik/core/templates/base/skeleton.html
@@ -0,0 +1,41 @@
+{% load static %}
+{% load i18n %}
+{% load authentik_utils %}
+
+
+
+
+
+
+
+
+
+ {% block title %}{% trans title|default:config.authentik.branding.title %}{% endblock %}
+
+
+
+
+
+
+
+
+ {% block head %}
+ {% endblock %}
+
+
+ {% if 'authentik_impersonate_user' in request.session %}
+
+ {% endif %}
+ {% block body %}
+ {% endblock %}
+ {% block scripts %}
+ {% endblock %}
+
+
diff --git a/authentik/core/templates/error/generic.html b/authentik/core/templates/error/generic.html
new file mode 100644
index 000000000..095be61d7
--- /dev/null
+++ b/authentik/core/templates/error/generic.html
@@ -0,0 +1,26 @@
+{% extends 'base/page.html' %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block body %}
+
+{% endblock %}
diff --git a/authentik/core/templates/generic/autosubmit_form.html b/authentik/core/templates/generic/autosubmit_form.html
new file mode 100644
index 000000000..b7254b437
--- /dev/null
+++ b/authentik/core/templates/generic/autosubmit_form.html
@@ -0,0 +1,31 @@
+{% extends "login/base.html" %}
+
+{% load authentik_utils %}
+{% load i18n %}
+
+{% block title %}
+{{ title }}
+{% endblock %}
+
+{% block card %}
+
+{% endblock %}
diff --git a/authentik/core/templates/generic/autosubmit_form_full.html b/authentik/core/templates/generic/autosubmit_form_full.html
new file mode 100644
index 000000000..e3b044b8a
--- /dev/null
+++ b/authentik/core/templates/generic/autosubmit_form_full.html
@@ -0,0 +1,34 @@
+{% extends "login/base_full.html" %}
+
+{% load authentik_utils %}
+{% load i18n %}
+
+{% block title %}
+{{ title }}
+{% endblock %}
+
+{% block card %}
+
+
+{% endblock %}
diff --git a/authentik/core/templates/generic/delete.html b/authentik/core/templates/generic/delete.html
new file mode 100644
index 000000000..594155be0
--- /dev/null
+++ b/authentik/core/templates/generic/delete.html
@@ -0,0 +1,43 @@
+{% extends container_template|default:"administration/base.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block content %}
+
+
+ {% block above_form %}
+
+ {% blocktrans with object_type=object|verbose_name %}
+ Delete {{ object_type }}
+ {% endblocktrans %}
+
+ {% endblock %}
+
+
+
+{% endblock %}
diff --git a/authentik/core/templates/library.html b/authentik/core/templates/library.html
new file mode 100644
index 000000000..fa69e07a9
--- /dev/null
+++ b/authentik/core/templates/library.html
@@ -0,0 +1,53 @@
+{% load i18n %}
+
+
+
+
+
+
+ {% trans 'Applications' %}
+
+
+
+
+ {% if applications %}
+
+ {% else %}
+
+
+
+
{% trans 'No Applications available.' %}
+
+ {% trans "Either no applications are defined, or you don't have access to any." %}
+
+ {% if perms.authentik_core.add_application %}
+
+ {% trans 'Create Application' %}
+
+ {% endif %}
+
+
+ {% endif %}
+
+
diff --git a/authentik/core/templates/login/base.html b/authentik/core/templates/login/base.html
new file mode 100644
index 000000000..401c5c1cd
--- /dev/null
+++ b/authentik/core/templates/login/base.html
@@ -0,0 +1,59 @@
+{% load static %}
+{% load i18n %}
+
+
+
+
+
+ {% block card_title %}
+ {% trans title %}
+ {% endblock %}
+
+
+
+ {% block card %}
+
+ {% endblock %}
+
+
+ {% if config.login.subtext %}
+ {{ config.login.subtext }}
+ {% endif %}
+
+ {% if enroll_url or recovery_url %}
+
+ {% endif %}
+
diff --git a/authentik/core/templates/login/base_full.html b/authentik/core/templates/login/base_full.html
new file mode 100644
index 000000000..bae02d2b7
--- /dev/null
+++ b/authentik/core/templates/login/base_full.html
@@ -0,0 +1,75 @@
+{% extends 'base/skeleton.html' %}
+
+{% load static %}
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block head %}
+{{ block.super }}
+
+{% endblock %}
+
+{% block body %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block main_container %}
+
+
+
+ {% block card_title %}
+ {% endblock %}
+
+
+
+ {% block card %}
+ {% endblock %}
+
+
+ {% endblock %}
+
+
+{% endblock %}
diff --git a/passbook/core/templates/login/form.html b/authentik/core/templates/login/form.html
similarity index 100%
rename from passbook/core/templates/login/form.html
rename to authentik/core/templates/login/form.html
diff --git a/authentik/core/templates/login/form_with_user.html b/authentik/core/templates/login/form_with_user.html
new file mode 100644
index 000000000..59f70b4f1
--- /dev/null
+++ b/authentik/core/templates/login/form_with_user.html
@@ -0,0 +1,18 @@
+{% extends 'login/form.html' %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block above_form %}
+
+{% endblock %}
diff --git a/authentik/core/templates/login/loading.html b/authentik/core/templates/login/loading.html
new file mode 100644
index 000000000..fd6ca02e2
--- /dev/null
+++ b/authentik/core/templates/login/loading.html
@@ -0,0 +1,24 @@
+{% extends 'login/base.html' %}
+
+{% load static %}
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block title %}
+{% trans title %}
+{% endblock %}
+
+{% block head %}
+
+{% endblock %}
+
+{% block card %}
+
+
+{% endblock %}
diff --git a/authentik/core/templates/partials/form.html b/authentik/core/templates/partials/form.html
new file mode 100644
index 000000000..be6358093
--- /dev/null
+++ b/authentik/core/templates/partials/form.html
@@ -0,0 +1,73 @@
+{% load authentik_utils %}
+{% load i18n %}
+
+{% csrf_token %}
+{% if form.non_field_errors %}
+
+{% endif %}
+{% for field in form %}
+{% if field.field.widget|fieldtype == 'HiddenInput' %}
+ {{ field }}
+{% else %}
+
+{% endif %}
+{% endfor %}
diff --git a/authentik/core/templates/partials/form_horizontal.html b/authentik/core/templates/partials/form_horizontal.html
new file mode 100644
index 000000000..883fc61ef
--- /dev/null
+++ b/authentik/core/templates/partials/form_horizontal.html
@@ -0,0 +1,108 @@
+{% load authentik_utils %}
+{% load i18n %}
+
+{% csrf_token %}
+{% for field in form %}
+
+{% endfor %}
diff --git a/authentik/core/templates/partials/pagination.html b/authentik/core/templates/partials/pagination.html
new file mode 100644
index 000000000..87c2ae354
--- /dev/null
+++ b/authentik/core/templates/partials/pagination.html
@@ -0,0 +1,42 @@
+{% load i18n %}
+{% load authentik_utils %}
+
+
diff --git a/passbook/core/templates/partials/toolbar_search.html b/authentik/core/templates/partials/toolbar_search.html
similarity index 100%
rename from passbook/core/templates/partials/toolbar_search.html
rename to authentik/core/templates/partials/toolbar_search.html
diff --git a/authentik/core/templates/shell.html b/authentik/core/templates/shell.html
new file mode 100644
index 000000000..4d4ff3b66
--- /dev/null
+++ b/authentik/core/templates/shell.html
@@ -0,0 +1,5 @@
+{% extends "base/skeleton.html" %}
+
+{% block body %}
+
+{% endblock %}
diff --git a/authentik/core/templates/user/settings.html b/authentik/core/templates/user/settings.html
new file mode 100644
index 000000000..ef34045b8
--- /dev/null
+++ b/authentik/core/templates/user/settings.html
@@ -0,0 +1,78 @@
+{% load i18n %}
+{% load authentik_user_settings %}
+
+
+
+
+
+
+
+ {% trans 'User Settings' %}
+
+
{% trans "Configure settings relevant to your user profile." %}
+
+
+
+
+ {% user_stages as user_stages_loc %}
+ {% for stage in user_stages_loc %}
+
+ {% endfor %}
+ {% user_sources as user_sources_loc %}
+ {% for source in user_sources_loc %}
+
+ {% endfor %}
+
+
diff --git a/authentik/core/templates/user/token_list.html b/authentik/core/templates/user/token_list.html
new file mode 100644
index 000000000..c51b6b760
--- /dev/null
+++ b/authentik/core/templates/user/token_list.html
@@ -0,0 +1,100 @@
+{% load i18n %}
+
+
+
+ {% if object_list %}
+
+
+
+
+ {% trans 'Identifier' %}
+ {% trans 'Expires?' %}
+ {% trans 'Expiry Date' %}
+ {% trans 'Description' %}
+
+
+
+
+ {% for token in object_list %}
+
+
+ {{ token.identifier }}
+
+
+
+ {{ token.expiring|yesno:"Yes,No" }}
+
+
+
+
+ {% if not token.expiring %}
+ -
+ {% else %}
+ {{ token.expires }}
+ {% endif %}
+
+
+
+
+ {{ token.description }}
+
+
+
+
+
+ {% trans 'Edit' %}
+
+
+
+
+
+ {% trans 'Delete' %}
+
+
+
+
+ {% trans 'Copy token' %}
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+
+ {% trans 'No Tokens.' %}
+
+
+ {% trans 'Currently no tokens exist. Click the button below to create one.' %}
+
+
+
+ {% trans 'Create' %}
+
+
+
+
+
+ {% endif %}
+
diff --git a/passbook/core/templatetags/__init__.py b/authentik/core/templatetags/__init__.py
similarity index 100%
rename from passbook/core/templatetags/__init__.py
rename to authentik/core/templatetags/__init__.py
diff --git a/authentik/core/templatetags/authentik_user_settings.py b/authentik/core/templatetags/authentik_user_settings.py
new file mode 100644
index 000000000..0721fc72e
--- /dev/null
+++ b/authentik/core/templatetags/authentik_user_settings.py
@@ -0,0 +1,44 @@
+"""authentik user settings template tags"""
+from typing import Iterable
+
+from django import template
+from django.template.context import RequestContext
+
+from authentik.core.models import Source
+from authentik.flows.models import Stage
+from authentik.policies.engine import PolicyEngine
+
+register = template.Library()
+
+
+@register.simple_tag(takes_context=True)
+# pylint: disable=unused-argument
+def user_stages(context: RequestContext) -> list[str]:
+ """Return list of all stages which apply to user"""
+ _all_stages: Iterable[Stage] = Stage.objects.all().select_subclasses()
+ matching_stages: list[str] = []
+ for stage in _all_stages:
+ user_settings = stage.ui_user_settings
+ if not user_settings:
+ continue
+ matching_stages.append(user_settings)
+ return matching_stages
+
+
+@register.simple_tag(takes_context=True)
+def user_sources(context: RequestContext) -> list[str]:
+ """Return a list of all sources which are enabled for the user"""
+ user = context.get("request").user
+ _all_sources: Iterable[Source] = Source.objects.filter(
+ enabled=True
+ ).select_subclasses()
+ matching_sources: list[str] = []
+ for source in _all_sources:
+ user_settings = source.ui_user_settings
+ if not user_settings:
+ continue
+ policy_engine = PolicyEngine(source, user, context.get("request"))
+ policy_engine.build()
+ if policy_engine.passing:
+ matching_sources.append(user_settings)
+ return matching_sources
diff --git a/passbook/core/tests/__init__.py b/authentik/core/tests/__init__.py
similarity index 100%
rename from passbook/core/tests/__init__.py
rename to authentik/core/tests/__init__.py
diff --git a/authentik/core/tests/test_impersonation.py b/authentik/core/tests/test_impersonation.py
new file mode 100644
index 000000000..4c18483b3
--- /dev/null
+++ b/authentik/core/tests/test_impersonation.py
@@ -0,0 +1,56 @@
+"""impersonation tests"""
+from django.shortcuts import reverse
+from django.test.testcases import TestCase
+
+from authentik.core.models import User
+
+
+class TestImpersonation(TestCase):
+ """impersonation tests"""
+
+ def setUp(self) -> None:
+ super().setUp()
+ self.other_user = User.objects.create(username="to-impersonate")
+ self.akadmin = User.objects.get(username="akadmin")
+
+ def test_impersonate_simple(self):
+ """test simple impersonation and un-impersonation"""
+ self.client.force_login(self.akadmin)
+
+ self.client.get(
+ reverse(
+ "authentik_core:impersonate-init",
+ kwargs={"user_id": self.other_user.pk},
+ )
+ )
+
+ response = self.client.get(reverse("authentik_api:user-me"))
+ self.assertIn(self.other_user.username, response.content.decode())
+ self.assertNotIn(self.akadmin.username, response.content.decode())
+
+ self.client.get(reverse("authentik_core:impersonate-end"))
+
+ response = self.client.get(reverse("authentik_api:user-me"))
+ self.assertNotIn(self.other_user.username, response.content.decode())
+ self.assertIn(self.akadmin.username, response.content.decode())
+
+ def test_impersonate_denied(self):
+ """test impersonation without permissions"""
+ self.client.force_login(self.other_user)
+
+ self.client.get(
+ reverse(
+ "authentik_core:impersonate-init", kwargs={"user_id": self.akadmin.pk}
+ )
+ )
+
+ response = self.client.get(reverse("authentik_api:user-me"))
+ self.assertIn(self.other_user.username, response.content.decode())
+ self.assertNotIn(self.akadmin.username, response.content.decode())
+
+ def test_un_impersonate_empty(self):
+ """test un-impersonation without impersonating first"""
+ self.client.force_login(self.other_user)
+
+ response = self.client.get(reverse("authentik_core:impersonate-end"))
+ self.assertRedirects(response, reverse("authentik_core:shell"))
diff --git a/authentik/core/tests/test_tasks.py b/authentik/core/tests/test_tasks.py
new file mode 100644
index 000000000..ff19843cc
--- /dev/null
+++ b/authentik/core/tests/test_tasks.py
@@ -0,0 +1,18 @@
+"""authentik core task tests"""
+from django.test import TestCase
+from django.utils.timezone import now
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.core.models import Token
+from authentik.core.tasks import clean_expired_models
+
+
+class TestTasks(TestCase):
+ """Test Tasks"""
+
+ def test_token_cleanup(self):
+ """Test Token cleanup task"""
+ Token.objects.create(expires=now(), user=get_anonymous_user())
+ self.assertEqual(Token.objects.all().count(), 1)
+ clean_expired_models.delay().get()
+ self.assertEqual(Token.objects.all().count(), 0)
diff --git a/authentik/core/tests/test_views_overview.py b/authentik/core/tests/test_views_overview.py
new file mode 100644
index 000000000..a756517b9
--- /dev/null
+++ b/authentik/core/tests/test_views_overview.py
@@ -0,0 +1,42 @@
+"""authentik user view tests"""
+import string
+from random import SystemRandom
+
+from django.shortcuts import reverse
+from django.test import TestCase
+
+from authentik.core.models import User
+
+
+class TestOverviewViews(TestCase):
+ """Test Overview Views"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create_user(
+ username="unittest user",
+ email="unittest@example.com",
+ password="".join(
+ SystemRandom().choice(string.ascii_uppercase + string.digits)
+ for _ in range(8)
+ ),
+ )
+ self.client.force_login(self.user)
+
+ def test_shell(self):
+ """Test shell"""
+ self.assertEqual(
+ self.client.get(reverse("authentik_core:shell")).status_code, 200
+ )
+
+ def test_overview(self):
+ """Test overview"""
+ self.assertEqual(
+ self.client.get(reverse("authentik_core:overview")).status_code, 200
+ )
+
+ def test_user_settings(self):
+ """Test user settings"""
+ self.assertEqual(
+ self.client.get(reverse("authentik_core:user-settings")).status_code, 200
+ )
diff --git a/authentik/core/tests/test_views_user.py b/authentik/core/tests/test_views_user.py
new file mode 100644
index 000000000..04b5c608d
--- /dev/null
+++ b/authentik/core/tests/test_views_user.py
@@ -0,0 +1,30 @@
+"""authentik user view tests"""
+import string
+from random import SystemRandom
+
+from django.shortcuts import reverse
+from django.test import TestCase
+
+from authentik.core.models import User
+
+
+class TestUserViews(TestCase):
+ """Test User Views"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create_user(
+ username="unittest user",
+ email="unittest@example.com",
+ password="".join(
+ SystemRandom().choice(string.ascii_uppercase + string.digits)
+ for _ in range(8)
+ ),
+ )
+ self.client.force_login(self.user)
+
+ def test_user_settings(self):
+ """Test UserSettingsView"""
+ self.assertEqual(
+ self.client.get(reverse("authentik_core:user-settings")).status_code, 200
+ )
diff --git a/authentik/core/types.py b/authentik/core/types.py
new file mode 100644
index 000000000..4dd249682
--- /dev/null
+++ b/authentik/core/types.py
@@ -0,0 +1,20 @@
+"""authentik core dataclasses"""
+from dataclasses import dataclass
+from typing import Optional
+
+
+@dataclass
+class UILoginButton:
+ """Dataclass for Source's ui_login_button"""
+
+ # Name, ran through i18n
+ name: str
+
+ # URL Which Button points to
+ url: str
+
+ # Icon name, ran through django's static
+ icon_path: Optional[str] = None
+
+ # Icon URL, used as-is
+ icon_url: Optional[str] = None
diff --git a/authentik/core/urls.py b/authentik/core/urls.py
new file mode 100644
index 000000000..e4bafb726
--- /dev/null
+++ b/authentik/core/urls.py
@@ -0,0 +1,39 @@
+"""authentik URL Configuration"""
+from django.urls import path
+
+from authentik.core.views import impersonate, library, shell, user
+
+urlpatterns = [
+ path("", shell.ShellView.as_view(), name="shell"),
+ # User views
+ path("-/user/", user.UserSettingsView.as_view(), name="user-settings"),
+ path("-/user/tokens/", user.TokenListView.as_view(), name="user-tokens"),
+ path(
+ "-/user/tokens/create/",
+ user.TokenCreateView.as_view(),
+ name="user-tokens-create",
+ ),
+ path(
+ "-/user/tokens//update/",
+ user.TokenUpdateView.as_view(),
+ name="user-tokens-update",
+ ),
+ path(
+ "-/user/tokens//delete/",
+ user.TokenDeleteView.as_view(),
+ name="user-tokens-delete",
+ ),
+ # Libray
+ path("library/", library.LibraryView.as_view(), name="overview"),
+ # Impersonation
+ path(
+ "-/impersonation//",
+ impersonate.ImpersonateInitView.as_view(),
+ name="impersonate-init",
+ ),
+ path(
+ "-/impersonation/end/",
+ impersonate.ImpersonateEndView.as_view(),
+ name="impersonate-end",
+ ),
+]
diff --git a/passbook/core/views/__init__.py b/authentik/core/views/__init__.py
similarity index 100%
rename from passbook/core/views/__init__.py
rename to authentik/core/views/__init__.py
diff --git a/authentik/core/views/error.py b/authentik/core/views/error.py
new file mode 100644
index 000000000..9f9e4deae
--- /dev/null
+++ b/authentik/core/views/error.py
@@ -0,0 +1,67 @@
+"""authentik core error views"""
+
+from django.http.response import (
+ HttpResponseBadRequest,
+ HttpResponseForbidden,
+ HttpResponseNotFound,
+ HttpResponseServerError,
+)
+from django.template.response import TemplateResponse
+from django.views.generic import TemplateView
+
+
+class BadRequestTemplateResponse(TemplateResponse, HttpResponseBadRequest):
+ """Combine Template response with Http Code 400"""
+
+
+class ForbiddenTemplateResponse(TemplateResponse, HttpResponseForbidden):
+ """Combine Template response with Http Code 403"""
+
+
+class NotFoundTemplateResponse(TemplateResponse, HttpResponseNotFound):
+ """Combine Template response with Http Code 404"""
+
+
+class ServerErrorTemplateResponse(TemplateResponse, HttpResponseServerError):
+ """Combine Template response with Http Code 500"""
+
+
+class BadRequestView(TemplateView):
+ """Show Bad Request message"""
+
+ extra_context = {"title": "Bad Request"}
+
+ response_class = BadRequestTemplateResponse
+ template_name = "error/generic.html"
+
+
+class ForbiddenView(TemplateView):
+ """Show Forbidden message"""
+
+ extra_context = {"title": "Forbidden"}
+
+ response_class = ForbiddenTemplateResponse
+ template_name = "error/generic.html"
+
+
+class NotFoundView(TemplateView):
+ """Show Not Found message"""
+
+ extra_context = {"title": "Not Found"}
+
+ response_class = NotFoundTemplateResponse
+ template_name = "error/generic.html"
+
+
+class ServerErrorView(TemplateView):
+ """Show Server Error message"""
+
+ extra_context = {"title": "Server Error"}
+
+ response_class = ServerErrorTemplateResponse
+ template_name = "error/generic.html"
+
+ # pylint: disable=useless-super-delegation
+ def dispatch(self, *args, **kwargs): # pragma: no cover
+ """Little wrapper so django accepts this function"""
+ return super().dispatch(*args, **kwargs)
diff --git a/authentik/core/views/impersonate.py b/authentik/core/views/impersonate.py
new file mode 100644
index 000000000..ef94e607d
--- /dev/null
+++ b/authentik/core/views/impersonate.py
@@ -0,0 +1,58 @@
+"""authentik impersonation views"""
+
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404, redirect
+from django.views import View
+from structlog import get_logger
+
+from authentik.audit.models import Event, EventAction
+from authentik.core.middleware import (
+ SESSION_IMPERSONATE_ORIGINAL_USER,
+ SESSION_IMPERSONATE_USER,
+)
+from authentik.core.models import User
+
+LOGGER = get_logger()
+
+
+class ImpersonateInitView(View):
+ """Initiate Impersonation"""
+
+ def get(self, request: HttpRequest, user_id: int) -> HttpResponse:
+ """Impersonation handler, checks permissions"""
+ if not request.user.has_perm("impersonate"):
+ LOGGER.debug(
+ "User attempted to impersonate without permissions", user=request.user
+ )
+ return HttpResponse("Unauthorized", status=401)
+
+ user_to_be = get_object_or_404(User, pk=user_id)
+
+ request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user
+ request.session[SESSION_IMPERSONATE_USER] = user_to_be
+
+ Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
+
+ return redirect("authentik_core:shell")
+
+
+class ImpersonateEndView(View):
+ """End User impersonation"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ """End Impersonation handler"""
+ if (
+ SESSION_IMPERSONATE_USER not in request.session
+ or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session
+ ):
+ LOGGER.debug("Can't end impersonation", user=request.user)
+ return redirect("authentik_core:shell")
+
+ original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
+
+ del request.session[SESSION_IMPERSONATE_USER]
+ del request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
+
+ Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
+
+ return redirect("authentik_core:shell")
diff --git a/authentik/core/views/library.py b/authentik/core/views/library.py
new file mode 100644
index 000000000..0d7984cfc
--- /dev/null
+++ b/authentik/core/views/library.py
@@ -0,0 +1,23 @@
+"""authentik library view"""
+
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.views.generic import TemplateView
+
+from authentik.core.models import Application
+from authentik.policies.engine import PolicyEngine
+
+
+class LibraryView(LoginRequiredMixin, TemplateView):
+ """Overview for logged in user, incase user opens authentik directly
+ and is not being forwarded"""
+
+ template_name = "library.html"
+
+ def get_context_data(self, **kwargs):
+ kwargs["applications"] = []
+ for application in Application.objects.all().order_by("name"):
+ engine = PolicyEngine(application, self.request.user, self.request)
+ engine.build()
+ if engine.passing:
+ kwargs["applications"].append(application)
+ return super().get_context_data(**kwargs)
diff --git a/passbook/core/views/shell.py b/authentik/core/views/shell.py
similarity index 100%
rename from passbook/core/views/shell.py
rename to authentik/core/views/shell.py
diff --git a/authentik/core/views/user.py b/authentik/core/views/user.py
new file mode 100644
index 000000000..1c3cdd24d
--- /dev/null
+++ b/authentik/core/views/user.py
@@ -0,0 +1,137 @@
+"""authentik core user views"""
+from typing import Any, Dict
+
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin as DjangoPermissionRequiredMixin,
+)
+from django.contrib.messages.views import SuccessMessageMixin
+from django.db.models.query import QuerySet
+from django.http.response import HttpResponse
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import ListView, UpdateView
+from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
+from guardian.shortcuts import get_objects_for_user
+
+from authentik.admin.views.utils import (
+ DeleteMessageView,
+ SearchListMixin,
+ UserPaginateListMixin,
+)
+from authentik.core.forms.token import UserTokenForm
+from authentik.core.forms.users import UserDetailForm
+from authentik.core.models import Token, TokenIntents
+from authentik.flows.models import Flow, FlowDesignation
+from authentik.lib.views import CreateAssignPermView
+
+
+class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
+ """Update User settings"""
+
+ template_name = "user/settings.html"
+ form_class = UserDetailForm
+
+ success_message = _("Successfully updated user.")
+ success_url = reverse_lazy("authentik_core:user-settings")
+
+ def get_object(self):
+ return self.request.user
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ unenrollment_flow = Flow.with_policy(
+ self.request, designation=FlowDesignation.UNRENOLLMENT
+ )
+ kwargs["unenrollment_enabled"] = bool(unenrollment_flow)
+ return kwargs
+
+
+class TokenListView(
+ LoginRequiredMixin,
+ PermissionListMixin,
+ UserPaginateListMixin,
+ SearchListMixin,
+ ListView,
+):
+ """Show list of all tokens"""
+
+ model = Token
+ ordering = "expires"
+ permission_required = "authentik_core.view_token"
+
+ template_name = "user/token_list.html"
+ search_fields = [
+ "identifier",
+ "intent",
+ "description",
+ ]
+
+ def get_queryset(self) -> QuerySet:
+ return super().get_queryset().filter(intent=TokenIntents.INTENT_API)
+
+
+class TokenCreateView(
+ SuccessMessageMixin,
+ LoginRequiredMixin,
+ DjangoPermissionRequiredMixin,
+ CreateAssignPermView,
+):
+ """Create new Token"""
+
+ model = Token
+ form_class = UserTokenForm
+ permission_required = "authentik_core.add_token"
+
+ template_name = "generic/create.html"
+ success_url = reverse_lazy("authentik_core:user-tokens")
+ success_message = _("Successfully created Token")
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ kwargs["container_template"] = "user/base.html"
+ return kwargs
+
+ def form_valid(self, form: UserTokenForm) -> HttpResponse:
+ form.instance.user = self.request.user
+ form.instance.intent = TokenIntents.INTENT_API
+ return super().form_valid(form)
+
+
+class TokenUpdateView(
+ SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
+):
+ """Update token"""
+
+ model = Token
+ form_class = UserTokenForm
+ permission_required = "authentik_core.update_token"
+ template_name = "generic/update.html"
+ success_url = reverse_lazy("authentik_core:user-tokens")
+ success_message = _("Successfully updated Token")
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ kwargs["container_template"] = "user/base.html"
+ return kwargs
+
+ def get_object(self) -> Token:
+ identifier = self.kwargs.get("identifier")
+ return get_objects_for_user(
+ self.request.user, "authentik_core.update_token", self.model
+ ).filter(intent=TokenIntents.INTENT_API, identifier=identifier)
+
+
+class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
+ """Delete token"""
+
+ model = Token
+ permission_required = "authentik_core.delete_token"
+ template_name = "generic/delete.html"
+ success_url = reverse_lazy("authentik_core:user-tokens")
+ success_message = _("Successfully deleted Token")
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ kwargs["container_template"] = "user/base.html"
+ return kwargs
diff --git a/passbook/crypto/__init__.py b/authentik/crypto/__init__.py
similarity index 100%
rename from passbook/crypto/__init__.py
rename to authentik/crypto/__init__.py
diff --git a/authentik/crypto/api.py b/authentik/crypto/api.py
new file mode 100644
index 000000000..1cbe0b728
--- /dev/null
+++ b/authentik/crypto/api.py
@@ -0,0 +1,47 @@
+"""Crypto API Views"""
+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 rest_framework.serializers import ModelSerializer, ValidationError
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.crypto.models import CertificateKeyPair
+
+
+class CertificateKeyPairSerializer(ModelSerializer):
+ """CertificateKeyPair Serializer"""
+
+ 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())
+ except ValueError:
+ raise ValidationError("Unable to load certificate.")
+ return value
+
+ 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 == "":
+ return value
+ try:
+ load_pem_private_key(
+ str.encode("\n".join([x.strip() for x in value.split("\n")])),
+ password=None,
+ backend=default_backend(),
+ )
+ except ValueError:
+ raise ValidationError("Unable to load private key.")
+ return value
+
+ class Meta:
+
+ model = CertificateKeyPair
+ fields = ["pk", "name", "certificate_data", "key_data"]
+
+
+class CertificateKeyPairViewSet(ModelViewSet):
+ """CertificateKeyPair Viewset"""
+
+ queryset = CertificateKeyPair.objects.all()
+ serializer_class = CertificateKeyPairSerializer
diff --git a/authentik/crypto/apps.py b/authentik/crypto/apps.py
new file mode 100644
index 000000000..a7c5419b4
--- /dev/null
+++ b/authentik/crypto/apps.py
@@ -0,0 +1,10 @@
+"""authentik crypto app config"""
+from django.apps import AppConfig
+
+
+class AuthentikCryptoConfig(AppConfig):
+ """authentik crypto app config"""
+
+ name = "authentik.crypto"
+ label = "authentik_crypto"
+ verbose_name = "authentik Crypto"
diff --git a/authentik/crypto/builder.py b/authentik/crypto/builder.py
new file mode 100644
index 000000000..122766a86
--- /dev/null
+++ b/authentik/crypto/builder.py
@@ -0,0 +1,84 @@
+"""Create self-signed certificates"""
+import datetime
+import uuid
+
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+
+
+class CertificateBuilder:
+ """Build self-signed certificates"""
+
+ __public_key = None
+ __private_key = None
+ __builder = None
+ __certificate = None
+
+ def __init__(self):
+ self.__public_key = None
+ self.__private_key = None
+ self.__builder = None
+ self.__certificate = None
+
+ def build(self):
+ """Build self-signed certificate"""
+ one_day = datetime.timedelta(1, 0, 0)
+ self.__private_key = rsa.generate_private_key(
+ public_exponent=65537, key_size=2048, backend=default_backend()
+ )
+ self.__public_key = self.__private_key.public_key()
+ self.__builder = (
+ x509.CertificateBuilder()
+ .subject_name(
+ x509.Name(
+ [
+ x509.NameAttribute(
+ NameOID.COMMON_NAME,
+ "authentik Self-signed Certificate",
+ ),
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "authentik"),
+ x509.NameAttribute(
+ NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed"
+ ),
+ ]
+ )
+ )
+ .issuer_name(
+ x509.Name(
+ [
+ x509.NameAttribute(
+ NameOID.COMMON_NAME,
+ "authentik Self-signed Certificate",
+ ),
+ ]
+ )
+ )
+ .not_valid_before(datetime.datetime.today() - one_day)
+ .not_valid_after(datetime.datetime.today() + datetime.timedelta(days=365))
+ .serial_number(int(uuid.uuid4()))
+ .public_key(self.__public_key)
+ )
+ self.__certificate = self.__builder.sign(
+ private_key=self.__private_key,
+ algorithm=hashes.SHA256(),
+ backend=default_backend(),
+ )
+
+ @property
+ def private_key(self):
+ """Return private key in PEM format"""
+ return self.__private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption(),
+ ).decode("utf-8")
+
+ @property
+ def certificate(self):
+ """Return certificate in PEM format"""
+ return self.__certificate.public_bytes(
+ encoding=serialization.Encoding.PEM,
+ ).decode("utf-8")
diff --git a/authentik/crypto/forms.py b/authentik/crypto/forms.py
new file mode 100644
index 000000000..6ab8dafc4
--- /dev/null
+++ b/authentik/crypto/forms.py
@@ -0,0 +1,57 @@
+"""authentik Crypto forms"""
+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 import forms
+from django.utils.translation import gettext_lazy as _
+
+from authentik.crypto.models import CertificateKeyPair
+
+
+class CertificateKeyPairForm(forms.ModelForm):
+ """CertificateKeyPair Form"""
+
+ def clean_certificate_data(self):
+ """Verify that input is a valid PEM x509 Certificate"""
+ certificate_data = self.cleaned_data["certificate_data"]
+ try:
+ load_pem_x509_certificate(
+ certificate_data.encode("utf-8"), default_backend()
+ )
+ except ValueError:
+ raise forms.ValidationError("Unable to load certificate.")
+ return certificate_data
+
+ def clean_key_data(self):
+ """Verify that input is a valid PEM RSA Key"""
+ key_data = self.cleaned_data["key_data"]
+ # Since this field is optional, data can be empty.
+ if key_data == "":
+ return key_data
+ try:
+ load_pem_private_key(
+ str.encode("\n".join([x.strip() for x in key_data.split("\n")])),
+ password=None,
+ backend=default_backend(),
+ )
+ except ValueError:
+ raise forms.ValidationError("Unable to load private key.")
+ return key_data
+
+ class Meta:
+
+ model = CertificateKeyPair
+ fields = [
+ "name",
+ "certificate_data",
+ "key_data",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "certificate_data": forms.Textarea(attrs={"class": "monospaced"}),
+ "key_data": forms.Textarea(attrs={"class": "monospaced"}),
+ }
+ labels = {
+ "certificate_data": _("Certificate"),
+ "key_data": _("Private Key"),
+ }
diff --git a/passbook/crypto/migrations/0001_initial.py b/authentik/crypto/migrations/0001_initial.py
similarity index 100%
rename from passbook/crypto/migrations/0001_initial.py
rename to authentik/crypto/migrations/0001_initial.py
diff --git a/authentik/crypto/migrations/0002_create_self_signed_kp.py b/authentik/crypto/migrations/0002_create_self_signed_kp.py
new file mode 100644
index 000000000..013d2de09
--- /dev/null
+++ b/authentik/crypto/migrations/0002_create_self_signed_kp.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.0.6 on 2020-05-23 23:07
+
+from django.db import migrations
+
+
+def create_self_signed(apps, schema_editor):
+ CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
+ db_alias = schema_editor.connection.alias
+ from authentik.crypto.builder import CertificateBuilder
+
+ builder = CertificateBuilder()
+ builder.build()
+ CertificateKeyPair.objects.using(db_alias).create(
+ name="authentik Self-signed Certificate",
+ certificate_data=builder.certificate,
+ key_data=builder.private_key,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_crypto", "0001_initial"),
+ ]
+
+ operations = [migrations.RunPython(create_self_signed)]
diff --git a/passbook/crypto/migrations/__init__.py b/authentik/crypto/migrations/__init__.py
similarity index 100%
rename from passbook/crypto/migrations/__init__.py
rename to authentik/crypto/migrations/__init__.py
diff --git a/authentik/crypto/models.py b/authentik/crypto/models.py
new file mode 100644
index 000000000..3b447e66d
--- /dev/null
+++ b/authentik/crypto/models.py
@@ -0,0 +1,87 @@
+"""authentik crypto models"""
+from binascii import hexlify
+from hashlib import md5
+from typing import Optional
+from uuid import uuid4
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
+from cryptography.hazmat.primitives.serialization import load_pem_private_key
+from cryptography.x509 import Certificate, load_pem_x509_certificate
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from authentik.lib.models import CreatedUpdatedModel
+
+
+class CertificateKeyPair(CreatedUpdatedModel):
+ """CertificateKeyPair that can be used for signing or encrypting if `key_data`
+ is set, otherwise it can be used to verify remote data."""
+
+ kp_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+
+ name = models.TextField()
+ certificate_data = models.TextField(help_text=_("PEM-encoded Certificate data"))
+ key_data = models.TextField(
+ help_text=_(
+ "Optional Private Key. If this is set, you can use this keypair for encryption."
+ ),
+ blank=True,
+ default="",
+ )
+
+ _cert: Optional[Certificate] = None
+ _private_key: Optional[RSAPrivateKey] = None
+ _public_key: Optional[RSAPublicKey] = None
+
+ @property
+ def certificate(self) -> Certificate:
+ """Get python cryptography Certificate instance"""
+ if not self._cert:
+ self._cert = load_pem_x509_certificate(
+ self.certificate_data.encode("utf-8"), default_backend()
+ )
+ return self._cert
+
+ @property
+ def public_key(self) -> Optional[RSAPublicKey]:
+ """Get public key of the private key"""
+ if not self._public_key:
+ self._public_key = self.private_key.public_key()
+ return self._public_key
+
+ @property
+ def private_key(self) -> Optional[RSAPrivateKey]:
+ """Get python cryptography PrivateKey instance"""
+ if not self._private_key and self._private_key != "":
+ self._private_key = load_pem_private_key(
+ str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])),
+ password=None,
+ backend=default_backend(),
+ )
+ return self._private_key
+
+ @property
+ def fingerprint(self) -> str:
+ """Get SHA256 Fingerprint of certificate_data"""
+ return hexlify(self.certificate.fingerprint(hashes.SHA256()), ":").decode(
+ "utf-8"
+ )
+
+ @property
+ def kid(self):
+ """Get Key ID used for JWKS"""
+ return "{0}".format(
+ md5(self.key_data.encode("utf-8")).hexdigest() # nosec
+ if self.key_data
+ else ""
+ )
+
+ def __str__(self) -> str:
+ return f"Certificate-Key Pair {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Certificate-Key Pair")
+ verbose_name_plural = _("Certificate-Key Pairs")
diff --git a/authentik/crypto/tests.py b/authentik/crypto/tests.py
new file mode 100644
index 000000000..1b43806d2
--- /dev/null
+++ b/authentik/crypto/tests.py
@@ -0,0 +1,50 @@
+"""Crypto tests"""
+from django.test import TestCase
+
+from authentik.crypto.api import CertificateKeyPairSerializer
+from authentik.crypto.forms import CertificateKeyPairForm
+from authentik.crypto.models import CertificateKeyPair
+
+
+class TestCrypto(TestCase):
+ """Test Crypto validation"""
+
+ def test_form(self):
+ """Test form validation"""
+ keypair = CertificateKeyPair.objects.first()
+ self.assertTrue(
+ CertificateKeyPairForm(
+ {
+ "name": keypair.name,
+ "certificate_data": keypair.certificate_data,
+ "key_data": keypair.key_data,
+ }
+ ).is_valid()
+ )
+ self.assertFalse(
+ CertificateKeyPairForm(
+ {"name": keypair.name, "certificate_data": "test", "key_data": "test"}
+ ).is_valid()
+ )
+
+ def test_serializer(self):
+ """Test API Validation"""
+ keypair = CertificateKeyPair.objects.first()
+ self.assertTrue(
+ CertificateKeyPairSerializer(
+ data={
+ "name": keypair.name,
+ "certificate_data": keypair.certificate_data,
+ "key_data": keypair.key_data,
+ }
+ ).is_valid()
+ )
+ self.assertFalse(
+ CertificateKeyPairSerializer(
+ data={
+ "name": keypair.name,
+ "certificate_data": "test",
+ "key_data": "test",
+ }
+ ).is_valid()
+ )
diff --git a/passbook/flows/__init__.py b/authentik/flows/__init__.py
similarity index 100%
rename from passbook/flows/__init__.py
rename to authentik/flows/__init__.py
diff --git a/authentik/flows/api.py b/authentik/flows/api.py
new file mode 100644
index 000000000..349bfde53
--- /dev/null
+++ b/authentik/flows/api.py
@@ -0,0 +1,94 @@
+"""Flow API Views"""
+from django.core.cache import cache
+from rest_framework.serializers import ModelSerializer, SerializerMethodField
+from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
+
+from authentik.flows.models import Flow, FlowStageBinding, Stage
+from authentik.flows.planner import cache_key
+
+
+class FlowSerializer(ModelSerializer):
+ """Flow Serializer"""
+
+ cache_count = SerializerMethodField()
+
+ def get_cache_count(self, flow: Flow):
+ """Get count of cached flows"""
+ return len(cache.keys(f"{cache_key(flow)}*"))
+
+ class Meta:
+
+ model = Flow
+ fields = [
+ "pk",
+ "name",
+ "slug",
+ "title",
+ "designation",
+ "background",
+ "stages",
+ "policies",
+ "cache_count",
+ ]
+
+
+class FlowViewSet(ModelViewSet):
+ """Flow Viewset"""
+
+ queryset = Flow.objects.all()
+ serializer_class = FlowSerializer
+
+
+class FlowStageBindingSerializer(ModelSerializer):
+ """FlowStageBinding Serializer"""
+
+ class Meta:
+
+ model = FlowStageBinding
+ fields = [
+ "pk",
+ "target",
+ "stage",
+ "evaluate_on_plan",
+ "re_evaluate_policies",
+ "order",
+ "policies",
+ ]
+
+
+class FlowStageBindingViewSet(ModelViewSet):
+ """FlowStageBinding Viewset"""
+
+ queryset = FlowStageBinding.objects.all()
+ serializer_class = FlowStageBindingSerializer
+ filterset_fields = "__all__"
+
+
+class StageSerializer(ModelSerializer):
+ """Stage Serializer"""
+
+ __type__ = SerializerMethodField(method_name="get_type")
+ verbose_name = SerializerMethodField(method_name="get_verbose_name")
+
+ def get_type(self, obj: Stage) -> str:
+ """Get object type so that we know which API Endpoint to use to get the full object"""
+ return obj._meta.object_name.lower().replace("stage", "")
+
+ def get_verbose_name(self, obj: Stage) -> str:
+ """Get verbose name for UI"""
+ return obj._meta.verbose_name
+
+ class Meta:
+
+ model = Stage
+ fields = ["pk", "name", "__type__", "verbose_name"]
+
+
+class StageViewSet(ReadOnlyModelViewSet):
+ """Stage Viewset"""
+
+ queryset = Stage.objects.all()
+ serializer_class = StageSerializer
+
+ def get_queryset(self):
+ return Stage.objects.select_subclasses()
diff --git a/authentik/flows/apps.py b/authentik/flows/apps.py
new file mode 100644
index 000000000..513b3f044
--- /dev/null
+++ b/authentik/flows/apps.py
@@ -0,0 +1,16 @@
+"""authentik flows app config"""
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class AuthentikFlowsConfig(AppConfig):
+ """authentik flows app config"""
+
+ name = "authentik.flows"
+ label = "authentik_flows"
+ mountpoint = "flows/"
+ verbose_name = "authentik Flows"
+
+ def ready(self):
+ import_module("authentik.flows.signals")
diff --git a/passbook/flows/exceptions.py b/authentik/flows/exceptions.py
similarity index 100%
rename from passbook/flows/exceptions.py
rename to authentik/flows/exceptions.py
diff --git a/authentik/flows/forms.py b/authentik/flows/forms.py
new file mode 100644
index 000000000..44c5adccd
--- /dev/null
+++ b/authentik/flows/forms.py
@@ -0,0 +1,69 @@
+"""Flow and Stage forms"""
+
+from django import forms
+from django.core.validators import FileExtensionValidator
+from django.forms import ValidationError
+from django.utils.translation import gettext_lazy as _
+
+from authentik.flows.models import Flow, FlowStageBinding, Stage
+from authentik.flows.transfer.importer import FlowImporter
+from authentik.lib.widgets import GroupedModelChoiceField
+
+
+class FlowForm(forms.ModelForm):
+ """Flow Form"""
+
+ class Meta:
+
+ model = Flow
+ fields = [
+ "name",
+ "title",
+ "slug",
+ "designation",
+ "background",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "title": forms.TextInput(),
+ "background": forms.FileInput(),
+ }
+
+
+class FlowStageBindingForm(forms.ModelForm):
+ """FlowStageBinding Form"""
+
+ stage = GroupedModelChoiceField(
+ queryset=Stage.objects.all().select_subclasses(), to_field_name="stage_uuid"
+ )
+
+ class Meta:
+
+ model = FlowStageBinding
+ fields = [
+ "target",
+ "stage",
+ "evaluate_on_plan",
+ "re_evaluate_policies",
+ "order",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ }
+
+
+class FlowImportForm(forms.Form):
+ """Form used for flow importing"""
+
+ flow = forms.FileField(
+ validators=[FileExtensionValidator(allowed_extensions=["akflow"])]
+ )
+
+ def clean_flow(self):
+ """Check if the flow is valid and rewind the file to the start"""
+ flow = self.cleaned_data["flow"].read()
+ valid = FlowImporter(flow.decode()).validate()
+ if not valid:
+ raise ValidationError(_("Flow invalid."))
+ self.cleaned_data["flow"].seek(0)
+ return self.cleaned_data["flow"]
diff --git a/passbook/flows/management/__init__.py b/authentik/flows/management/__init__.py
similarity index 100%
rename from passbook/flows/management/__init__.py
rename to authentik/flows/management/__init__.py
diff --git a/passbook/flows/management/commands/__init__.py b/authentik/flows/management/commands/__init__.py
similarity index 100%
rename from passbook/flows/management/commands/__init__.py
rename to authentik/flows/management/commands/__init__.py
diff --git a/authentik/flows/management/commands/apply_flow.py b/authentik/flows/management/commands/apply_flow.py
new file mode 100644
index 000000000..349686a39
--- /dev/null
+++ b/authentik/flows/management/commands/apply_flow.py
@@ -0,0 +1,22 @@
+"""Apply flow from commandline"""
+from django.core.management.base import BaseCommand, no_translations
+
+from authentik.flows.transfer.importer import FlowImporter
+
+
+class Command(BaseCommand): # pragma: no cover
+ """Apply flow from commandline"""
+
+ @no_translations
+ def handle(self, *args, **options):
+ """Apply all flows in order, abort when one fails to import"""
+ for flow_path in options.get("flows", []):
+ with open(flow_path, "r") as flow_file:
+ importer = FlowImporter(flow_file.read())
+ valid = importer.validate()
+ if not valid:
+ raise ValueError("Flow invalid")
+ importer.apply()
+
+ def add_arguments(self, parser):
+ parser.add_argument("flows", nargs="+", type=str)
diff --git a/authentik/flows/management/commands/benchmark.py b/authentik/flows/management/commands/benchmark.py
new file mode 100644
index 000000000..f6d963f89
--- /dev/null
+++ b/authentik/flows/management/commands/benchmark.py
@@ -0,0 +1,117 @@
+"""authentik benchmark command"""
+from csv import DictWriter
+from multiprocessing import Manager, Process, cpu_count
+from sys import stdout
+from time import time
+
+from django import db
+from django.core.management.base import BaseCommand
+from django.test import RequestFactory
+from structlog import get_logger
+
+from authentik import __version__
+from authentik.core.models import User
+from authentik.flows.models import Flow
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
+
+LOGGER = get_logger()
+
+
+class FlowPlanProcess(Process): # pragma: no cover
+ """Test process which executes flow planner"""
+
+ def __init__(self, index, return_dict, flow, user) -> None:
+ super().__init__()
+ self.index = index
+ self.return_dict = return_dict
+ self.flow = flow
+ self.user = user
+ self.request = RequestFactory().get("/")
+
+ def run(self):
+ print(f"Proc {self.index} Running")
+
+ def test_inner():
+ planner = FlowPlanner(self.flow)
+ planner.use_cache = False
+ planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: self.user})
+
+ diffs = []
+ for _ in range(1000):
+ start = time()
+ test_inner()
+ end = time()
+ diffs.append(end - start)
+ self.return_dict[self.index] = diffs
+
+
+class Command(BaseCommand): # pragma: no cover
+ """Benchmark authentik"""
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "-p",
+ "--processes",
+ default=cpu_count(),
+ action="store",
+ help="How many processes should be started.",
+ )
+ parser.add_argument(
+ "--csv",
+ action="store_true",
+ help="Output results as CSV",
+ )
+
+ def benchmark_flows(self, proc_count):
+ """Get full recovery link"""
+ flow = Flow.objects.get(slug="default-authentication-flow")
+ user = User.objects.get(username="akadmin")
+ manager = Manager()
+ return_dict = manager.dict()
+
+ jobs = []
+ db.connections.close_all()
+ for i in range(proc_count):
+ proc = FlowPlanProcess(i, return_dict, flow, user)
+ jobs.append(proc)
+ proc.start()
+
+ for proc in jobs:
+ proc.join()
+ return return_dict.values()
+
+ def handle(self, *args, **options):
+ """Start benchmark"""
+ proc_count = options.get("processes", 1)
+ all_values = self.benchmark_flows(proc_count)
+ if options.get("csv"):
+ self.output_csv(all_values)
+ else:
+ self.output_overview(all_values)
+
+ def output_overview(self, values):
+ """Output results human readable"""
+ total_max: int = max([max(inner) for inner in values])
+ total_min: int = min([min(inner) for inner in values])
+ total_avg = sum([sum(inner) for inner in values]) / sum(
+ [len(inner) for inner in values]
+ )
+
+ print(f"Version: {__version__}")
+ print(f"Processes: {len(values)}")
+ print(f"\tMax: {total_max * 100}ms")
+ print(f"\tMin: {total_min * 100}ms")
+ print(f"\tAvg: {total_avg * 100}ms")
+
+ def output_csv(self, values):
+ """Output results as CSV"""
+ proc_count = len(values)
+ fieldnames = [f"proc_{idx}" for idx in range(proc_count)]
+ writer = DictWriter(stdout, fieldnames=fieldnames)
+
+ writer.writeheader()
+ for run_idx in range(len(values[0])):
+ row_dict = {}
+ for proc_idx in range(proc_count):
+ row_dict[f"proc_{proc_idx}"] = values[proc_idx][run_idx] * 100
+ writer.writerow(row_dict)
diff --git a/authentik/flows/markers.py b/authentik/flows/markers.py
new file mode 100644
index 000000000..e20f7af93
--- /dev/null
+++ b/authentik/flows/markers.py
@@ -0,0 +1,57 @@
+"""Stage Markers"""
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Optional
+
+from django.http.request import HttpRequest
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.flows.models import Stage
+from authentik.policies.engine import PolicyEngine
+from authentik.policies.models import PolicyBinding
+
+if TYPE_CHECKING:
+ from authentik.flows.planner import FlowPlan
+
+LOGGER = get_logger()
+
+
+@dataclass
+class StageMarker:
+ """Base stage marker class, no extra attributes, and has no special handler."""
+
+ # pylint: disable=unused-argument
+ def process(
+ self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest]
+ ) -> Optional[Stage]:
+ """Process callback for this marker. This should be overridden by sub-classes.
+ If a stage should be removed, return None."""
+ return stage
+
+
+@dataclass
+class ReevaluateMarker(StageMarker):
+ """Reevaluate Marker, forces stage's policies to be evaluated again."""
+
+ binding: PolicyBinding
+ user: User
+
+ def process(
+ self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest]
+ ) -> Optional[Stage]:
+ """Re-evaluate policies bound to stage, and if they fail, remove from plan"""
+ engine = PolicyEngine(self.binding, self.user)
+ engine.use_cache = False
+ if http_request:
+ engine.request.http_request = http_request
+ engine.request.context = plan.context
+ engine.build()
+ result = engine.result
+ if result.passing:
+ return stage
+ LOGGER.warning(
+ "f(plan_inst)[re-eval marker]: stage failed re-evaluation",
+ stage=stage,
+ messages=result.messages,
+ )
+ return None
diff --git a/authentik/flows/migrations/0001_initial.py b/authentik/flows/migrations/0001_initial.py
new file mode 100644
index 000000000..297f09c70
--- /dev/null
+++ b/authentik/flows/migrations/0001_initial.py
@@ -0,0 +1,138 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:07
+
+import uuid
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_policies", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Flow",
+ fields=[
+ (
+ "flow_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("name", models.TextField()),
+ ("slug", models.SlugField(unique=True)),
+ (
+ "designation",
+ models.CharField(
+ choices=[
+ ("authentication", "Authentication"),
+ ("invalidation", "Invalidation"),
+ ("enrollment", "Enrollment"),
+ ("unenrollment", "Unrenollment"),
+ ("recovery", "Recovery"),
+ ("password_change", "Password Change"),
+ ],
+ max_length=100,
+ ),
+ ),
+ (
+ "pbm",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ related_name="+",
+ to="authentik_policies.PolicyBindingModel",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Flow",
+ "verbose_name_plural": "Flows",
+ },
+ bases=("authentik_policies.policybindingmodel",),
+ ),
+ migrations.CreateModel(
+ name="Stage",
+ fields=[
+ (
+ "stage_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("name", models.TextField()),
+ ],
+ ),
+ migrations.CreateModel(
+ name="FlowStageBinding",
+ fields=[
+ (
+ "policybindingmodel_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ to="authentik_policies.PolicyBindingModel",
+ ),
+ ),
+ (
+ "fsb_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ (
+ "re_evaluate_policies",
+ models.BooleanField(
+ default=False,
+ help_text="When this option is enabled, the planner will re-evaluate policies bound to this.",
+ ),
+ ),
+ ("order", models.IntegerField()),
+ (
+ "flow",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="authentik_flows.Flow",
+ ),
+ ),
+ (
+ "stage",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Flow Stage Binding",
+ "verbose_name_plural": "Flow Stage Bindings",
+ "ordering": ["order", "flow"],
+ "unique_together": {("flow", "stage", "order")},
+ },
+ bases=("authentik_policies.policybindingmodel",),
+ ),
+ migrations.AddField(
+ model_name="flow",
+ name="stages",
+ field=models.ManyToManyField(
+ blank=True,
+ through="authentik_flows.FlowStageBinding",
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ]
diff --git a/authentik/flows/migrations/0003_auto_20200523_1133.py b/authentik/flows/migrations/0003_auto_20200523_1133.py
new file mode 100644
index 000000000..ef4388351
--- /dev/null
+++ b/authentik/flows/migrations/0003_auto_20200523_1133.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.0.6 on 2020-05-23 11:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="flow",
+ name="designation",
+ field=models.CharField(
+ choices=[
+ ("authentication", "Authentication"),
+ ("authorization", "Authorization"),
+ ("invalidation", "Invalidation"),
+ ("enrollment", "Enrollment"),
+ ("unenrollment", "Unrenollment"),
+ ("recovery", "Recovery"),
+ ("password_change", "Password Change"),
+ ],
+ max_length=100,
+ ),
+ ),
+ ]
diff --git a/authentik/flows/migrations/0006_auto_20200629_0857.py b/authentik/flows/migrations/0006_auto_20200629_0857.py
new file mode 100644
index 000000000..2278bbf79
--- /dev/null
+++ b/authentik/flows/migrations/0006_auto_20200629_0857.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.0.7 on 2020-06-29 08:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0003_auto_20200523_1133"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="flow",
+ name="designation",
+ field=models.CharField(
+ choices=[
+ ("authentication", "Authentication"),
+ ("authorization", "Authorization"),
+ ("invalidation", "Invalidation"),
+ ("enrollment", "Enrollment"),
+ ("unenrollment", "Unrenollment"),
+ ("recovery", "Recovery"),
+ ("stage_setup", "Stage Setup"),
+ ],
+ max_length=100,
+ ),
+ ),
+ ]
diff --git a/authentik/flows/migrations/0007_auto_20200703_2059.py b/authentik/flows/migrations/0007_auto_20200703_2059.py
new file mode 100644
index 000000000..220d1cc24
--- /dev/null
+++ b/authentik/flows/migrations/0007_auto_20200703_2059.py
@@ -0,0 +1,47 @@
+# Generated by Django 3.0.7 on 2020-07-03 20:59
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_policies", "0002_auto_20200528_1647"),
+ ("authentik_flows", "0006_auto_20200629_0857"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="flowstagebinding",
+ options={
+ "ordering": ["order", "target"],
+ "verbose_name": "Flow Stage Binding",
+ "verbose_name_plural": "Flow Stage Bindings",
+ },
+ ),
+ migrations.RenameField(
+ model_name="flowstagebinding",
+ old_name="flow",
+ new_name="target",
+ ),
+ migrations.RenameField(
+ model_name="flow",
+ old_name="pbm",
+ new_name="policybindingmodel_ptr",
+ ),
+ migrations.AlterUniqueTogether(
+ name="flowstagebinding",
+ unique_together={("target", "stage", "order")},
+ ),
+ migrations.AlterField(
+ model_name="flow",
+ name="policybindingmodel_ptr",
+ field=models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ to="authentik_policies.PolicyBindingModel",
+ ),
+ ),
+ ]
diff --git a/authentik/flows/migrations/0008_default_flows.py b/authentik/flows/migrations/0008_default_flows.py
new file mode 100644
index 000000000..d903d8b4e
--- /dev/null
+++ b/authentik/flows/migrations/0008_default_flows.py
@@ -0,0 +1,113 @@
+# Generated by Django 3.0.3 on 2020-05-08 14:30
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.flows.models import FlowDesignation
+from authentik.stages.identification.models import Templates, UserFields
+
+
+def create_default_authentication_flow(
+ apps: Apps, schema_editor: BaseDatabaseSchemaEditor
+):
+ Flow = apps.get_model("authentik_flows", "Flow")
+ FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
+ PasswordStage = apps.get_model("authentik_stages_password", "PasswordStage")
+ UserLoginStage = apps.get_model("authentik_stages_user_login", "UserLoginStage")
+ IdentificationStage = apps.get_model(
+ "authentik_stages_identification", "IdentificationStage"
+ )
+ db_alias = schema_editor.connection.alias
+
+ identification_stage, _ = IdentificationStage.objects.using(
+ db_alias
+ ).update_or_create(
+ name="default-authentication-identification",
+ defaults={
+ "user_fields": [UserFields.E_MAIL, UserFields.USERNAME],
+ "template": Templates.DEFAULT_LOGIN,
+ },
+ )
+
+ password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create(
+ name="default-authentication-password",
+ defaults={"backends": ["django.contrib.auth.backends.ModelBackend"]},
+ )
+
+ login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create(
+ name="default-authentication-login"
+ )
+
+ flow, _ = Flow.objects.using(db_alias).update_or_create(
+ slug="default-authentication-flow",
+ designation=FlowDesignation.AUTHENTICATION,
+ defaults={
+ "name": "Welcome to authentik!",
+ },
+ )
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow,
+ stage=identification_stage,
+ defaults={
+ "order": 0,
+ },
+ )
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow,
+ stage=password_stage,
+ defaults={
+ "order": 1,
+ },
+ )
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow,
+ stage=login_stage,
+ defaults={
+ "order": 2,
+ },
+ )
+
+
+def create_default_invalidation_flow(
+ apps: Apps, schema_editor: BaseDatabaseSchemaEditor
+):
+ Flow = apps.get_model("authentik_flows", "Flow")
+ FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
+ UserLogoutStage = apps.get_model("authentik_stages_user_logout", "UserLogoutStage")
+ db_alias = schema_editor.connection.alias
+
+ UserLogoutStage.objects.using(db_alias).update_or_create(
+ name="default-invalidation-logout"
+ )
+
+ flow, _ = Flow.objects.using(db_alias).update_or_create(
+ slug="default-invalidation-flow",
+ designation=FlowDesignation.INVALIDATION,
+ defaults={
+ "name": "Logout",
+ },
+ )
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow,
+ stage=UserLogoutStage.objects.using(db_alias).first(),
+ defaults={
+ "order": 0,
+ },
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0007_auto_20200703_2059"),
+ ("authentik_stages_user_login", "0001_initial"),
+ ("authentik_stages_user_logout", "0001_initial"),
+ ("authentik_stages_password", "0001_initial"),
+ ("authentik_stages_identification", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_default_authentication_flow),
+ migrations.RunPython(create_default_invalidation_flow),
+ ]
diff --git a/authentik/flows/migrations/0009_source_flows.py b/authentik/flows/migrations/0009_source_flows.py
new file mode 100644
index 000000000..d441ceeb9
--- /dev/null
+++ b/authentik/flows/migrations/0009_source_flows.py
@@ -0,0 +1,158 @@
+# Generated by Django 3.0.6 on 2020-05-23 15:47
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.flows.models import FlowDesignation
+from authentik.stages.prompt.models import FieldTypes
+
+FLOW_POLICY_EXPRESSION = """# This policy ensures that this flow can only be used when the user
+# is in a SSO Flow (meaning they come from an external IdP)
+return ak_is_sso_flow"""
+PROMPT_POLICY_EXPRESSION = """# Check if we've not been given a username by the external IdP
+# and trigger the enrollment flow
+return 'username' not in context.get('prompt_data', {})"""
+
+
+def create_default_source_enrollment_flow(
+ apps: Apps, schema_editor: BaseDatabaseSchemaEditor
+):
+ Flow = apps.get_model("authentik_flows", "Flow")
+ FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
+ PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
+
+ ExpressionPolicy = apps.get_model(
+ "authentik_policies_expression", "ExpressionPolicy"
+ )
+
+ PromptStage = apps.get_model("authentik_stages_prompt", "PromptStage")
+ Prompt = apps.get_model("authentik_stages_prompt", "Prompt")
+ UserWriteStage = apps.get_model("authentik_stages_user_write", "UserWriteStage")
+ UserLoginStage = apps.get_model("authentik_stages_user_login", "UserLoginStage")
+
+ db_alias = schema_editor.connection.alias
+
+ # Create a policy that only allows this flow when doing an SSO Request
+ flow_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
+ name="default-source-enrollment-if-sso",
+ defaults={"expression": FLOW_POLICY_EXPRESSION},
+ )
+
+ # This creates a Flow used by sources to enroll users
+ # It makes sure that a username is set, and if not, prompts the user for a Username
+ flow, _ = Flow.objects.using(db_alias).update_or_create(
+ slug="default-source-enrollment",
+ designation=FlowDesignation.ENROLLMENT,
+ defaults={
+ "name": "Welcome to authentik!",
+ },
+ )
+ PolicyBinding.objects.using(db_alias).update_or_create(
+ policy=flow_policy, target=flow, defaults={"order": 0}
+ )
+
+ # PromptStage to ask user for their username
+ prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create(
+ name="Welcome to authentik! Please select a username.",
+ )
+ prompt, _ = Prompt.objects.using(db_alias).update_or_create(
+ field_key="username",
+ defaults={
+ "label": "Username",
+ "type": FieldTypes.TEXT,
+ "required": True,
+ "placeholder": "Username",
+ },
+ )
+ prompt_stage.fields.add(prompt)
+
+ # Policy to only trigger prompt when no username is given
+ prompt_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
+ name="default-source-enrollment-if-username",
+ defaults={"expression": PROMPT_POLICY_EXPRESSION},
+ )
+
+ # UserWrite stage to create the user, and login stage to log user in
+ user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create(
+ name="default-source-enrollment-write"
+ )
+ user_login, _ = UserLoginStage.objects.using(db_alias).update_or_create(
+ name="default-source-enrollment-login"
+ )
+
+ binding, _ = FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow,
+ stage=prompt_stage,
+ defaults={"order": 0, "re_evaluate_policies": True},
+ )
+ PolicyBinding.objects.using(db_alias).update_or_create(
+ policy=prompt_policy, target=binding, defaults={"order": 0}
+ )
+
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow, stage=user_write, defaults={"order": 1}
+ )
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow, stage=user_login, defaults={"order": 2}
+ )
+
+
+def create_default_source_authentication_flow(
+ apps: Apps, schema_editor: BaseDatabaseSchemaEditor
+):
+ Flow = apps.get_model("authentik_flows", "Flow")
+ FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
+ PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
+
+ ExpressionPolicy = apps.get_model(
+ "authentik_policies_expression", "ExpressionPolicy"
+ )
+
+ UserLoginStage = apps.get_model("authentik_stages_user_login", "UserLoginStage")
+
+ db_alias = schema_editor.connection.alias
+
+ # Create a policy that only allows this flow when doing an SSO Request
+ flow_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
+ name="default-source-authentication-if-sso",
+ defaults={
+ "expression": FLOW_POLICY_EXPRESSION,
+ },
+ )
+
+ # This creates a Flow used by sources to authenticate users
+ flow, _ = Flow.objects.using(db_alias).update_or_create(
+ slug="default-source-authentication",
+ designation=FlowDesignation.AUTHENTICATION,
+ defaults={
+ "name": "Welcome to authentik!",
+ },
+ )
+ PolicyBinding.objects.using(db_alias).update_or_create(
+ policy=flow_policy, target=flow, defaults={"order": 0}
+ )
+
+ user_login, _ = UserLoginStage.objects.using(db_alias).update_or_create(
+ name="default-source-authentication-login"
+ )
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow, stage=user_login, defaults={"order": 0}
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0008_default_flows"),
+ ("authentik_policies", "0001_initial"),
+ ("authentik_policies_expression", "0001_initial"),
+ ("authentik_stages_prompt", "0001_initial"),
+ ("authentik_stages_user_write", "0001_initial"),
+ ("authentik_stages_user_login", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_default_source_enrollment_flow),
+ migrations.RunPython(create_default_source_authentication_flow),
+ ]
diff --git a/authentik/flows/migrations/0010_provider_flows.py b/authentik/flows/migrations/0010_provider_flows.py
new file mode 100644
index 000000000..b80e11b6f
--- /dev/null
+++ b/authentik/flows/migrations/0010_provider_flows.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.0.6 on 2020-05-24 11:34
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.flows.models import FlowDesignation
+
+
+def create_default_provider_authorization_flow(
+ apps: Apps, schema_editor: BaseDatabaseSchemaEditor
+):
+ Flow = apps.get_model("authentik_flows", "Flow")
+ FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
+
+ ConsentStage = apps.get_model("authentik_stages_consent", "ConsentStage")
+
+ db_alias = schema_editor.connection.alias
+
+ # Empty flow for providers where consent is implicitly given
+ Flow.objects.using(db_alias).update_or_create(
+ slug="default-provider-authorization-implicit-consent",
+ designation=FlowDesignation.AUTHORIZATION,
+ defaults={"name": "Authorize Application"},
+ )
+
+ # Flow with consent form to obtain explicit user consent
+ flow, _ = Flow.objects.using(db_alias).update_or_create(
+ slug="default-provider-authorization-explicit-consent",
+ designation=FlowDesignation.AUTHORIZATION,
+ defaults={"name": "Authorize Application"},
+ )
+ stage, _ = ConsentStage.objects.using(db_alias).update_or_create(
+ name="default-provider-authorization-consent"
+ )
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow, stage=stage, defaults={"order": 0}
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0009_source_flows"),
+ ("authentik_stages_consent", "0001_initial"),
+ ]
+
+ operations = [migrations.RunPython(create_default_provider_authorization_flow)]
diff --git a/authentik/flows/migrations/0011_flow_title.py b/authentik/flows/migrations/0011_flow_title.py
new file mode 100644
index 000000000..06f89d86d
--- /dev/null
+++ b/authentik/flows/migrations/0011_flow_title.py
@@ -0,0 +1,54 @@
+# Generated by Django 3.1 on 2020-08-28 13:14
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def add_title_for_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ slug_title_map = {
+ "default-authentication-flow": "Welcome to authentik!",
+ "default-invalidation-flow": "Default Invalidation Flow",
+ "default-source-enrollment": "Welcome to authentik!",
+ "default-source-authentication": "Welcome to authentik!",
+ "default-provider-authorization-implicit-consent": "Default Provider Authorization Flow (implicit consent)",
+ "default-provider-authorization-explicit-consent": "Default Provider Authorization Flow (explicit consent)",
+ "default-password-change": "Change password",
+ }
+ db_alias = schema_editor.connection.alias
+ Flow = apps.get_model("authentik_flows", "Flow")
+ for flow in Flow.objects.using(db_alias).all():
+ if flow.slug in slug_title_map:
+ flow.title = slug_title_map[flow.slug]
+ else:
+ flow.title = flow.name
+ flow.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0010_provider_flows"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="flow",
+ options={
+ "permissions": [("export_flow", "Can export a Flow")],
+ "verbose_name": "Flow",
+ "verbose_name_plural": "Flows",
+ },
+ ),
+ migrations.AddField(
+ model_name="flow",
+ name="title",
+ field=models.TextField(default="", blank=True),
+ preserve_default=False,
+ ),
+ migrations.RunPython(add_title_for_defaults),
+ migrations.AlterField(
+ model_name="flow",
+ name="title",
+ field=models.TextField(),
+ ),
+ ]
diff --git a/authentik/flows/migrations/0012_auto_20200908_1542.py b/authentik/flows/migrations/0012_auto_20200908_1542.py
new file mode 100644
index 000000000..2f19ab807
--- /dev/null
+++ b/authentik/flows/migrations/0012_auto_20200908_1542.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.1.1 on 2020-09-08 15:42
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import authentik.lib.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0011_flow_title"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="flowstagebinding",
+ name="stage",
+ field=authentik.lib.models.InheritanceForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="authentik_flows.stage"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="stage",
+ name="name",
+ field=models.TextField(unique=True),
+ ),
+ ]
diff --git a/authentik/flows/migrations/0013_auto_20200924_1605.py b/authentik/flows/migrations/0013_auto_20200924_1605.py
new file mode 100644
index 000000000..138992a7a
--- /dev/null
+++ b/authentik/flows/migrations/0013_auto_20200924_1605.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.1.1 on 2020-09-24 16:05
+
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.flows.models import FlowDesignation
+
+
+def update_flow_designation(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ Flow = apps.get_model("authentik_flows", "Flow")
+ db_alias = schema_editor.connection.alias
+
+ for flow in Flow.objects.using(db_alias).all():
+ if flow.designation == "stage_setup":
+ flow.designation = FlowDesignation.STAGE_CONFIGURATION
+ flow.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0012_auto_20200908_1542"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="flow",
+ name="designation",
+ field=models.CharField(
+ choices=[
+ ("authentication", "Authentication"),
+ ("authorization", "Authorization"),
+ ("invalidation", "Invalidation"),
+ ("enrollment", "Enrollment"),
+ ("unenrollment", "Unrenollment"),
+ ("recovery", "Recovery"),
+ ("stage_configuration", "Stage Configuration"),
+ ],
+ max_length=100,
+ ),
+ ),
+ migrations.RunPython(update_flow_designation),
+ ]
diff --git a/authentik/flows/migrations/0014_auto_20200925_2332.py b/authentik/flows/migrations/0014_auto_20200925_2332.py
new file mode 100644
index 000000000..15f7f4d5f
--- /dev/null
+++ b/authentik/flows/migrations/0014_auto_20200925_2332.py
@@ -0,0 +1,51 @@
+# Generated by Django 3.1.1 on 2020-09-25 23:32
+
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+# First stage for default-source-enrollment flow (prompt stage)
+# needs to have its policy re-evaluated
+def update_default_source_enrollment_flow_binding(
+ apps: Apps, schema_editor: BaseDatabaseSchemaEditor
+):
+ Flow = apps.get_model("authentik_flows", "Flow")
+ FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
+ db_alias = schema_editor.connection.alias
+
+ flows = Flow.objects.using(db_alias).filter(slug="default-source-enrollment")
+ if not flows.exists():
+ return
+ flow = flows.first()
+
+ binding = FlowStageBinding.objects.get(target=flow, order=0)
+ binding.re_evaluate_policies = True
+ binding.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0013_auto_20200924_1605"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="flowstagebinding",
+ options={
+ "ordering": ["target", "order"],
+ "verbose_name": "Flow Stage Binding",
+ "verbose_name_plural": "Flow Stage Bindings",
+ },
+ ),
+ migrations.AlterField(
+ model_name="flowstagebinding",
+ name="re_evaluate_policies",
+ field=models.BooleanField(
+ default=False,
+ help_text="When this option is enabled, the planner will re-evaluate policies bound to this binding.",
+ ),
+ ),
+ migrations.RunPython(update_default_source_enrollment_flow_binding),
+ ]
diff --git a/authentik/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py b/authentik/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py
new file mode 100644
index 000000000..86d5d51a6
--- /dev/null
+++ b/authentik/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.1.2 on 2020-10-20 12:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0014_auto_20200925_2332"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="flowstagebinding",
+ name="re_evaluate_policies",
+ field=models.BooleanField(
+ default=False,
+ help_text="Evaluate policies when the Stage is present to the user.",
+ ),
+ ),
+ migrations.AddField(
+ model_name="flowstagebinding",
+ name="evaluate_on_plan",
+ field=models.BooleanField(
+ default=True,
+ help_text="Evaluate policies during the Flow planning process. Disable this for input-based policies.",
+ ),
+ ),
+ ]
diff --git a/authentik/flows/migrations/0016_auto_20201202_1307.py b/authentik/flows/migrations/0016_auto_20201202_1307.py
new file mode 100644
index 000000000..dfc80fc8b
--- /dev/null
+++ b/authentik/flows/migrations/0016_auto_20201202_1307.py
@@ -0,0 +1,50 @@
+# Generated by Django 3.1.3 on 2020-12-02 13:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0015_flowstagebinding_evaluate_on_plan"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="flow",
+ name="background",
+ field=models.FileField(
+ blank=True,
+ default="../static/dist/assets/images/flow_background.jpg",
+ help_text="Background shown during execution",
+ upload_to="flow-backgrounds/",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="flow",
+ name="designation",
+ field=models.CharField(
+ choices=[
+ ("authentication", "Authentication"),
+ ("authorization", "Authorization"),
+ ("invalidation", "Invalidation"),
+ ("enrollment", "Enrollment"),
+ ("unenrollment", "Unrenollment"),
+ ("recovery", "Recovery"),
+ ("stage_configuration", "Stage Configuration"),
+ ],
+ help_text="Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik.",
+ max_length=100,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="flow",
+ name="slug",
+ field=models.SlugField(help_text="Visible in the URL.", unique=True),
+ ),
+ migrations.AlterField(
+ model_name="flow",
+ name="title",
+ field=models.TextField(help_text="Shown as the Title in Flow pages."),
+ ),
+ ]
diff --git a/passbook/flows/migrations/__init__.py b/authentik/flows/migrations/__init__.py
similarity index 100%
rename from passbook/flows/migrations/__init__.py
rename to authentik/flows/migrations/__init__.py
diff --git a/authentik/flows/models.py b/authentik/flows/models.py
new file mode 100644
index 000000000..5cd69f215
--- /dev/null
+++ b/authentik/flows/models.py
@@ -0,0 +1,228 @@
+"""Flow models"""
+from typing import TYPE_CHECKING, Optional, Type
+from uuid import uuid4
+
+from django.db import models
+from django.forms import ModelForm
+from django.http import HttpRequest
+from django.utils.translation import gettext_lazy as _
+from model_utils.managers import InheritanceManager
+from rest_framework.serializers import BaseSerializer
+from structlog import get_logger
+
+from authentik.lib.models import InheritanceForeignKey, SerializerModel
+from authentik.policies.models import PolicyBindingModel
+
+if TYPE_CHECKING:
+ from authentik.flows.stage import StageView
+
+LOGGER = get_logger()
+
+
+class NotConfiguredAction(models.TextChoices):
+ """Decides how the FlowExecutor should proceed when a stage isn't configured"""
+
+ SKIP = "skip"
+ # CONFIGURE = "configure"
+
+
+class FlowDesignation(models.TextChoices):
+ """Designation of what a Flow should be used for. At a later point, this
+ should be replaced by a database entry."""
+
+ AUTHENTICATION = "authentication"
+ AUTHORIZATION = "authorization"
+ INVALIDATION = "invalidation"
+ ENROLLMENT = "enrollment"
+ UNRENOLLMENT = "unenrollment"
+ RECOVERY = "recovery"
+ STAGE_CONFIGURATION = "stage_configuration"
+
+
+class Stage(SerializerModel):
+ """Stage is an instance of a component used in a flow. This can verify the user,
+ enroll the user or offer a way of recovery"""
+
+ stage_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+
+ name = models.TextField(unique=True)
+
+ objects = InheritanceManager()
+
+ @property
+ def type(self) -> Type["StageView"]:
+ """Return StageView class that implements logic for this stage"""
+ # This is a bit of a workaround, since we can't set class methods with setattr
+ if hasattr(self, "__in_memory_type"):
+ return getattr(self, "__in_memory_type")
+ raise NotImplementedError
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ """Return Form class used to edit this object"""
+ raise NotImplementedError
+
+ @property
+ def ui_user_settings(self) -> Optional[str]:
+ """Entrypoint to integrate with User settings. Can either return None if no
+ user settings are available, or a string with the URL to fetch."""
+ return None
+
+ def __str__(self):
+ if hasattr(self, "__in_memory_type"):
+ return f"In-memory Stage {getattr(self, '__in_memory_type')}"
+ return f"Stage {self.name}"
+
+
+def in_memory_stage(view: Type["StageView"]) -> Stage:
+ """Creates an in-memory stage instance, based on a `_type` as view."""
+ stage = Stage()
+ # Because we can't pickle a locally generated function,
+ # we set the view as a separate property and reference a generic function
+ # that returns that member
+ setattr(stage, "__in_memory_type", view)
+ return stage
+
+
+class Flow(SerializerModel, PolicyBindingModel):
+ """Flow describes how a series of Stages should be executed to authenticate/enroll/recover
+ a user. Additionally, policies can be applied, to specify which users
+ have access to this flow."""
+
+ flow_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+
+ name = models.TextField()
+ slug = models.SlugField(unique=True, help_text=_("Visible in the URL."))
+
+ title = models.TextField(help_text=_("Shown as the Title in Flow pages."))
+
+ designation = models.CharField(
+ max_length=100,
+ choices=FlowDesignation.choices,
+ help_text=_(
+ (
+ "Decides what this Flow is used for. For example, the Authentication flow "
+ "is redirect to when an un-authenticated user visits authentik."
+ )
+ ),
+ )
+
+ background = models.FileField(
+ upload_to="flow-backgrounds/",
+ default="../static/dist/assets/images/flow_background.jpg",
+ blank=True,
+ help_text=_("Background shown during execution"),
+ )
+
+ stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True)
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.flows.api import FlowSerializer
+
+ return FlowSerializer
+
+ @staticmethod
+ def with_policy(request: HttpRequest, **flow_filter) -> Optional["Flow"]:
+ """Get a Flow by `**flow_filter` and check if the request from `request` can access it."""
+ from authentik.policies.engine import PolicyEngine
+
+ flows = Flow.objects.filter(**flow_filter).order_by("slug")
+ for flow in flows:
+ engine = PolicyEngine(flow, request.user, request)
+ engine.build()
+ result = engine.result
+ if result.passing:
+ LOGGER.debug("with_policy: flow passing", flow=flow)
+ return flow
+ LOGGER.warning(
+ "with_policy: flow not passing", flow=flow, messages=result.messages
+ )
+ 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})"
+
+ class Meta:
+
+ verbose_name = _("Flow")
+ verbose_name_plural = _("Flows")
+
+ permissions = [
+ ("export_flow", "Can export a Flow"),
+ ]
+
+
+class FlowStageBinding(SerializerModel, PolicyBindingModel):
+ """Relationship between Flow and Stage. Order is required and unique for
+ each flow-stage Binding. Additionally, policies can be specified, which determine if
+ this Binding applies to the current user"""
+
+ fsb_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+
+ target = models.ForeignKey("Flow", on_delete=models.CASCADE)
+ stage = InheritanceForeignKey(Stage, on_delete=models.CASCADE)
+
+ evaluate_on_plan = models.BooleanField(
+ default=True,
+ help_text=_(
+ (
+ "Evaluate policies during the Flow planning process. "
+ "Disable this for input-based policies."
+ )
+ ),
+ )
+ re_evaluate_policies = models.BooleanField(
+ default=False,
+ help_text=_("Evaluate policies when the Stage is present to the user."),
+ )
+
+ order = models.IntegerField()
+
+ objects = InheritanceManager()
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.flows.api import FlowStageBindingSerializer
+
+ return FlowStageBindingSerializer
+
+ def __str__(self) -> str:
+ return f"{self.target} #{self.order} -> {self.stage}"
+
+ class Meta:
+
+ ordering = ["target", "order"]
+
+ verbose_name = _("Flow Stage Binding")
+ verbose_name_plural = _("Flow Stage Bindings")
+ unique_together = (("target", "stage", "order"),)
+
+
+class ConfigurableStage(models.Model):
+ """Abstract base class for a Stage that can be configured by the enduser.
+ The stage should create a default flow with the configure_stage designation during
+ migration."""
+
+ configure_flow = models.ForeignKey(
+ Flow,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ help_text=_(
+ (
+ "Flow used by an authenticated user to configure this Stage. "
+ "If empty, user will not be able to configure this stage."
+ )
+ ),
+ )
+
+ class Meta:
+
+ abstract = True
diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py
new file mode 100644
index 000000000..bcc7bd9d2
--- /dev/null
+++ b/authentik/flows/planner.py
@@ -0,0 +1,201 @@
+"""Flows Planner"""
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+
+from django.core.cache import cache
+from django.http import HttpRequest
+from sentry_sdk.hub import Hub
+from sentry_sdk.tracing import Span
+from structlog import get_logger
+
+from authentik.audit.models import cleanse_dict
+from authentik.core.models import User
+from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
+from authentik.flows.markers import ReevaluateMarker, StageMarker
+from authentik.flows.models import Flow, FlowStageBinding, Stage
+from authentik.policies.engine import PolicyEngine
+
+LOGGER = get_logger()
+
+PLAN_CONTEXT_PENDING_USER = "pending_user"
+PLAN_CONTEXT_SSO = "is_sso"
+PLAN_CONTEXT_APPLICATION = "application"
+
+
+def cache_key(flow: Flow, user: Optional[User] = None) -> str:
+ """Generate Cache key for flow"""
+ prefix = f"flow_{flow.pk}"
+ if user:
+ prefix += f"#{user.pk}"
+ return prefix
+
+
+@dataclass
+class FlowPlan:
+ """This data-class is the output of a FlowPlanner. It holds a flat list
+ of all Stages that should be run."""
+
+ flow_pk: str
+
+ stages: List[Stage] = field(default_factory=list)
+ context: Dict[str, Any] = field(default_factory=dict)
+ markers: List[StageMarker] = field(default_factory=list)
+
+ def append(self, stage: Stage, marker: Optional[StageMarker] = None):
+ """Append `stage` to all stages, optionall with stage marker"""
+ self.stages.append(stage)
+ self.markers.append(marker or StageMarker())
+
+ def next(self, http_request: Optional[HttpRequest]) -> Optional[Stage]:
+ """Return next pending stage from the bottom of the list"""
+ if not self.has_stages:
+ return None
+ stage = self.stages[0]
+ marker = self.markers[0]
+
+ if marker.__class__ is not StageMarker:
+ LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker)
+ marked_stage = marker.process(self, stage, http_request)
+ if not marked_stage:
+ LOGGER.debug("f(plan_inst): marker returned none, next stage", stage=stage)
+ self.stages.remove(stage)
+ self.markers.remove(marker)
+ if not self.has_stages:
+ return None
+ # pylint: disable=not-callable
+ return self.next(http_request)
+ return marked_stage
+
+ def pop(self):
+ """Pop next pending stage from bottom of list"""
+ self.markers.pop(0)
+ self.stages.pop(0)
+
+ @property
+ def has_stages(self) -> bool:
+ """Check if there are any stages left in this plan"""
+ return len(self.markers) + len(self.stages) > 0
+
+
+class FlowPlanner:
+ """Execute all policies to plan out a flat list of all Stages
+ that should be applied."""
+
+ use_cache: bool
+ allow_empty_flows: bool
+
+ flow: Flow
+
+ def __init__(self, flow: Flow):
+ self.use_cache = True
+ self.allow_empty_flows = False
+ self.flow = flow
+
+ def plan(
+ self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
+ ) -> FlowPlan:
+ """Check each of the flows' policies, check policies for each stage with PolicyBinding
+ and return ordered list"""
+ with Hub.current.start_span(op="flow.planner.plan") as span:
+ span: Span
+ span.set_data("flow", self.flow)
+ span.set_data("request", request)
+
+ LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
+ # Bit of a workaround here, if there is a pending user set in the default context
+ # we use that user for our cache key
+ # to make sure they don't get the generic response
+ if default_context and PLAN_CONTEXT_PENDING_USER in default_context:
+ user = default_context[PLAN_CONTEXT_PENDING_USER]
+ else:
+ user = request.user
+ # First off, check the flow's direct policy bindings
+ # to make sure the user even has access to the flow
+ engine = PolicyEngine(self.flow, user, request)
+ if default_context:
+ span.set_data("default_context", cleanse_dict(default_context))
+ engine.request.context = default_context
+ engine.build()
+ result = engine.result
+ if not result.passing:
+ raise FlowNonApplicableException(result.messages)
+ # User is passing so far, check if we have a cached plan
+ cached_plan_key = cache_key(self.flow, user)
+ cached_plan = cache.get(cached_plan_key, None)
+ if cached_plan and self.use_cache:
+ LOGGER.debug(
+ "f(plan): Taking plan from cache",
+ flow=self.flow,
+ key=cached_plan_key,
+ )
+ # Reset the context as this isn't factored into caching
+ cached_plan.context = default_context or {}
+ return cached_plan
+ LOGGER.debug("f(plan): building plan", flow=self.flow)
+ plan = self._build_plan(user, request, default_context)
+ cache.set(cache_key(self.flow, user), plan)
+ if not plan.stages and not self.allow_empty_flows:
+ raise EmptyFlowException()
+ return plan
+
+ def _build_plan(
+ self,
+ user: User,
+ request: HttpRequest,
+ default_context: Optional[Dict[str, Any]],
+ ) -> 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:
+ span: Span
+ span.set_data("flow", self.flow)
+ span.set_data("user", user)
+ span.set_data("request", request)
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex)
+ if default_context:
+ plan.context = default_context
+ # Check Flow policies
+ for binding in FlowStageBinding.objects.filter(
+ target__pk=self.flow.pk
+ ).order_by("order"):
+ binding: FlowStageBinding
+ stage = binding.stage
+ marker = StageMarker()
+ if binding.evaluate_on_plan:
+ LOGGER.debug(
+ "f(plan): evaluating on plan",
+ stage=binding.stage,
+ flow=self.flow,
+ )
+ engine = PolicyEngine(binding, user, request)
+ engine.request.context = plan.context
+ engine.build()
+ if engine.passing:
+ LOGGER.debug(
+ "f(plan): Stage passing",
+ stage=binding.stage,
+ flow=self.flow,
+ )
+ else:
+ stage = None
+ else:
+ LOGGER.debug(
+ "f(plan): not evaluating on plan",
+ stage=binding.stage,
+ flow=self.flow,
+ )
+ if binding.re_evaluate_policies and stage:
+ LOGGER.debug(
+ "f(plan): Stage has re-evaluate marker",
+ stage=binding.stage,
+ flow=self.flow,
+ )
+ marker = ReevaluateMarker(binding=binding, user=user)
+ if stage:
+ plan.append(stage, marker)
+ LOGGER.debug(
+ "f(plan): Finished building",
+ flow=self.flow,
+ )
+ return plan
diff --git a/authentik/flows/signals.py b/authentik/flows/signals.py
new file mode 100644
index 000000000..c1c0c5275
--- /dev/null
+++ b/authentik/flows/signals.py
@@ -0,0 +1,37 @@
+"""authentik flow signals"""
+from django.core.cache import cache
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from structlog import get_logger
+
+LOGGER = get_logger()
+
+
+def delete_cache_prefix(prefix: str) -> int:
+ """Delete keys prefixed with `prefix` and return count of deleted keys."""
+ keys = cache.keys(prefix)
+ cache.delete_many(keys)
+ return len(keys)
+
+
+@receiver(post_save)
+# pylint: disable=unused-argument
+def invalidate_flow_cache(sender, instance, **_):
+ """Invalidate flow cache when flow is updated"""
+ from authentik.flows.models import Flow, FlowStageBinding, Stage
+ from authentik.flows.planner import cache_key
+
+ if isinstance(instance, Flow):
+ total = delete_cache_prefix(f"{cache_key(instance)}*")
+ LOGGER.debug("Invalidating Flow cache", flow=instance, len=total)
+ if isinstance(instance, FlowStageBinding):
+ total = delete_cache_prefix(f"{cache_key(instance.target)}*")
+ LOGGER.debug(
+ "Invalidating Flow cache from FlowStageBinding", binding=instance, len=total
+ )
+ if isinstance(instance, Stage):
+ total = 0
+ for binding in FlowStageBinding.objects.filter(stage=instance):
+ prefix = cache_key(binding.target)
+ total += delete_cache_prefix(f"{prefix}*")
+ LOGGER.debug("Invalidating Flow cache from Stage", stage=instance, len=total)
diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py
new file mode 100644
index 000000000..edef963d5
--- /dev/null
+++ b/authentik/flows/stage.py
@@ -0,0 +1,29 @@
+"""authentik stage Base view"""
+from typing import Any, Dict
+
+from django.http import HttpRequest
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import TemplateView
+
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.views import FlowExecutorView
+
+
+class StageView(TemplateView):
+ """Abstract Stage, inherits TemplateView but can be combined with FormView"""
+
+ template_name = "login/form_with_user.html"
+
+ executor: FlowExecutorView
+
+ request: HttpRequest = None
+
+ def __init__(self, executor: FlowExecutorView):
+ self.executor = executor
+
+ def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
+ kwargs["title"] = self.executor.flow.title
+ if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
+ kwargs["user"] = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ kwargs["primary_action"] = _("Continue")
+ return super().get_context_data(**kwargs)
diff --git a/authentik/flows/templates/flows/denied_shell.html b/authentik/flows/templates/flows/denied_shell.html
new file mode 100644
index 000000000..768c4b02b
--- /dev/null
+++ b/authentik/flows/templates/flows/denied_shell.html
@@ -0,0 +1,57 @@
+{% extends 'login/base.html' %}
+
+{% load static %}
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block card_title %}
+{% trans 'Permission denied' %}
+{% endblock %}
+
+{% block title %}
+{% trans 'Permission denied' %}
+{% endblock %}
+
+{% block card %}
+
+{% endblock %}
diff --git a/authentik/flows/templates/flows/error.html b/authentik/flows/templates/flows/error.html
new file mode 100644
index 000000000..1f729ca30
--- /dev/null
+++ b/authentik/flows/templates/flows/error.html
@@ -0,0 +1,22 @@
+{% load i18n %}
+
+
+
+
+
+ {% trans 'Whoops!' %}
+
+
+
+
+ {% trans 'Something went wrong! Please try again later.' %}
+
+ {% if debug %}
+
{{ tb }}{{ error }}
+ {% endif %}
+
diff --git a/authentik/flows/templates/flows/shell.html b/authentik/flows/templates/flows/shell.html
new file mode 100644
index 000000000..2d23dbe17
--- /dev/null
+++ b/authentik/flows/templates/flows/shell.html
@@ -0,0 +1,32 @@
+{% extends 'login/base_full.html' %}
+
+{% load static %}
+{% load i18n %}
+
+{% block head %}
+{{ block.super }}
+
+{% endblock %}
+
+{% block main_container %}
+
+
+{% endblock %}
diff --git a/passbook/flows/tests/__init__.py b/authentik/flows/tests/__init__.py
similarity index 100%
rename from passbook/flows/tests/__init__.py
rename to authentik/flows/tests/__init__.py
diff --git a/authentik/flows/tests/test_misc.py b/authentik/flows/tests/test_misc.py
new file mode 100644
index 000000000..7546f4c85
--- /dev/null
+++ b/authentik/flows/tests/test_misc.py
@@ -0,0 +1,25 @@
+"""miscellaneous flow tests"""
+from django.test import TestCase
+
+from authentik.flows.api import StageSerializer, StageViewSet
+from authentik.flows.models import Stage
+from authentik.stages.dummy.models import DummyStage
+
+
+class TestFlowsMisc(TestCase):
+ """miscellaneous tests"""
+
+ def test_models(self):
+ """Test that ui_user_settings returns none"""
+ self.assertIsNone(Stage().ui_user_settings)
+
+ def test_api_serializer(self):
+ """Test that stage serializer returns the correct type"""
+ obj = DummyStage()
+ self.assertEqual(StageSerializer().get_type(obj), "dummy")
+ self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage")
+
+ def test_api_viewset(self):
+ """Test that stage serializer returns the correct type"""
+ dummy = DummyStage.objects.create()
+ self.assertIn(dummy, StageViewSet().get_queryset())
diff --git a/authentik/flows/tests/test_models.py b/authentik/flows/tests/test_models.py
new file mode 100644
index 000000000..864518c39
--- /dev/null
+++ b/authentik/flows/tests/test_models.py
@@ -0,0 +1,31 @@
+"""flow model tests"""
+from typing import Callable, Type
+
+from django.forms import ModelForm
+from django.test import TestCase
+
+from authentik.flows.models import Stage
+from authentik.flows.stage import StageView
+
+
+class TestStageProperties(TestCase):
+ """Generic model properties tests"""
+
+
+def stage_tester_factory(model: Type[Stage]) -> Callable:
+ """Test a form"""
+
+ def tester(self: TestStageProperties):
+ model_inst = model()
+ self.assertTrue(issubclass(model_inst.form, ModelForm))
+ self.assertTrue(issubclass(model_inst.type, StageView))
+
+ return tester
+
+
+for stage_type in Stage.__subclasses__():
+ setattr(
+ TestStageProperties,
+ f"test_stage_{stage_type.__name__}",
+ stage_tester_factory(stage_type),
+ )
diff --git a/authentik/flows/tests/test_planner.py b/authentik/flows/tests/test_planner.py
new file mode 100644
index 000000000..415f773f7
--- /dev/null
+++ b/authentik/flows/tests/test_planner.py
@@ -0,0 +1,189 @@
+"""flow planner tests"""
+from unittest.mock import MagicMock, Mock, PropertyMock, patch
+
+from django.contrib.sessions.middleware import SessionMiddleware
+from django.core.cache import cache
+from django.http import HttpRequest
+from django.shortcuts import reverse
+from django.test import RequestFactory, TestCase
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.core.models import User
+from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
+from authentik.flows.markers import ReevaluateMarker, StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
+from authentik.policies.dummy.models import DummyPolicy
+from authentik.policies.models import PolicyBinding
+from authentik.policies.types import PolicyResult
+from authentik.stages.dummy.models import DummyStage
+
+POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False))
+CACHE_MOCK = Mock(wraps=cache)
+
+POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True))
+
+
+def dummy_get_response(request: HttpRequest): # pragma: no cover
+ """Dummy get_response for SessionMiddleware"""
+ return None
+
+
+class TestFlowPlanner(TestCase):
+ """Test planner logic"""
+
+ def setUp(self):
+ self.request_factory = RequestFactory()
+
+ def test_empty_plan(self):
+ """Test that empty plan raises exception"""
+ flow = Flow.objects.create(
+ name="test-empty",
+ slug="test-empty",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ request = self.request_factory.get(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ request.user = get_anonymous_user()
+
+ with self.assertRaises(EmptyFlowException):
+ planner = FlowPlanner(flow)
+ planner.plan(request)
+
+ @patch(
+ "authentik.policies.engine.PolicyEngine.result",
+ POLICY_RETURN_FALSE,
+ )
+ def test_non_applicable_plan(self):
+ """Test that empty plan raises exception"""
+ flow = Flow.objects.create(
+ name="test-empty",
+ slug="test-empty",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ request = self.request_factory.get(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ request.user = get_anonymous_user()
+
+ with self.assertRaises(FlowNonApplicableException):
+ planner = FlowPlanner(flow)
+ planner.plan(request)
+
+ @patch("authentik.flows.planner.cache", CACHE_MOCK)
+ def test_planner_cache(self):
+ """Test planner cache"""
+ flow = Flow.objects.create(
+ name="test-cache",
+ slug="test-cache",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ FlowStageBinding.objects.create(
+ target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
+ )
+ request = self.request_factory.get(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ request.user = get_anonymous_user()
+
+ planner = FlowPlanner(flow)
+ planner.plan(request)
+ self.assertEqual(
+ CACHE_MOCK.set.call_count, 1
+ ) # Ensure plan is written to cache
+ planner = FlowPlanner(flow)
+ planner.plan(request)
+ self.assertEqual(
+ CACHE_MOCK.set.call_count, 1
+ ) # Ensure nothing is written to cache
+ self.assertEqual(CACHE_MOCK.get.call_count, 2) # Get is called twice
+
+ def test_planner_default_context(self):
+ """Test planner with default_context"""
+ flow = Flow.objects.create(
+ name="test-default-context",
+ slug="test-default-context",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ FlowStageBinding.objects.create(
+ target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
+ )
+
+ user = User.objects.create(username="test-user")
+ request = self.request_factory.get(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ request.user = user
+ planner = FlowPlanner(flow)
+ planner.plan(request, default_context={PLAN_CONTEXT_PENDING_USER: user})
+ key = cache_key(flow, user)
+ self.assertTrue(cache.get(key) is not None)
+
+ def test_planner_marker_reevaluate(self):
+ """Test that the planner creates the proper marker"""
+ flow = Flow.objects.create(
+ name="test-default-context",
+ slug="test-default-context",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+
+ FlowStageBinding.objects.create(
+ target=flow,
+ stage=DummyStage.objects.create(name="dummy1"),
+ order=0,
+ re_evaluate_policies=True,
+ )
+
+ request = self.request_factory.get(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ request.user = get_anonymous_user()
+
+ planner = FlowPlanner(flow)
+ plan = planner.plan(request)
+
+ self.assertIsInstance(plan.markers[0], ReevaluateMarker)
+
+ def test_planner_reevaluate_actual(self):
+ """Test planner with re-evaluate"""
+ flow = Flow.objects.create(
+ name="test-default-context",
+ slug="test-default-context",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
+
+ binding = FlowStageBinding.objects.create(
+ target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
+ )
+ binding2 = FlowStageBinding.objects.create(
+ target=flow,
+ stage=DummyStage.objects.create(name="dummy2"),
+ order=1,
+ re_evaluate_policies=True,
+ )
+
+ PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
+
+ request = self.request_factory.get(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ request.user = get_anonymous_user()
+
+ middleware = SessionMiddleware(dummy_get_response)
+ middleware.process_request(request)
+ request.session.save()
+
+ # Here we patch the dummy policy to evaluate to true so the stage is included
+ with patch(
+ "authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE
+ ):
+ planner = FlowPlanner(flow)
+ plan = planner.plan(request)
+
+ self.assertEqual(plan.stages[0], binding.stage)
+ self.assertEqual(plan.stages[1], binding2.stage)
+
+ self.assertIsInstance(plan.markers[0], StageMarker)
+ self.assertIsInstance(plan.markers[1], ReevaluateMarker)
diff --git a/authentik/flows/tests/test_transfer.py b/authentik/flows/tests/test_transfer.py
new file mode 100644
index 000000000..f268232eb
--- /dev/null
+++ b/authentik/flows/tests/test_transfer.py
@@ -0,0 +1,136 @@
+"""Test flow transfer"""
+from json import dumps
+
+from django.test import TransactionTestCase
+
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.transfer.common import DataclassEncoder
+from authentik.flows.transfer.exporter import FlowExporter
+from authentik.flows.transfer.importer import FlowImporter, transaction_rollback
+from authentik.policies.expression.models import ExpressionPolicy
+from authentik.policies.models import PolicyBinding
+from authentik.providers.oauth2.generators import generate_client_id
+from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
+from authentik.stages.user_login.models import UserLoginStage
+
+
+class TestFlowTransfer(TransactionTestCase):
+ """Test flow transfer"""
+
+ def test_bundle_invalid_format(self):
+ """Test bundle with invalid format"""
+ importer = FlowImporter('{"version": 3}')
+ self.assertFalse(importer.validate())
+ importer = FlowImporter(
+ (
+ '{"version": 1,"entries":[{"identifiers":{},"attrs":{},'
+ '"model": "authentik_core.User"}]}'
+ )
+ )
+ self.assertFalse(importer.validate())
+
+ def test_export_validate_import(self):
+ """Test export and validate it"""
+ flow_slug = generate_client_id()
+ with transaction_rollback():
+ login_stage = UserLoginStage.objects.create(name=generate_client_id())
+
+ flow = Flow.objects.create(
+ slug=flow_slug,
+ designation=FlowDesignation.AUTHENTICATION,
+ name=generate_client_id(),
+ title=generate_client_id(),
+ )
+ FlowStageBinding.objects.update_or_create(
+ target=flow,
+ stage=login_stage,
+ order=0,
+ )
+
+ exporter = FlowExporter(flow)
+ export = exporter.export()
+ self.assertEqual(len(export.entries), 3)
+ export_json = exporter.export_to_string()
+
+ importer = FlowImporter(export_json)
+ self.assertTrue(importer.validate())
+ self.assertTrue(importer.apply())
+
+ self.assertTrue(Flow.objects.filter(slug=flow_slug).exists())
+
+ def test_export_validate_import_policies(self):
+ """Test export and validate it"""
+ flow_slug = generate_client_id()
+ stage_name = generate_client_id()
+ with transaction_rollback():
+ flow_policy = ExpressionPolicy.objects.create(
+ name=generate_client_id(),
+ expression="return True",
+ )
+ flow = Flow.objects.create(
+ slug=flow_slug,
+ designation=FlowDesignation.AUTHENTICATION,
+ name=generate_client_id(),
+ title=generate_client_id(),
+ )
+ PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0)
+
+ user_login = UserLoginStage.objects.create(name=stage_name)
+ fsb = FlowStageBinding.objects.create(
+ target=flow, stage=user_login, order=0
+ )
+ PolicyBinding.objects.create(policy=flow_policy, target=fsb, order=0)
+
+ exporter = FlowExporter(flow)
+ export = exporter.export()
+
+ export_json = dumps(export, cls=DataclassEncoder)
+
+ importer = FlowImporter(export_json)
+ self.assertTrue(importer.validate())
+ self.assertTrue(importer.apply())
+ self.assertTrue(UserLoginStage.objects.filter(name=stage_name).exists())
+ self.assertTrue(Flow.objects.filter(slug=flow_slug).exists())
+
+ def test_export_validate_import_prompt(self):
+ """Test export and validate it"""
+ with transaction_rollback():
+ # First stage fields
+ username_prompt = Prompt.objects.create(
+ field_key="username", label="Username", order=0, type=FieldTypes.TEXT
+ )
+ password = Prompt.objects.create(
+ field_key="password",
+ label="Password",
+ order=1,
+ type=FieldTypes.PASSWORD,
+ )
+ password_repeat = Prompt.objects.create(
+ field_key="password_repeat",
+ label="Password (repeat)",
+ order=2,
+ type=FieldTypes.PASSWORD,
+ )
+
+ # Stages
+ first_stage = PromptStage.objects.create(name=generate_client_id())
+ first_stage.fields.set([username_prompt, password, password_repeat])
+ first_stage.save()
+
+ flow = Flow.objects.create(
+ name=generate_client_id(),
+ slug=generate_client_id(),
+ designation=FlowDesignation.ENROLLMENT,
+ title=generate_client_id(),
+ )
+
+ FlowStageBinding.objects.create(target=flow, stage=first_stage, order=0)
+
+ exporter = FlowExporter(flow)
+ export = exporter.export()
+ export_json = dumps(export, cls=DataclassEncoder)
+
+ importer = FlowImporter(export_json)
+
+ self.assertTrue(importer.validate())
+ self.assertTrue(importer.apply())
diff --git a/authentik/flows/tests/test_transfer_docs.py b/authentik/flows/tests/test_transfer_docs.py
new file mode 100644
index 000000000..debd3a1d9
--- /dev/null
+++ b/authentik/flows/tests/test_transfer_docs.py
@@ -0,0 +1,29 @@
+"""test example flows in docs"""
+from glob import glob
+from pathlib import Path
+from typing import Callable
+
+from django.test import TransactionTestCase
+
+from authentik.flows.transfer.importer import FlowImporter
+
+
+class TestTransferDocs(TransactionTestCase):
+ """Empty class, test methods are added dynamically"""
+
+
+def pbflow_tester(file_name: str) -> Callable:
+ """This is used instead of subTest for better visibility"""
+
+ def tester(self: TestTransferDocs):
+ with open(file_name, "r") as flow_json:
+ importer = FlowImporter(flow_json.read())
+ self.assertTrue(importer.validate())
+ self.assertTrue(importer.apply())
+
+ return tester
+
+
+for flow_file in glob("website/static/flows/*.pbflow"):
+ method_name = Path(flow_file).stem.replace("-", "_").replace(".", "_")
+ setattr(TestTransferDocs, f"test_flow_{method_name}", pbflow_tester(flow_file))
diff --git a/authentik/flows/tests/test_views.py b/authentik/flows/tests/test_views.py
new file mode 100644
index 000000000..ce10a2c90
--- /dev/null
+++ b/authentik/flows/tests/test_views.py
@@ -0,0 +1,353 @@
+"""flow views tests"""
+from unittest.mock import MagicMock, PropertyMock, patch
+
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
+from authentik.flows.markers import ReevaluateMarker, StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import FlowPlan
+from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN
+from authentik.lib.config import CONFIG
+from authentik.policies.dummy.models import DummyPolicy
+from authentik.policies.http import AccessDeniedResponse
+from authentik.policies.models import PolicyBinding
+from authentik.policies.types import PolicyResult
+from authentik.stages.dummy.models import DummyStage
+
+POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False))
+POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True))
+
+
+def to_stage_response(request: HttpRequest, source: HttpResponse):
+ """Mock for to_stage_response that returns the original response, so we can check
+ inheritance and member attributes"""
+ return source
+
+
+TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response)
+
+
+class TestFlowExecutor(TestCase):
+ """Test views logic"""
+
+ def setUp(self):
+ self.client = Client()
+
+ def test_existing_plan_diff_flow(self):
+ """Check that a plan for a different flow cancels the current plan"""
+ flow = Flow.objects.create(
+ name="test-existing-plan-diff",
+ slug="test-existing-plan-diff",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ stage = DummyStage.objects.create(name="dummy")
+ plan = FlowPlan(
+ flow_pk=flow.pk.hex + "a", stages=[stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ cancel_mock = MagicMock()
+ with patch("authentik.flows.views.FlowExecutorView.cancel", cancel_mock):
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
+ ),
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(cancel_mock.call_count, 2)
+
+ @patch(
+ "authentik.flows.views.to_stage_response",
+ TO_STAGE_RESPONSE_MOCK,
+ )
+ @patch(
+ "authentik.policies.engine.PolicyEngine.result",
+ POLICY_RETURN_FALSE,
+ )
+ def test_invalid_non_applicable_flow(self):
+ """Tests that a non-applicable flow returns the correct error message"""
+ flow = Flow.objects.create(
+ name="test-non-applicable",
+ slug="test-non-applicable",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+
+ CONFIG.update_from_dict({"domain": "testserver"})
+ response = self.client.get(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response, AccessDeniedResponse)
+ self.assertInHTML(FlowNonApplicableException.__doc__, response.rendered_content)
+
+ @patch(
+ "authentik.flows.views.to_stage_response",
+ TO_STAGE_RESPONSE_MOCK,
+ )
+ def test_invalid_empty_flow(self):
+ """Tests that an empty flow returns the correct error message"""
+ flow = Flow.objects.create(
+ name="test-empty",
+ slug="test-empty",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+
+ CONFIG.update_from_dict({"domain": "testserver"})
+ response = self.client.get(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response, AccessDeniedResponse)
+ self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content)
+
+ def test_invalid_flow_redirect(self):
+ """Tests that an invalid flow still redirects"""
+ flow = Flow.objects.create(
+ name="test-empty",
+ slug="test-empty",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+
+ CONFIG.update_from_dict({"domain": "testserver"})
+ dest = "/unique-string"
+ url = reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug})
+ response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}")
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": dest},
+ )
+
+ def test_multi_stage_flow(self):
+ """Test a full flow with multiple stages"""
+ flow = Flow.objects.create(
+ name="test-full",
+ slug="test-full",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ FlowStageBinding.objects.create(
+ target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
+ )
+ FlowStageBinding.objects.create(
+ target=flow, stage=DummyStage.objects.create(name="dummy2"), order=1
+ )
+
+ exec_url = reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
+ )
+ # First Request, start planning, renders form
+ response = self.client.get(exec_url)
+ self.assertEqual(response.status_code, 200)
+ # Check that two stages are in plan
+ session = self.client.session
+ plan: FlowPlan = session[SESSION_KEY_PLAN]
+ self.assertEqual(len(plan.stages), 2)
+ # Second request, submit form, one stage left
+ response = self.client.post(exec_url)
+ # Second request redirects to the same URL
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, exec_url)
+ # Check that two stages are in plan
+ session = self.client.session
+ plan: FlowPlan = session[SESSION_KEY_PLAN]
+ self.assertEqual(len(plan.stages), 1)
+
+ def test_reevaluate_remove_last(self):
+ """Test planner with re-evaluate (last stage is removed)"""
+ flow = Flow.objects.create(
+ name="test-default-context",
+ slug="test-default-context",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
+
+ binding = FlowStageBinding.objects.create(
+ target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
+ )
+ binding2 = FlowStageBinding.objects.create(
+ target=flow,
+ stage=DummyStage.objects.create(name="dummy2"),
+ order=1,
+ re_evaluate_policies=True,
+ )
+
+ PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
+
+ # Here we patch the dummy policy to evaluate to true so the stage is included
+ with patch(
+ "authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE
+ ):
+
+ exec_url = reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
+ )
+ # First request, run the planner
+ response = self.client.get(exec_url)
+ self.assertEqual(response.status_code, 200)
+
+ plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
+
+ self.assertEqual(plan.stages[0], binding.stage)
+ self.assertEqual(plan.stages[1], binding2.stage)
+
+ self.assertIsInstance(plan.markers[0], StageMarker)
+ self.assertIsInstance(plan.markers[1], ReevaluateMarker)
+
+ # Second request, this passes the first dummy stage
+ response = self.client.post(exec_url)
+ self.assertEqual(response.status_code, 302)
+
+ # third request, this should trigger the re-evaluate
+ # We do this request without the patch, so the policy results in false
+ response = self.client.post(exec_url)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("authentik_core:shell"))
+
+ def test_reevaluate_remove_middle(self):
+ """Test planner with re-evaluate (middle stage is removed)"""
+ flow = Flow.objects.create(
+ name="test-default-context",
+ slug="test-default-context",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
+
+ binding = FlowStageBinding.objects.create(
+ target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
+ )
+ binding2 = FlowStageBinding.objects.create(
+ target=flow,
+ stage=DummyStage.objects.create(name="dummy2"),
+ order=1,
+ re_evaluate_policies=True,
+ )
+ binding3 = FlowStageBinding.objects.create(
+ target=flow, stage=DummyStage.objects.create(name="dummy3"), order=2
+ )
+
+ PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
+
+ # Here we patch the dummy policy to evaluate to true so the stage is included
+ with patch(
+ "authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE
+ ):
+
+ exec_url = reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
+ )
+ # First request, run the planner
+ response = self.client.get(exec_url)
+
+ self.assertEqual(response.status_code, 200)
+ plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
+
+ self.assertEqual(plan.stages[0], binding.stage)
+ self.assertEqual(plan.stages[1], binding2.stage)
+ self.assertEqual(plan.stages[2], binding3.stage)
+
+ self.assertIsInstance(plan.markers[0], StageMarker)
+ self.assertIsInstance(plan.markers[1], ReevaluateMarker)
+ self.assertIsInstance(plan.markers[2], StageMarker)
+
+ # Second request, this passes the first dummy stage
+ response = self.client.post(exec_url)
+ self.assertEqual(response.status_code, 302)
+
+ plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
+
+ self.assertEqual(plan.stages[0], binding2.stage)
+ self.assertEqual(plan.stages[1], binding3.stage)
+
+ self.assertIsInstance(plan.markers[0], StageMarker)
+ self.assertIsInstance(plan.markers[1], StageMarker)
+
+ # third request, this should trigger the re-evaluate
+ # We do this request without the patch, so the policy results in false
+ response = self.client.post(exec_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+
+ def test_reevaluate_remove_consecutive(self):
+ """Test planner with re-evaluate (consecutive stages are removed)"""
+ flow = Flow.objects.create(
+ name="test-default-context",
+ slug="test-default-context",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
+
+ binding = FlowStageBinding.objects.create(
+ target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
+ )
+ binding2 = FlowStageBinding.objects.create(
+ target=flow,
+ stage=DummyStage.objects.create(name="dummy2"),
+ order=1,
+ re_evaluate_policies=True,
+ )
+ binding3 = FlowStageBinding.objects.create(
+ target=flow,
+ stage=DummyStage.objects.create(name="dummy3"),
+ order=2,
+ re_evaluate_policies=True,
+ )
+ binding4 = FlowStageBinding.objects.create(
+ target=flow, stage=DummyStage.objects.create(name="dummy4"), order=2
+ )
+
+ PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
+ PolicyBinding.objects.create(policy=false_policy, target=binding3, order=0)
+
+ # Here we patch the dummy policy to evaluate to true so the stage is included
+ with patch(
+ "authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE
+ ):
+
+ exec_url = reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
+ )
+ # First request, run the planner
+ response = self.client.get(exec_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("dummy1", force_str(response.content))
+
+ plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
+
+ self.assertEqual(plan.stages[0], binding.stage)
+ self.assertEqual(plan.stages[1], binding2.stage)
+ self.assertEqual(plan.stages[2], binding3.stage)
+ self.assertEqual(plan.stages[3], binding4.stage)
+
+ self.assertIsInstance(plan.markers[0], StageMarker)
+ self.assertIsInstance(plan.markers[1], ReevaluateMarker)
+ self.assertIsInstance(plan.markers[2], ReevaluateMarker)
+ self.assertIsInstance(plan.markers[3], StageMarker)
+
+ # Second request, this passes the first dummy stage
+ response = self.client.post(exec_url)
+ self.assertEqual(response.status_code, 302)
+
+ # third request, this should trigger the re-evaluate
+ # A get request will evaluate the policies and this will return stage 4
+ # but it won't save it, hence we cant' check the plan
+ response = self.client.get(exec_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("dummy4", force_str(response.content))
+
+ # fourth request, this confirms the last stage (dummy4)
+ # We do this request without the patch, so the policy results in false
+ response = self.client.post(exec_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
diff --git a/authentik/flows/tests/test_views_helper.py b/authentik/flows/tests/test_views_helper.py
new file mode 100644
index 000000000..3a5af523f
--- /dev/null
+++ b/authentik/flows/tests/test_views_helper.py
@@ -0,0 +1,47 @@
+"""flow views tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from authentik.flows.models import Flow, FlowDesignation
+from authentik.flows.planner import FlowPlan
+from authentik.flows.views import SESSION_KEY_PLAN
+
+
+class TestHelperView(TestCase):
+ """Test helper views logic"""
+
+ def setUp(self):
+ self.client = Client()
+
+ def test_default_view(self):
+ """Test that ToDefaultFlow returns the expected URL"""
+ flow = Flow.objects.filter(
+ designation=FlowDesignation.INVALIDATION,
+ ).first()
+ response = self.client.get(
+ reverse("authentik_flows:default-invalidation"),
+ )
+ expected_url = reverse(
+ "authentik_flows:flow-executor-shell", kwargs={"flow_slug": flow.slug}
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, expected_url)
+
+ def test_default_view_invalid_plan(self):
+ """Test that ToDefaultFlow returns the expected URL (with an invalid plan)"""
+ flow = Flow.objects.filter(
+ designation=FlowDesignation.INVALIDATION,
+ ).first()
+ plan = FlowPlan(flow_pk=flow.pk.hex + "aa")
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse("authentik_flows:default-invalidation"),
+ )
+ expected_url = reverse(
+ "authentik_flows:flow-executor-shell", kwargs={"flow_slug": flow.slug}
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, expected_url)
diff --git a/passbook/flows/transfer/__init__.py b/authentik/flows/transfer/__init__.py
similarity index 100%
rename from passbook/flows/transfer/__init__.py
rename to authentik/flows/transfer/__init__.py
diff --git a/authentik/flows/transfer/common.py b/authentik/flows/transfer/common.py
new file mode 100644
index 000000000..dda3a50ea
--- /dev/null
+++ b/authentik/flows/transfer/common.py
@@ -0,0 +1,68 @@
+"""transfer common classes"""
+from dataclasses import asdict, dataclass, field, is_dataclass
+from json.encoder import JSONEncoder
+from typing import Any, Dict, List
+from uuid import UUID
+
+from authentik.lib.models import SerializerModel
+from authentik.lib.sentry import SentryIgnoredException
+
+
+def get_attrs(obj: SerializerModel) -> Dict[str, Any]:
+ """Get object's attributes via their serializer, and covert it to a normal dict"""
+ data = dict(obj.serializer(obj).data)
+ to_remove = ("policies", "stages", "pk", "background")
+ for to_remove_name in to_remove:
+ if to_remove_name in data:
+ data.pop(to_remove_name)
+ return data
+
+
+@dataclass
+class FlowBundleEntry:
+ """Single entry of a bundle"""
+
+ identifiers: Dict[str, Any]
+ model: str
+ attrs: Dict[str, Any]
+
+ @staticmethod
+ def from_model(
+ model: SerializerModel, *extra_identifier_names: str
+ ) -> "FlowBundleEntry":
+ """Convert a SerializerModel instance to a Bundle Entry"""
+ identifiers = {
+ "pk": model.pk,
+ }
+ all_attrs = get_attrs(model)
+
+ for extra_identifier_name in extra_identifier_names:
+ identifiers[extra_identifier_name] = all_attrs.pop(extra_identifier_name)
+ return FlowBundleEntry(
+ identifiers=identifiers,
+ model=f"{model._meta.app_label}.{model._meta.model_name}",
+ attrs=all_attrs,
+ )
+
+
+@dataclass
+class FlowBundle:
+ """Dataclass used for a full export"""
+
+ version: int = field(default=1)
+ entries: List[FlowBundleEntry] = field(default_factory=list)
+
+
+class DataclassEncoder(JSONEncoder):
+ """Convert FlowBundleEntry to json"""
+
+ def default(self, o):
+ if is_dataclass(o):
+ return asdict(o)
+ if isinstance(o, UUID):
+ return str(o)
+ return super().default(o)
+
+
+class EntryInvalidError(SentryIgnoredException):
+ """Error raised when an entry is invalid"""
diff --git a/authentik/flows/transfer/exporter.py b/authentik/flows/transfer/exporter.py
new file mode 100644
index 000000000..bf79f1354
--- /dev/null
+++ b/authentik/flows/transfer/exporter.py
@@ -0,0 +1,106 @@
+"""Flow exporter"""
+from json import dumps
+from typing import Iterator, List
+from uuid import UUID
+
+from django.db.models import Q
+
+from authentik.flows.models import Flow, FlowStageBinding, Stage
+from authentik.flows.transfer.common import (
+ DataclassEncoder,
+ FlowBundle,
+ FlowBundleEntry,
+)
+from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
+from authentik.stages.prompt.models import PromptStage
+
+
+class FlowExporter:
+ """Export flow with attached stages into json"""
+
+ flow: Flow
+ with_policies: bool
+ with_stage_prompts: bool
+
+ pbm_uuids: List[UUID]
+
+ def __init__(self, flow: Flow):
+ self.flow = flow
+ self.with_policies = True
+ self.with_stage_prompts = True
+
+ def _prepare_pbm(self):
+ self.pbm_uuids = [self.flow.pbm_uuid]
+ for stage_subclass in Stage.__subclasses__():
+ if issubclass(stage_subclass, PolicyBindingModel):
+ self.pbm_uuids += stage_subclass.objects.filter(
+ flow=self.flow
+ ).values_list("pbm_uuid", flat=True)
+ self.pbm_uuids += FlowStageBinding.objects.filter(target=self.flow).values_list(
+ "pbm_uuid", flat=True
+ )
+
+ def walk_stages(self) -> Iterator[FlowBundleEntry]:
+ """Convert all stages attached to self.flow into FlowBundleEntry objects"""
+ stages = (
+ Stage.objects.filter(flow=self.flow).select_related().select_subclasses()
+ )
+ for stage in stages:
+ if isinstance(stage, PromptStage):
+ pass
+ yield FlowBundleEntry.from_model(stage, "name")
+
+ def walk_stage_bindings(self) -> Iterator[FlowBundleEntry]:
+ """Convert all bindings attached to self.flow into FlowBundleEntry objects"""
+ bindings = FlowStageBinding.objects.filter(target=self.flow).select_related()
+ for binding in bindings:
+ yield FlowBundleEntry.from_model(binding, "target", "stage", "order")
+
+ def walk_policies(self) -> Iterator[FlowBundleEntry]:
+ """Walk over all policies. This is done at the beginning of the export for stages that have
+ a direct foreign key to a policy."""
+ # Special case for PromptStage as that has a direct M2M to policy, we have to ensure
+ # all policies referenced in there we also include here
+ prompt_stages = PromptStage.objects.filter(flow=self.flow).values_list(
+ "pk", flat=True
+ )
+ query = Q(bindings__in=self.pbm_uuids) | Q(promptstage__in=prompt_stages)
+ policies = Policy.objects.filter(query).select_related()
+ for policy in policies:
+ yield FlowBundleEntry.from_model(policy)
+
+ def walk_policy_bindings(self) -> Iterator[FlowBundleEntry]:
+ """Walk over all policybindings relative to us. This is run at the end of the export, as
+ we are sure all objects exist now."""
+ bindings = PolicyBinding.objects.filter(
+ target__in=self.pbm_uuids
+ ).select_related()
+ for binding in bindings:
+ yield FlowBundleEntry.from_model(binding, "policy", "target", "order")
+
+ def walk_stage_prompts(self) -> Iterator[FlowBundleEntry]:
+ """Walk over all prompts associated with any PromptStages"""
+ prompt_stages = PromptStage.objects.filter(flow=self.flow)
+ for stage in prompt_stages:
+ for prompt in stage.fields.all():
+ yield FlowBundleEntry.from_model(prompt)
+
+ def export(self) -> FlowBundle:
+ """Create a list of all objects including the flow"""
+ if self.with_policies:
+ self._prepare_pbm()
+ bundle = FlowBundle()
+ bundle.entries.append(FlowBundleEntry.from_model(self.flow, "slug"))
+ if self.with_stage_prompts:
+ bundle.entries.extend(self.walk_stage_prompts())
+ if self.with_policies:
+ bundle.entries.extend(self.walk_policies())
+ bundle.entries.extend(self.walk_stages())
+ bundle.entries.extend(self.walk_stage_bindings())
+ if self.with_policies:
+ bundle.entries.extend(self.walk_policy_bindings())
+ return bundle
+
+ def export_to_string(self) -> str:
+ """Call export and convert it to json"""
+ return dumps(self.export(), cls=DataclassEncoder)
diff --git a/authentik/flows/transfer/importer.py b/authentik/flows/transfer/importer.py
new file mode 100644
index 000000000..76e5bc2fe
--- /dev/null
+++ b/authentik/flows/transfer/importer.py
@@ -0,0 +1,179 @@
+"""Flow importer"""
+from contextlib import contextmanager
+from copy import deepcopy
+from json import loads
+from typing import Any, Dict
+
+from dacite import from_dict
+from dacite.exceptions import DaciteError
+from django.apps import apps
+from django.db import transaction
+from django.db.models import Model
+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 import BoundLogger, get_logger
+
+from authentik.flows.models import Flow, FlowStageBinding, Stage
+from authentik.flows.transfer.common import (
+ EntryInvalidError,
+ FlowBundle,
+ FlowBundleEntry,
+)
+from authentik.lib.models import SerializerModel
+from authentik.policies.models import Policy, PolicyBinding
+from authentik.stages.prompt.models import Prompt
+
+ALLOWED_MODELS = (Flow, FlowStageBinding, Stage, Policy, PolicyBinding, Prompt)
+
+
+@contextmanager
+def transaction_rollback():
+ """Enters an atomic transaction and always triggers a rollback at the end of the block."""
+ atomic = transaction.atomic()
+ atomic.__enter__()
+ yield
+ atomic.__exit__(IntegrityError, None, None)
+
+
+class FlowImporter:
+ """Import Flow from json"""
+
+ __import: FlowBundle
+
+ __pk_map: Dict[Any, Model]
+
+ logger: BoundLogger
+
+ def __init__(self, json_input: str):
+ self.logger = get_logger()
+ self.__pk_map = {}
+ import_dict = loads(json_input)
+ try:
+ self.__import = from_dict(FlowBundle, import_dict)
+ except DaciteError as exc:
+ raise EntryInvalidError from exc
+
+ def __update_pks_for_attrs(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
+ """Replace any value if it is a known primary key of an other object"""
+
+ def updater(value) -> Any:
+ if value in self.__pk_map:
+ self.logger.debug("updating reference in entry", value=value)
+ return self.__pk_map[value]
+ return value
+
+ for key, value in attrs.items():
+ if isinstance(value, dict):
+ for idx, _inner_key in enumerate(value):
+ value[_inner_key] = updater(value[_inner_key])
+ elif isinstance(value, list):
+ for idx, _inner_value in enumerate(value):
+ attrs[key][idx] = updater(_inner_value)
+ else:
+ attrs[key] = updater(value)
+ return attrs
+
+ def __query_from_identifier(self, attrs: Dict[str, Any]) -> Q:
+ """Generate an or'd query from all identifiers in an entry"""
+ # Since identifiers can also be pk-references to other objects (see FlowStageBinding)
+ # we have to ensure those references are also replaced
+ main_query = Q(pk=attrs["pk"])
+ sub_query = Q()
+ for identifier, value in attrs.items():
+ if identifier == "pk":
+ continue
+ sub_query &= Q(**{identifier: value})
+ return main_query | sub_query
+
+ def _validate_single(self, entry: FlowBundleEntry) -> BaseSerializer:
+ """Validate a single entry"""
+ model_app_label, model_name = entry.model.split(".")
+ model: SerializerModel = apps.get_model(model_app_label, model_name)
+ if not isinstance(model(), ALLOWED_MODELS):
+ raise EntryInvalidError(f"Model {model} not allowed")
+
+ # If we try to validate without referencing a possible instance
+ # we'll get a duplicate error, hence we load the model here and return
+ # the full serializer for later usage
+ # Because a model might have multiple unique columns, we chain all identifiers together
+ # to create an OR query.
+ updated_identifiers = self.__update_pks_for_attrs(entry.identifiers)
+ for key, value in list(updated_identifiers.items()):
+ if isinstance(value, dict) and "pk" in value:
+ del updated_identifiers[key]
+ updated_identifiers[f"{key}"] = value["pk"]
+ existing_models = model.objects.filter(
+ self.__query_from_identifier(updated_identifiers)
+ )
+
+ serializer_kwargs = {}
+ if existing_models.exists():
+ model_instance = existing_models.first()
+ self.logger.debug(
+ "initialise serializer with instance",
+ model=model,
+ instance=model_instance,
+ pk=model_instance.pk,
+ )
+ serializer_kwargs["instance"] = model_instance
+ else:
+ self.logger.debug(
+ "initialise new instance", model=model, **updated_identifiers
+ )
+ full_data = self.__update_pks_for_attrs(entry.attrs)
+ full_data.update(updated_identifiers)
+ serializer_kwargs["data"] = full_data
+
+ serializer: Serializer = model().serializer(**serializer_kwargs)
+ try:
+ serializer.is_valid(raise_exception=True)
+ except ValidationError as exc:
+ raise EntryInvalidError(f"Serializer errors {serializer.errors}") from exc
+ return serializer
+
+ def apply(self) -> bool:
+ """Apply (create/update) flow json, in database transaction"""
+ try:
+ with transaction.atomic():
+ if not self._apply_models():
+ self.logger.debug("Reverting changes due to error")
+ raise IntegrityError
+ except IntegrityError:
+ return False
+ else:
+ self.logger.debug("Committing changes")
+ return True
+
+ def _apply_models(self) -> bool:
+ """Apply (create/update) flow json"""
+ self.__pk_map = {}
+ entries = deepcopy(self.__import.entries)
+ for entry in entries:
+ model_app_label, model_name = entry.model.split(".")
+ model: SerializerModel = apps.get_model(model_app_label, model_name)
+ # Validate each single entry
+ try:
+ serializer = self._validate_single(entry)
+ except EntryInvalidError as exc:
+ self.logger.error("entry not valid", entry=entry, error=exc)
+ return False
+
+ model = serializer.save()
+ self.__pk_map[entry.identifiers["pk"]] = model.pk
+ self.logger.debug("updated model", model=model, pk=model.pk)
+ return True
+
+ def validate(self) -> bool:
+ """Validate loaded flow export, ensure all models are allowed
+ and serializers have no errors"""
+ self.logger.debug("Starting flow import validaton")
+ if self.__import.version != 1:
+ self.logger.warning("Invalid bundle version")
+ return False
+ with transaction_rollback():
+ successful = self._apply_models()
+ if not successful:
+ self.logger.debug("Flow validation failed")
+ return successful
diff --git a/authentik/flows/urls.py b/authentik/flows/urls.py
new file mode 100644
index 000000000..ad440097a
--- /dev/null
+++ b/authentik/flows/urls.py
@@ -0,0 +1,49 @@
+"""flow urls"""
+from django.urls import path
+
+from authentik.flows.models import FlowDesignation
+from authentik.flows.views import (
+ CancelView,
+ ConfigureFlowInitView,
+ FlowExecutorShellView,
+ FlowExecutorView,
+ ToDefaultFlow,
+)
+
+urlpatterns = [
+ path(
+ "-/default/authentication/",
+ ToDefaultFlow.as_view(designation=FlowDesignation.AUTHENTICATION),
+ name="default-authentication",
+ ),
+ path(
+ "-/default/invalidation/",
+ 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//",
+ ConfigureFlowInitView.as_view(),
+ name="configure",
+ ),
+ path("b//", FlowExecutorView.as_view(), name="flow-executor"),
+ path(
+ "/", FlowExecutorShellView.as_view(), name="flow-executor-shell"
+ ),
+]
diff --git a/authentik/flows/views.py b/authentik/flows/views.py
new file mode 100644
index 000000000..41b9dc354
--- /dev/null
+++ b/authentik/flows/views.py
@@ -0,0 +1,326 @@
+"""authentik multi-stage authentication engine"""
+from traceback import format_tb
+from typing import Any, Dict, Optional
+
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import (
+ Http404,
+ HttpRequest,
+ HttpResponse,
+ HttpResponseRedirect,
+ JsonResponse,
+)
+from django.shortcuts import get_object_or_404, redirect, reverse
+from django.template.response import TemplateResponse
+from django.utils.decorators import method_decorator
+from django.views.decorators.clickjacking import xframe_options_sameorigin
+from django.views.generic import TemplateView, View
+from structlog import get_logger
+
+from authentik.audit.models import cleanse_dict
+from authentik.core.models import USER_ATTRIBUTE_DEBUG
+from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
+from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan, FlowPlanner
+from authentik.lib.utils.reflection import class_to_path
+from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
+from authentik.policies.http import AccessDeniedResponse
+
+LOGGER = get_logger()
+# Argument used to redirect user after login
+NEXT_ARG_NAME = "next"
+SESSION_KEY_PLAN = "authentik_flows_plan"
+SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre"
+SESSION_KEY_GET = "authentik_flows_get"
+
+
+@method_decorator(xframe_options_sameorigin, name="dispatch")
+class FlowExecutorView(View):
+ """Stage 1 Flow executor, passing requests to Stage Views"""
+
+ flow: Flow
+
+ plan: Optional[FlowPlan] = None
+ current_stage: Stage
+ current_stage_view: View
+
+ def setup(self, request: HttpRequest, flow_slug: str):
+ super().setup(request, flow_slug=flow_slug)
+ self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
+
+ def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
+ """When a flow is non-applicable check if user is on the correct domain"""
+ if NEXT_ARG_NAME in self.request.GET:
+ if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)):
+ LOGGER.debug("f(exec): Redirecting to next on fail")
+ return redirect(self.request.GET.get(NEXT_ARG_NAME))
+ message = exc.__doc__ if exc.__doc__ else str(exc)
+ return self.stage_invalid(error_message=message)
+
+ def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
+ # Early check if theres an active Plan for the current session
+ if SESSION_KEY_PLAN in self.request.session:
+ self.plan = self.request.session[SESSION_KEY_PLAN]
+ if self.plan.flow_pk != self.flow.pk.hex:
+ LOGGER.warning(
+ "f(exec): Found existing plan for other flow, deleteing plan",
+ flow_slug=flow_slug,
+ )
+ # Existing plan is deleted from session and instance
+ self.plan = None
+ self.cancel()
+ LOGGER.debug("f(exec): Continuing existing plan", flow_slug=flow_slug)
+
+ # Don't check session again as we've either already loaded the plan or we need to plan
+ if not self.plan:
+ LOGGER.debug(
+ "f(exec): No active Plan found, initiating planner", flow_slug=flow_slug
+ )
+ try:
+ self.plan = self._initiate_plan()
+ except FlowNonApplicableException as exc:
+ LOGGER.warning("f(exec): Flow not applicable to current user", exc=exc)
+ return to_stage_response(self.request, self.handle_invalid_flow(exc))
+ except EmptyFlowException as exc:
+ LOGGER.warning("f(exec): Flow is empty", exc=exc)
+ return to_stage_response(self.request, self.handle_invalid_flow(exc))
+ # We don't save the Plan after getting the next stage
+ # as it hasn't been successfully passed yet
+ next_stage = self.plan.next(self.request)
+ if not next_stage:
+ LOGGER.debug("f(exec): no more stages, flow is done.")
+ return self._flow_done()
+ self.current_stage = next_stage
+ LOGGER.debug(
+ "f(exec): Current stage",
+ current_stage=self.current_stage,
+ flow_slug=self.flow.slug,
+ )
+ stage_cls = self.current_stage.type
+ self.current_stage_view = stage_cls(self)
+ self.current_stage_view.args = self.args
+ self.current_stage_view.kwargs = self.kwargs
+ self.current_stage_view.request = request
+ return super().dispatch(request)
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """pass get request to current stage"""
+ LOGGER.debug(
+ "f(exec): Passing GET",
+ view_class=class_to_path(self.current_stage_view.__class__),
+ stage=self.current_stage,
+ flow_slug=self.flow.slug,
+ )
+ try:
+ 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
+ LOGGER.exception(exc)
+ return to_stage_response(request, FlowErrorResponse(request, exc))
+
+ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """pass post request to current stage"""
+ LOGGER.debug(
+ "f(exec): Passing POST",
+ view_class=class_to_path(self.current_stage_view.__class__),
+ stage=self.current_stage,
+ flow_slug=self.flow.slug,
+ )
+ try:
+ 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
+ LOGGER.exception(exc)
+ return to_stage_response(request, FlowErrorResponse(request, exc))
+
+ def _initiate_plan(self) -> FlowPlan:
+ planner = FlowPlanner(self.flow)
+ plan = planner.plan(self.request)
+ self.request.session[SESSION_KEY_PLAN] = plan
+ return plan
+
+ def _flow_done(self) -> HttpResponse:
+ """User Successfully passed all stages"""
+ # Since this is wrapped by the ExecutorShell, the next argument is saved in the session
+ # extract the next param before cancel as that cleans it
+ next_param = self.request.session.get(SESSION_KEY_GET, {}).get(
+ NEXT_ARG_NAME, "authentik_core:shell"
+ )
+ self.cancel()
+ return redirect_with_qs(next_param)
+
+ def stage_ok(self) -> HttpResponse:
+ """Callback called by stages upon successful completion.
+ Persists updated plan and context to session."""
+ LOGGER.debug(
+ "f(exec): Stage ok",
+ stage_class=class_to_path(self.current_stage_view.__class__),
+ flow_slug=self.flow.slug,
+ )
+ self.plan.pop()
+ self.request.session[SESSION_KEY_PLAN] = self.plan
+ if self.plan.stages:
+ LOGGER.debug(
+ "f(exec): Continuing with next stage",
+ reamining=len(self.plan.stages),
+ flow_slug=self.flow.slug,
+ )
+ return redirect_with_qs(
+ "authentik_flows:flow-executor", self.request.GET, **self.kwargs
+ )
+ # User passed all stages
+ LOGGER.debug(
+ "f(exec): User passed all stages",
+ flow_slug=self.flow.slug,
+ context=cleanse_dict(self.plan.context),
+ )
+ return self._flow_done()
+
+ def stage_invalid(self, error_message: Optional[str] = None) -> HttpResponse:
+ """Callback used stage when data is correct but a policy denies access
+ or the user account is disabled.
+
+ Optionally, an exception can be passed, which will be shown if the current user
+ is a superuser."""
+ LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug)
+ self.cancel()
+ response = AccessDeniedResponse(
+ self.request, template="flows/denied_shell.html"
+ )
+ response.error_message = error_message
+ return to_stage_response(self.request, response)
+
+ def cancel(self):
+ """Cancel current execution and return a redirect"""
+ keys_to_delete = [
+ SESSION_KEY_APPLICATION_PRE,
+ SESSION_KEY_PLAN,
+ SESSION_KEY_GET,
+ ]
+ for key in keys_to_delete:
+ if key in self.request.session:
+ del self.request.session[key]
+
+
+class FlowErrorResponse(TemplateResponse):
+ """Response class when an unhandled error occurs during a stage. Normal users
+ are shown an error message, superusers are shown a full stacktrace."""
+
+ error: Exception
+
+ def __init__(self, request: HttpRequest, error: Exception) -> None:
+ # For some reason pyright complains about keyword argument usage here
+ # pyright: reportGeneralTypeIssues=false
+ super().__init__(request=request, template="flows/error.html")
+ self.error = error
+
+ def resolve_context(
+ self, context: Optional[Dict[str, Any]]
+ ) -> Optional[Dict[str, Any]]:
+ if not context:
+ context = {}
+ context["error"] = self.error
+ if self._request.user and self._request.user.is_authenticated:
+ if self._request.user.is_superuser or self._request.user.attributes.get(
+ USER_ATTRIBUTE_DEBUG, False
+ ):
+ context["tb"] = "".join(format_tb(self.error.__traceback__))
+ return context
+
+
+class FlowExecutorShellView(TemplateView):
+ """Executor Shell view, loads a dummy card with a spinner
+ that loads the next stage in the background."""
+
+ template_name = "flows/shell.html"
+
+ def get_context_data(self, **kwargs) -> Dict[str, Any]:
+ flow: Flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
+ kwargs["background_url"] = flow.background.url
+ kwargs["exec_url"] = reverse(
+ "authentik_flows:flow-executor", kwargs=self.kwargs
+ )
+ self.request.session[SESSION_KEY_GET] = self.request.GET
+ return kwargs
+
+
+class CancelView(View):
+ """View which canels the currently active plan"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ """View which canels the currently active plan"""
+ if SESSION_KEY_PLAN in request.session:
+ del request.session[SESSION_KEY_PLAN]
+ LOGGER.debug("Canceled current plan")
+ return redirect("authentik_core:shell")
+
+
+class ToDefaultFlow(View):
+ """Redirect to default flow matching by designation"""
+
+ designation: Optional[FlowDesignation] = None
+
+ def dispatch(self, request: HttpRequest) -> HttpResponse:
+ 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.
+ if SESSION_KEY_PLAN in self.request.session:
+ plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
+ if plan.flow_pk != flow.pk.hex:
+ LOGGER.warning(
+ "f(def): Found existing plan for other flow, deleteing plan",
+ flow_slug=flow.slug,
+ )
+ del self.request.session[SESSION_KEY_PLAN]
+ return redirect_with_qs(
+ "authentik_flows:flow-executor-shell", request.GET, flow_slug=flow.slug
+ )
+
+
+def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse:
+ """Convert normal HttpResponse into JSON Response"""
+ if isinstance(source, HttpResponseRedirect) or source.status_code == 302:
+ redirect_url = source["Location"]
+ if request.path != redirect_url:
+ return JsonResponse({"type": "redirect", "to": redirect_url})
+ return source
+ if isinstance(source, TemplateResponse):
+ return JsonResponse(
+ {"type": "template", "body": source.render().content.decode("utf-8")}
+ )
+ # Check for actual HttpResponse (without isinstance as we dont want to check inheritance)
+ if source.__class__ == HttpResponse:
+ return JsonResponse(
+ {"type": "template", "body": source.content.decode("utf-8")}
+ )
+ return source
+
+
+class ConfigureFlowInitView(LoginRequiredMixin, View):
+ """Initiate planner for selected change flow and redirect to flow executor,
+ or raise Http404 if no configure_flow has been set."""
+
+ def get(self, request: HttpRequest, stage_uuid: str) -> HttpResponse:
+ """Initiate planner for selected change flow and redirect to flow executor,
+ or raise Http404 if no configure_flow has been set."""
+ try:
+ stage: Stage = Stage.objects.get_subclass(pk=stage_uuid)
+ except Stage.DoesNotExist as exc:
+ raise Http404 from exc
+ if not isinstance(stage, ConfigurableStage):
+ LOGGER.debug("Stage does not inherit ConfigurableStage", stage=stage)
+ raise Http404
+ if not stage.configure_flow:
+ LOGGER.debug("Stage has no configure_flow set", stage=stage)
+ raise Http404
+
+ plan = FlowPlanner(stage.configure_flow).plan(
+ request, {PLAN_CONTEXT_PENDING_USER: request.user}
+ )
+ request.session[SESSION_KEY_PLAN] = plan
+ return redirect_with_qs(
+ "authentik_flows:flow-executor-shell",
+ self.request.GET,
+ flow_slug=stage.configure_flow.slug,
+ )
diff --git a/passbook/lib/__init__.py b/authentik/lib/__init__.py
similarity index 100%
rename from passbook/lib/__init__.py
rename to authentik/lib/__init__.py
diff --git a/authentik/lib/apps.py b/authentik/lib/apps.py
new file mode 100644
index 000000000..e155f53f7
--- /dev/null
+++ b/authentik/lib/apps.py
@@ -0,0 +1,10 @@
+"""authentik lib app config"""
+from django.apps import AppConfig
+
+
+class AuthentikLibConfig(AppConfig):
+ """authentik lib app config"""
+
+ name = "authentik.lib"
+ label = "authentik_lib"
+ verbose_name = "authentik lib"
diff --git a/authentik/lib/config.py b/authentik/lib/config.py
new file mode 100644
index 000000000..5565b34d9
--- /dev/null
+++ b/authentik/lib/config.py
@@ -0,0 +1,173 @@
+"""authentik core config loader"""
+import os
+from collections.abc import Mapping
+from contextlib import contextmanager
+from glob import glob
+from json import dumps
+from time import time
+from typing import Any, Dict
+from urllib.parse import urlparse
+
+import yaml
+from django.conf import ImproperlyConfigured
+from django.http import HttpRequest
+
+SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob(
+ "/etc/authentik/config.d/*.yml", recursive=True
+)
+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"""
+ kwargs = {"config": CONFIG.raw}
+ return kwargs
+
+
+class ConfigLoader:
+ """Search through SEARCH_PATHS and load configuration. Environment variables starting with
+ `ENV_PREFIX` are also applied.
+
+ A variable like AUTHENTIK_POSTGRESQL__HOST would translate to postgresql.host"""
+
+ loaded_file = []
+
+ __config = {}
+
+ def __init__(self):
+ super().__init__()
+ base_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "../.."))
+ for path in SEARCH_PATHS:
+ # Check if path is relative, and if so join with base_dir
+ if not os.path.isabs(path):
+ path = os.path.join(base_dir, path)
+ if os.path.isfile(path) and os.path.exists(path):
+ # Path is an existing file, so we just read it and update our config with it
+ self.update_from_file(path)
+ elif os.path.isdir(path) and os.path.exists(path):
+ # Path is an existing dir, so we try to read the env config from it
+ env_paths = [
+ os.path.join(path, ENVIRONMENT + ".yml"),
+ os.path.join(path, ENVIRONMENT + ".env.yml"),
+ ]
+ for env_file in env_paths:
+ if os.path.isfile(env_file) and os.path.exists(env_file):
+ # Update config with env file
+ self.update_from_file(env_file)
+ self.update_from_env()
+
+ def _log(self, level: str, message: str, **kwargs):
+ """Custom Log method, we want to ensure ConfigLoader always logs JSON even when
+ 'structlog' or 'logging' hasn't been configured yet."""
+ output = {
+ "event": message,
+ "level": level,
+ "logger": self.__class__.__module__,
+ "timestamp": time(),
+ }
+ output.update(kwargs)
+ print(dumps(output))
+
+ def update(self, root, updatee):
+ """Recursively update dictionary"""
+ for key, value in updatee.items():
+ if isinstance(value, Mapping):
+ root[key] = self.update(root.get(key, {}), value)
+ else:
+ if isinstance(value, str):
+ value = self.parse_uri(value)
+ root[key] = value
+ return root
+
+ def parse_uri(self, value):
+ """Parse string values which start with a URI"""
+ url = urlparse(value)
+ if url.scheme == "env":
+ value = os.getenv(url.netloc, url.query)
+ return value
+
+ def update_from_file(self, path: str):
+ """Update config from file contents"""
+ try:
+ with open(path) as file:
+ try:
+ self.update(self.__config, yaml.safe_load(file))
+ self._log("debug", "Loaded config", file=path)
+ self.loaded_file.append(path)
+ except yaml.YAMLError as exc:
+ raise ImproperlyConfigured from exc
+ except PermissionError as exc:
+ self._log(
+ "warning", "Permission denied while reading file", path=path, error=exc
+ )
+
+ def update_from_dict(self, update: dict):
+ """Update config from dict"""
+ self.__config.update(update)
+
+ def update_from_env(self):
+ """Check environment variables"""
+ outer = {}
+ idx = 0
+ for key, value in os.environ.items():
+ if not key.startswith(ENV_PREFIX):
+ continue
+ relative_key = key.replace(f"{ENV_PREFIX}_", "").replace("__", ".").lower()
+ # Recursively convert path from a.b.c into outer[a][b][c]
+ current_obj = outer
+ dot_parts = relative_key.split(".")
+ for dot_part in dot_parts[:-1]:
+ if dot_part not in current_obj:
+ current_obj[dot_part] = {}
+ current_obj = current_obj[dot_part]
+ current_obj[dot_parts[-1]] = value
+ idx += 1
+ if idx > 0:
+ self._log("debug", "Loaded environment variables", count=idx)
+ self.update(self.__config, outer)
+
+ @contextmanager
+ def patch(self, path: str, value: Any):
+ """Context manager for unittests to patch a value"""
+ original_value = self.y(path)
+ self.y_set(path, value)
+ yield
+ self.y_set(path, original_value)
+
+ @property
+ def raw(self) -> dict:
+ """Get raw config dictionary"""
+ return self.__config
+
+ # pylint: disable=invalid-name
+ def y(self, path: str, default=None, sep=".") -> Any:
+ """Access attribute by using yaml path"""
+ # Walk sub_dicts before parsing path
+ root = self.raw
+ # Walk each component of the path
+ for comp in path.split(sep):
+ if root and comp in root:
+ root = root.get(comp)
+ else:
+ return default
+ return root
+
+ def y_set(self, path: str, value: Any, sep="."):
+ """Set value using same syntax as y()"""
+ # Walk sub_dicts before parsing path
+ root = self.raw
+ # Walk each component of the path
+ path_parts = path.split(sep)
+ for comp in path_parts[:-1]:
+ if comp not in root:
+ root[comp] = {}
+ root = root.get(comp)
+ root[path_parts[-1]] = value
+
+ def y_bool(self, path: str, default=False) -> bool:
+ """Wrapper for y that converts value into boolean"""
+ return str(self.y(path, default)).lower() == "true"
+
+
+CONFIG = ConfigLoader()
diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml
new file mode 100644
index 000000000..4f9e1bd56
--- /dev/null
+++ b/authentik/lib/default.yml
@@ -0,0 +1,37 @@
+# This is the default configuration file
+postgresql:
+ host: localhost
+ name: authentik
+ user: authentik
+ password: 'env://POSTGRES_PASSWORD'
+
+redis:
+ host: localhost
+ password: ''
+ cache_db: 0
+ message_queue_db: 1
+ ws_db: 2
+
+debug: false
+log_level: info
+
+# Error reporting, sends stacktrace to sentry.beryju.org
+error_reporting:
+ enabled: false
+ environment: customer
+ send_pii: false
+
+outposts:
+ docker_image_base: "beryju/authentik" # this is prepended to -proxy:version
+
+authentik:
+ avatars: gravatar # gravatar or none
+ 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
+ href: https://goauthentik.io/docs/
+ - name: authentik Website
+ href: https://goauthentik.io/
diff --git a/passbook/lib/expression/__init__.py b/authentik/lib/expression/__init__.py
similarity index 100%
rename from passbook/lib/expression/__init__.py
rename to authentik/lib/expression/__init__.py
diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py
new file mode 100644
index 000000000..35b79b3a6
--- /dev/null
+++ b/authentik/lib/expression/evaluator.py
@@ -0,0 +1,112 @@
+"""authentik expression policy evaluator"""
+import re
+from textwrap import indent
+from typing import Any, Dict, Iterable, Optional
+
+from django.core.exceptions import ValidationError
+from requests import Session
+from sentry_sdk.hub import Hub
+from sentry_sdk.tracing import Span
+from structlog import get_logger
+
+from authentik.core.models import User
+
+LOGGER = get_logger()
+
+
+class BaseEvaluator:
+ """Validate and evaluate python-based expressions"""
+
+ # Globals that can be used by function
+ _globals: Dict[str, Any]
+ # Context passed as locals to exec()
+ _context: Dict[str, Any]
+
+ # Filename used for exec
+ _filename: str
+
+ def __init__(self):
+ # update authentik/policies/expression/templates/policy/expression/form.html
+ # update website/docs/policies/expression.md
+ self._globals = {
+ "regex_match": BaseEvaluator.expr_filter_regex_match,
+ "regex_replace": BaseEvaluator.expr_filter_regex_replace,
+ "ak_is_group_member": BaseEvaluator.expr_func_is_group_member,
+ "ak_user_by": BaseEvaluator.expr_func_user_by,
+ "ak_logger": get_logger(),
+ "requests": Session(),
+ }
+ self._context = {}
+ self._filename = "BaseEvalautor"
+
+ @staticmethod
+ def expr_filter_regex_match(value: Any, regex: str) -> bool:
+ """Expression Filter to run re.search"""
+ return re.search(regex, value) is None
+
+ @staticmethod
+ def expr_filter_regex_replace(value: Any, regex: str, repl: str) -> str:
+ """Expression Filter to run re.sub"""
+ return re.sub(regex, repl, value)
+
+ @staticmethod
+ def expr_func_user_by(**filters) -> Optional[User]:
+ """Get user by filters"""
+ users = User.objects.filter(**filters)
+ if users:
+ return users.first()
+ return None
+
+ @staticmethod
+ def expr_func_is_group_member(user: User, **group_filters) -> bool:
+ """Check if `user` is member of group with name `group_name`"""
+ return user.groups.filter(**group_filters).exists()
+
+ def wrap_expression(self, expression: str, params: Iterable[str]) -> str:
+ """Wrap expression in a function, call it, and save the result as `result`"""
+ handler_signature = ",".join(params)
+ full_expression = ""
+ full_expression += "from ipaddress import ip_address, ip_network\n"
+ full_expression += f"def handler({handler_signature}):\n"
+ full_expression += indent(expression, " ")
+ full_expression += f"\nresult = handler({handler_signature})"
+ return full_expression
+
+ def evaluate(self, expression_source: str) -> Any:
+ """Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised.
+ If any exception is raised during execution, it is raised.
+ The result is returned without any type-checking."""
+ with Hub.current.start_span(op="lib.evaluator.evaluate") as span:
+ span: Span
+ span.set_data("expression", expression_source)
+ param_keys = self._context.keys()
+ ast_obj = compile(
+ self.wrap_expression(expression_source, param_keys),
+ self._filename,
+ "exec",
+ )
+ try:
+ _locals = self._context
+ # Yes this is an exec, yes it is potentially bad. Since we limit what variables are
+ # available here, and these policies can only be edited by admins, this is a risk
+ # we're willing to take.
+ # pylint: disable=exec-used
+ exec(ast_obj, self._globals, _locals) # nosec # noqa
+ result = _locals["result"]
+ except Exception as exc:
+ LOGGER.warning("Expression error", exc=exc)
+ raise
+ return result
+
+ def validate(self, expression: str) -> bool:
+ """Validate expression's syntax, raise ValidationError if Syntax is invalid"""
+ param_keys = self._context.keys()
+ try:
+ compile(
+ self.wrap_expression(expression, param_keys),
+ self._filename,
+ "exec",
+ )
+ return True
+ except (ValueError, SyntaxError) as exc:
+ raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc
diff --git a/authentik/lib/logging.py b/authentik/lib/logging.py
new file mode 100644
index 000000000..7ec26f48c
--- /dev/null
+++ b/authentik/lib/logging.py
@@ -0,0 +1,23 @@
+"""logging helpers"""
+from logging import Logger
+from os import getpid
+from typing import Callable
+
+
+# pylint: disable=unused-argument
+def add_process_id(logger: Logger, method_name: str, event_dict):
+ """Add the current process ID"""
+ event_dict["pid"] = getpid()
+ return event_dict
+
+
+def add_common_fields(environment: str) -> Callable:
+ """Add a common field to easily search for authentik logs"""
+
+ def add_common_field(logger: Logger, method_name: str, event_dict):
+ """Add a common field to easily search for authentik logs"""
+ event_dict["app"] = "authentik"
+ event_dict["app_environment"] = environment
+ return event_dict
+
+ return add_common_field
diff --git a/passbook/lib/models.py b/authentik/lib/models.py
similarity index 100%
rename from passbook/lib/models.py
rename to authentik/lib/models.py
diff --git a/authentik/lib/sentry.py b/authentik/lib/sentry.py
new file mode 100644
index 000000000..acd3c710b
--- /dev/null
+++ b/authentik/lib/sentry.py
@@ -0,0 +1,64 @@
+"""authentik sentry integration"""
+from aioredis.errors import ConnectionClosedError, ReplyError
+from billiard.exceptions import WorkerLostError
+from botocore.client import ClientError
+from celery.exceptions import CeleryError
+from channels_redis.core import ChannelFull
+from django.core.exceptions import DisallowedHost, ValidationError
+from django.db import InternalError, OperationalError, ProgrammingError
+from django_redis.exceptions import ConnectionInterrupted
+from ldap3.core.exceptions import LDAPException
+from redis.exceptions import ConnectionError as RedisConnectionError
+from redis.exceptions import RedisError, ResponseError
+from rest_framework.exceptions import APIException
+from structlog import get_logger
+from websockets.exceptions import WebSocketException
+
+LOGGER = get_logger()
+
+
+class SentryIgnoredException(Exception):
+ """Base Class for all errors that are suppressed, and not sent to sentry."""
+
+
+def before_send(event, hint):
+ """Check if error is database error, and ignore if so"""
+ ignored_classes = (
+ # Inbuilt types
+ KeyboardInterrupt,
+ ConnectionResetError,
+ OSError,
+ # Django DB Errors
+ OperationalError,
+ InternalError,
+ ProgrammingError,
+ DisallowedHost,
+ ValidationError,
+ # Redis errors
+ RedisConnectionError,
+ ConnectionInterrupted,
+ RedisError,
+ ResponseError,
+ ReplyError,
+ ConnectionClosedError,
+ # websocket errors
+ ChannelFull,
+ WebSocketException,
+ # rest_framework error
+ APIException,
+ # celery errors
+ WorkerLostError,
+ CeleryError,
+ # S3 errors
+ ClientError,
+ # custom baseclass
+ SentryIgnoredException,
+ # ldap errors
+ LDAPException,
+ )
+ if "exc_info" in hint:
+ _, exc_value, _ = hint["exc_info"]
+ if isinstance(exc_value, ignored_classes):
+ LOGGER.info("Supressing error %r", exc_value)
+ return None
+ return event
diff --git a/passbook/lib/tasks.py b/authentik/lib/tasks.py
similarity index 100%
rename from passbook/lib/tasks.py
rename to authentik/lib/tasks.py
diff --git a/authentik/lib/templates/lib/arrayfield.html b/authentik/lib/templates/lib/arrayfield.html
new file mode 100644
index 000000000..cba450c33
--- /dev/null
+++ b/authentik/lib/templates/lib/arrayfield.html
@@ -0,0 +1,17 @@
+{% load authentik_utils %}
+
+{% spaceless %}
+
+{% endspaceless %}
diff --git a/passbook/lib/templatetags/__init__.py b/authentik/lib/templatetags/__init__.py
similarity index 100%
rename from passbook/lib/templatetags/__init__.py
rename to authentik/lib/templatetags/__init__.py
diff --git a/authentik/lib/templatetags/authentik_is_active.py b/authentik/lib/templatetags/authentik_is_active.py
new file mode 100644
index 000000000..a99e34bbd
--- /dev/null
+++ b/authentik/lib/templatetags/authentik_is_active.py
@@ -0,0 +1,55 @@
+"""authentik lib navbar Templatetag"""
+from django import template
+from django.http import HttpRequest
+from structlog import get_logger
+
+register = template.Library()
+
+LOGGER = get_logger()
+ACTIVE_STRING = "pf-m-current"
+
+
+@register.simple_tag(takes_context=True)
+def is_active(context, *args: str, **_) -> str:
+ """Return whether a navbar link is active or not."""
+ request: HttpRequest = context.get("request")
+ if not request.resolver_match:
+ return ""
+ match = request.resolver_match
+ for url in args:
+ if ":" in url:
+ app_name, url = url.split(":")
+ if match.app_name == app_name and match.url_name == url:
+ return ACTIVE_STRING
+ else:
+ if match.url_name == url:
+ return ACTIVE_STRING
+ return ""
+
+
+@register.simple_tag(takes_context=True)
+def is_active_url(context, view: str) -> str:
+ """Return whether a navbar link is active or not."""
+ request: HttpRequest = context.get("request")
+ if not request.resolver_match:
+ return ""
+
+ match = request.resolver_match
+ current_full_url = f"{match.app_name}:{match.url_name}"
+
+ if current_full_url == view:
+ return ACTIVE_STRING
+ return ""
+
+
+@register.simple_tag(takes_context=True)
+def is_active_app(context, *args: str) -> str:
+ """Return True if current link is from app"""
+
+ request: HttpRequest = context.get("request")
+ if not request.resolver_match:
+ return ""
+ for app_name in args:
+ if request.resolver_match.app_name == app_name:
+ return ACTIVE_STRING
+ return ""
diff --git a/authentik/lib/templatetags/authentik_utils.py b/authentik/lib/templatetags/authentik_utils.py
new file mode 100644
index 000000000..16cd0e153
--- /dev/null
+++ b/authentik/lib/templatetags/authentik_utils.py
@@ -0,0 +1,113 @@
+"""authentik lib Templatetags"""
+from hashlib import md5
+from urllib.parse import urlencode
+
+from django import template
+from django.db.models import Model
+from django.http.request import HttpRequest
+from django.template import Context
+from django.templatetags.static import static
+from django.utils.html import escape, mark_safe
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.lib.config import CONFIG
+from authentik.lib.utils.urls import is_url_absolute
+
+register = template.Library()
+LOGGER = get_logger()
+
+GRAVATAR_URL = "https://secure.gravatar.com"
+
+
+@register.simple_tag(takes_context=True)
+def back(context: Context) -> str:
+ """Return a link back (either from GET parameter or referer."""
+ if "request" not in context:
+ return ""
+ request = context.get("request")
+ url = ""
+ if "HTTP_REFERER" in request.META:
+ url = request.META.get("HTTP_REFERER")
+ if "back" in request.GET:
+ url = request.GET.get("back")
+
+ if not is_url_absolute(url):
+ return url
+ return ""
+
+
+@register.filter("fieldtype")
+def fieldtype(field):
+ """Return classname"""
+ if isinstance(field.__class__, Model) or issubclass(field.__class__, Model):
+ return verbose_name(field)
+ return field.__class__.__name__
+
+
+@register.simple_tag
+def config(path, default=""):
+ """Get a setting from the database. Returns default is setting doesn't exist."""
+ return CONFIG.y(path, default)
+
+
+@register.filter(name="css_class")
+def css_class(field, css):
+ """Add css class to form field"""
+ return field.as_widget(attrs={"class": css})
+
+
+@register.simple_tag
+def avatar(user: User) -> str:
+ """Get avatar, depending on authentik.avatar setting"""
+ mode = CONFIG.raw.get("authentik").get("avatars")
+ if mode == "none":
+ return static("authentik/user_default.png")
+ if mode == "gravatar":
+ parameters = [
+ ("s", "158"),
+ ("r", "g"),
+ ]
+ # gravatar uses md5 for their URLs, so md5 can't be avoided
+ mail_hash = md5(user.email.encode("utf-8")).hexdigest() # nosec
+ gravatar_url = (
+ f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
+ )
+ return escape(gravatar_url)
+ raise ValueError(f"Invalid avatar mode {mode}")
+
+
+@register.filter
+def verbose_name(obj) -> str:
+ """Return Object's Verbose Name"""
+ if not obj:
+ return ""
+ if hasattr(obj, "verbose_name"):
+ return obj.verbose_name
+ return obj._meta.verbose_name
+
+
+@register.filter
+def form_verbose_name(obj) -> str:
+ """Return ModelForm's Object's Verbose Name"""
+ if not obj:
+ return ""
+ return verbose_name(obj._meta.model)
+
+
+@register.filter
+def doc(obj) -> str:
+ """Return docstring of object"""
+ return mark_safe(obj.__doc__.replace("\n", " "))
+
+
+@register.simple_tag(takes_context=True)
+def query_transform(context: Context, **kwargs) -> str:
+ """Append objects to the current querystring"""
+ if "request" not in context:
+ return ""
+ request: HttpRequest = context["request"]
+ updated = request.GET.copy()
+ for key, value in kwargs.items():
+ updated[key] = value
+ return updated.urlencode()
diff --git a/authentik/lib/tests.py b/authentik/lib/tests.py
new file mode 100644
index 000000000..9075a2a03
--- /dev/null
+++ b/authentik/lib/tests.py
@@ -0,0 +1,30 @@
+"""base model tests"""
+from typing import Callable, Type
+
+from django.test import TestCase
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Stage
+from authentik.lib.models import SerializerModel
+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_inst = test_model()
+ try:
+ self.assertTrue(issubclass(model_inst.serializer, BaseSerializer))
+ except NotImplementedError:
+ pass
+
+ return tester
+
+
+for model in all_subclasses(SerializerModel):
+ setattr(TestModels, f"test_model_{model.__name__}", model_tester_factory(model))
diff --git a/passbook/lib/utils/__init__.py b/authentik/lib/utils/__init__.py
similarity index 100%
rename from passbook/lib/utils/__init__.py
rename to authentik/lib/utils/__init__.py
diff --git a/passbook/lib/utils/http.py b/authentik/lib/utils/http.py
similarity index 100%
rename from passbook/lib/utils/http.py
rename to authentik/lib/utils/http.py
diff --git a/authentik/lib/utils/reflection.py b/authentik/lib/utils/reflection.py
new file mode 100644
index 000000000..1cfd73d86
--- /dev/null
+++ b/authentik/lib/utils/reflection.py
@@ -0,0 +1,43 @@
+"""authentik lib reflection utilities"""
+from importlib import import_module
+
+from django.conf import settings
+
+
+def all_subclasses(cls, sort=True):
+ """Recursively return all subclassess of cls"""
+ classes = set(cls.__subclasses__()).union(
+ [s for c in cls.__subclasses__() for s in all_subclasses(c, sort=sort)]
+ )
+ # Check if we're in debug mode, if not exclude classes which have `__debug_only__`
+ if not settings.DEBUG:
+ # Filter class out when __debug_only__ is not False
+ classes = [x for x in classes if not getattr(x, "__debug_only__", False)]
+ # classes = filter(lambda x: not getattr(x, "__debug_only__", False), classes)
+ if sort:
+ return sorted(classes, key=lambda x: x.__name__)
+ return classes
+
+
+def class_to_path(cls):
+ """Turn Class (Class or instance) into module path"""
+ return f"{cls.__module__}.{cls.__name__}"
+
+
+def path_to_class(path):
+ """Import module and return class"""
+ if not path:
+ return None
+ parts = path.split(".")
+ package = ".".join(parts[:-1])
+ _class = getattr(import_module(package), parts[-1])
+ return _class
+
+
+def get_apps():
+ """Get list of all authentik apps"""
+ from django.apps.registry import apps
+
+ for _app in apps.get_app_configs():
+ if _app.name.startswith("authentik"):
+ yield _app
diff --git a/authentik/lib/utils/template.py b/authentik/lib/utils/template.py
new file mode 100644
index 000000000..a5486164a
--- /dev/null
+++ b/authentik/lib/utils/template.py
@@ -0,0 +1,8 @@
+"""authentik lib template utilities"""
+from django.template import Context, loader
+
+
+def render_to_string(template_path: str, ctx: Context) -> str:
+ """Render a template to string"""
+ template = loader.get_template(template_path)
+ return template.render(ctx)
diff --git a/passbook/lib/utils/time.py b/authentik/lib/utils/time.py
similarity index 100%
rename from passbook/lib/utils/time.py
rename to authentik/lib/utils/time.py
diff --git a/authentik/lib/utils/ui.py b/authentik/lib/utils/ui.py
new file mode 100644
index 000000000..cfe7d6c3a
--- /dev/null
+++ b/authentik/lib/utils/ui.py
@@ -0,0 +1,11 @@
+"""authentik UI utils"""
+from typing import Any, List
+
+
+def human_list(_list: List[Any]) -> str:
+ """Convert a list of items into 'a, b or c'"""
+ last_item = _list.pop()
+ if len(_list) < 1:
+ return last_item
+ result = ", ".join(_list)
+ return "%s or %s" % (result, last_item)
diff --git a/passbook/lib/utils/urls.py b/authentik/lib/utils/urls.py
similarity index 100%
rename from passbook/lib/utils/urls.py
rename to authentik/lib/utils/urls.py
diff --git a/authentik/lib/views.py b/authentik/lib/views.py
new file mode 100644
index 000000000..bfa28414e
--- /dev/null
+++ b/authentik/lib/views.py
@@ -0,0 +1,41 @@
+"""authentik helper views"""
+from django.http import HttpRequest
+from django.template.response import TemplateResponse
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import CreateView
+from guardian.shortcuts import assign_perm
+
+
+class CreateAssignPermView(CreateView):
+ """Assign permissions to object after creation"""
+
+ permissions = [
+ "%s.view_%s",
+ "%s.change_%s",
+ "%s.delete_%s",
+ ]
+
+ def form_valid(self, form):
+ response = super().form_valid(form)
+ for permission in self.permissions:
+ full_permission = permission % (
+ self.object._meta.app_label,
+ self.object._meta.model_name,
+ )
+ assign_perm(full_permission, self.request.user, self.object)
+ return response
+
+
+def bad_request_message(
+ request: HttpRequest,
+ message: str,
+ title="Bad Request",
+ template="error/generic.html",
+) -> TemplateResponse:
+ """Return generic error page with message, with status code set to 400"""
+ return TemplateResponse(
+ request,
+ template,
+ {"message": message, "title": _(title)},
+ status=400,
+ )
diff --git a/passbook/lib/widgets.py b/authentik/lib/widgets.py
similarity index 100%
rename from passbook/lib/widgets.py
rename to authentik/lib/widgets.py
diff --git a/passbook/outposts/__init__.py b/authentik/outposts/__init__.py
similarity index 100%
rename from passbook/outposts/__init__.py
rename to authentik/outposts/__init__.py
diff --git a/authentik/outposts/api.py b/authentik/outposts/api.py
new file mode 100644
index 000000000..d6815a955
--- /dev/null
+++ b/authentik/outposts/api.py
@@ -0,0 +1,66 @@
+"""Outpost API Views"""
+from rest_framework.serializers import JSONField, ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.outposts.models import (
+ DockerServiceConnection,
+ KubernetesServiceConnection,
+ Outpost,
+)
+
+
+class OutpostSerializer(ModelSerializer):
+ """Outpost Serializer"""
+
+ _config = JSONField()
+
+ class Meta:
+
+ model = Outpost
+ fields = ["pk", "name", "providers", "service_connection", "_config"]
+
+
+class OutpostViewSet(ModelViewSet):
+ """Outpost Viewset"""
+
+ queryset = Outpost.objects.all()
+ serializer_class = OutpostSerializer
+
+
+class DockerServiceConnectionSerializer(ModelSerializer):
+ """DockerServiceConnection Serializer"""
+
+ class Meta:
+
+ model = DockerServiceConnection
+ fields = [
+ "pk",
+ "name",
+ "local",
+ "url",
+ "tls_verification",
+ "tls_authentication",
+ ]
+
+
+class DockerServiceConnectionViewSet(ModelViewSet):
+ """DockerServiceConnection Viewset"""
+
+ queryset = DockerServiceConnection.objects.all()
+ serializer_class = DockerServiceConnectionSerializer
+
+
+class KubernetesServiceConnectionSerializer(ModelSerializer):
+ """KubernetesServiceConnection Serializer"""
+
+ class Meta:
+
+ model = KubernetesServiceConnection
+ fields = ["pk", "name", "local", "kubeconfig"]
+
+
+class KubernetesServiceConnectionViewSet(ModelViewSet):
+ """KubernetesServiceConnection Viewset"""
+
+ queryset = KubernetesServiceConnection.objects.all()
+ serializer_class = KubernetesServiceConnectionSerializer
diff --git a/authentik/outposts/apps.py b/authentik/outposts/apps.py
new file mode 100644
index 000000000..b9690724c
--- /dev/null
+++ b/authentik/outposts/apps.py
@@ -0,0 +1,74 @@
+"""authentik outposts app config"""
+from importlib import import_module
+from os import R_OK, access
+from os.path import expanduser
+from pathlib import Path
+from socket import gethostname
+from urllib.parse import urlparse
+
+import yaml
+from django.apps import AppConfig
+from django.db import ProgrammingError
+from docker.constants import DEFAULT_UNIX_SOCKET
+from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME
+from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION
+from structlog import get_logger
+
+LOGGER = get_logger()
+
+
+class AuthentikOutpostConfig(AppConfig):
+ """authentik outposts app config"""
+
+ name = "authentik.outposts"
+ label = "authentik_outposts"
+ mountpoint = "outposts/"
+ verbose_name = "authentik Outpost"
+
+ def ready(self):
+ import_module("authentik.outposts.signals")
+ try:
+ AuthentikOutpostConfig.init_local_connection()
+ except ProgrammingError:
+ pass
+
+ @staticmethod
+ def init_local_connection():
+ """Check if local kubernetes or docker connections should be created"""
+ from authentik.outposts.models import (
+ KubernetesServiceConnection,
+ DockerServiceConnection,
+ )
+
+ if Path(SERVICE_TOKEN_FILENAME).exists():
+ LOGGER.debug("Detected in-cluster Kubernetes Config")
+ if not KubernetesServiceConnection.objects.filter(local=True).exists():
+ LOGGER.debug("Created Service Connection for in-cluster")
+ KubernetesServiceConnection.objects.create(
+ name="Local Kubernetes Cluster", local=True, kubeconfig={}
+ )
+ # For development, check for the existence of a kubeconfig file
+ kubeconfig_path = expanduser(KUBE_CONFIG_DEFAULT_LOCATION)
+ if Path(kubeconfig_path).exists():
+ LOGGER.debug("Detected kubeconfig")
+ kubeconfig_local_name = f"k8s-{gethostname()}"
+ if not KubernetesServiceConnection.objects.filter(
+ name=kubeconfig_local_name
+ ).exists():
+ LOGGER.debug("Creating kubeconfig Service Connection")
+ with open(kubeconfig_path, "r") as _kubeconfig:
+ KubernetesServiceConnection.objects.create(
+ name=kubeconfig_local_name,
+ kubeconfig=yaml.safe_load(_kubeconfig),
+ )
+ unix_socket_path = urlparse(DEFAULT_UNIX_SOCKET).path
+ socket = Path(unix_socket_path)
+ if socket.exists() and access(socket, R_OK):
+ LOGGER.debug("Detected local docker socket")
+ if not DockerServiceConnection.objects.filter(local=True).exists():
+ LOGGER.debug("Created Service Connection for docker")
+ DockerServiceConnection.objects.create(
+ name="Local Docker connection",
+ local=True,
+ url=unix_socket_path,
+ )
diff --git a/authentik/outposts/channels.py b/authentik/outposts/channels.py
new file mode 100644
index 000000000..cebe16049
--- /dev/null
+++ b/authentik/outposts/channels.py
@@ -0,0 +1,89 @@
+"""Outpost websocket handler"""
+from dataclasses import asdict, dataclass, field
+from datetime import datetime
+from enum import IntEnum
+from typing import Any, Dict
+
+from dacite import from_dict
+from dacite.data import Data
+from guardian.shortcuts import get_objects_for_user
+from structlog import get_logger
+
+from authentik.core.channels import AuthJsonConsumer
+from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState
+
+LOGGER = get_logger()
+
+
+class WebsocketMessageInstruction(IntEnum):
+ """Commands which can be triggered over Websocket"""
+
+ # Simple message used by either side when a message is acknowledged
+ ACK = 0
+
+ # Message used by outposts to report their alive status
+ HELLO = 1
+
+ # Message sent by us to trigger an Update
+ TRIGGER_UPDATE = 2
+
+
+@dataclass
+class WebsocketMessage:
+ """Complete Websocket Message that is being sent"""
+
+ instruction: int
+ args: Dict[str, Any] = field(default_factory=dict)
+
+
+class OutpostConsumer(AuthJsonConsumer):
+ """Handler for Outposts that connect over websockets for health checks and live updates"""
+
+ outpost: Outpost
+
+ def connect(self):
+ if not super().connect():
+ return
+ uuid = self.scope["url_route"]["kwargs"]["pk"]
+ outpost = get_objects_for_user(
+ self.user, "authentik_outposts.view_outpost"
+ ).filter(pk=uuid)
+ if not outpost.exists():
+ self.close()
+ return
+ self.accept()
+ self.outpost = outpost.first()
+ OutpostState(
+ uid=self.channel_name, last_seen=datetime.now(), _outpost=self.outpost
+ ).save(timeout=OUTPOST_HELLO_INTERVAL * 1.5)
+ LOGGER.debug("added channel to cache", channel_name=self.channel_name)
+
+ # pylint: disable=unused-argument
+ def disconnect(self, close_code):
+ OutpostState.for_channel(self.outpost, self.channel_name).delete()
+ LOGGER.debug("removed channel from cache", channel_name=self.channel_name)
+
+ def receive_json(self, content: Data):
+ msg = from_dict(WebsocketMessage, content)
+ state = OutpostState(
+ uid=self.channel_name,
+ last_seen=datetime.now(),
+ _outpost=self.outpost,
+ )
+ if msg.instruction == WebsocketMessageInstruction.HELLO:
+ state.version = msg.args.get("version", None)
+ elif msg.instruction == WebsocketMessageInstruction.ACK:
+ return
+ state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5)
+
+ response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
+ self.send_json(asdict(response))
+
+ # pylint: disable=unused-argument
+ def event_update(self, event):
+ """Event handler which is called by post_save signals, Send update instruction"""
+ self.send_json(
+ asdict(
+ WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE)
+ )
+ )
diff --git a/passbook/outposts/controllers/__init__.py b/authentik/outposts/controllers/__init__.py
similarity index 100%
rename from passbook/outposts/controllers/__init__.py
rename to authentik/outposts/controllers/__init__.py
diff --git a/authentik/outposts/controllers/base.py b/authentik/outposts/controllers/base.py
new file mode 100644
index 000000000..57b9cf68b
--- /dev/null
+++ b/authentik/outposts/controllers/base.py
@@ -0,0 +1,46 @@
+"""Base Controller"""
+from typing import Dict, List
+
+from structlog import get_logger
+from structlog.testing import capture_logs
+
+from authentik.lib.sentry import SentryIgnoredException
+from authentik.outposts.models import Outpost, OutpostServiceConnection
+
+
+class ControllerException(SentryIgnoredException):
+ """Exception raised when anything fails during controller run"""
+
+
+class BaseController:
+ """Base Outpost deployment controller"""
+
+ deployment_ports: Dict[str, int]
+
+ outpost: Outpost
+ connection: OutpostServiceConnection
+
+ def __init__(self, outpost: Outpost, connection: OutpostServiceConnection):
+ self.outpost = outpost
+ self.connection = connection
+ self.logger = get_logger()
+ self.deployment_ports = {}
+
+ # pylint: disable=invalid-name
+ def up(self):
+ """Called by scheduled task to reconcile deployment/service/etc"""
+ raise NotImplementedError
+
+ def up_with_logs(self) -> List[str]:
+ """Call .up() but capture all log output and return it."""
+ with capture_logs() as logs:
+ self.up()
+ return [x["event"] for x in logs]
+
+ def down(self):
+ """Handler to delete everything we've created"""
+ raise NotImplementedError
+
+ def get_static_deployment(self) -> str:
+ """Return a static deployment configuration"""
+ raise NotImplementedError
diff --git a/authentik/outposts/controllers/docker.py b/authentik/outposts/controllers/docker.py
new file mode 100644
index 000000000..e12d7872c
--- /dev/null
+++ b/authentik/outposts/controllers/docker.py
@@ -0,0 +1,160 @@
+"""Docker controller"""
+from time import sleep
+from typing import Dict, Tuple
+
+from django.conf import settings
+from docker import DockerClient
+from docker.errors import DockerException, NotFound
+from docker.models.containers import Container
+from yaml import safe_dump
+
+from authentik import __version__
+from authentik.lib.config import CONFIG
+from authentik.outposts.controllers.base import BaseController, ControllerException
+from authentik.outposts.models import (
+ DockerServiceConnection,
+ Outpost,
+ ServiceConnectionInvalid,
+)
+
+
+class DockerController(BaseController):
+ """Docker controller"""
+
+ client: DockerClient
+
+ container: Container
+ connection: DockerServiceConnection
+
+ def __init__(self, outpost: Outpost, connection: DockerServiceConnection) -> None:
+ super().__init__(outpost, connection)
+ try:
+ self.client = connection.client()
+ except ServiceConnectionInvalid as exc:
+ raise ControllerException from exc
+
+ def _get_labels(self) -> Dict[str, str]:
+ return {}
+
+ def _get_env(self) -> Dict[str, str]:
+ return {
+ "AUTHENTIK_HOST": self.outpost.config.authentik_host,
+ "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure),
+ "AUTHENTIK_TOKEN": self.outpost.token.key,
+ }
+
+ def _comp_env(self, container: Container) -> bool:
+ """Check if container's env is equal to what we would set. Return true if container needs
+ to be rebuilt."""
+ should_be = self._get_env()
+ container_env = container.attrs.get("Config", {}).get("Env", {})
+ for key, expected_value in should_be.items():
+ if key not in container_env:
+ continue
+ if container_env[key] != expected_value:
+ return True
+ return False
+
+ def _get_container(self) -> Tuple[Container, bool]:
+ container_name = f"authentik-proxy-{self.outpost.uuid.hex}"
+ try:
+ return self.client.containers.get(container_name), False
+ except NotFound:
+ self.logger.info("Container does not exist, creating")
+ image_prefix = CONFIG.y("outposts.docker_image_base")
+ image_name = f"{image_prefix}-{self.outpost.type}:{__version__}"
+ self.client.images.pull(image_name)
+ container_args = {
+ "image": image_name,
+ "name": f"authentik-proxy-{self.outpost.uuid.hex}",
+ "detach": True,
+ "ports": {x: x for _, x in self.deployment_ports.items()},
+ "environment": self._get_env(),
+ "labels": self._get_labels(),
+ }
+ if settings.TEST:
+ del container_args["ports"]
+ container_args["network_mode"] = "host"
+ return (
+ self.client.containers.create(**container_args),
+ True,
+ )
+
+ def up(self):
+ try:
+ container, has_been_created = self._get_container()
+ # Check if the container is out of date, delete it and retry
+ if len(container.image.tags) > 0:
+ tag: str = container.image.tags[0]
+ _, _, version = tag.partition(":")
+ if version != __version__:
+ self.logger.info(
+ "Container has mismatched version, re-creating...",
+ has=version,
+ should=__version__,
+ )
+ container.kill()
+ container.remove(force=True)
+ return self.up()
+ # Check that container values match our values
+ if self._comp_env(container):
+ self.logger.info("Container has outdated config, re-creating...")
+ container.kill()
+ container.remove(force=True)
+ return self.up()
+ # Check that container is healthy
+ if (
+ container.status == "running"
+ and container.attrs.get("State", {}).get("Health", {}).get("Status", "")
+ != "healthy"
+ ):
+ # At this point we know the config is correct, but the container isn't healthy,
+ # so we just restart it with the same config
+ if has_been_created:
+ # Since we've just created the container, give it some time to start.
+ # If its still not up by then, restart it
+ self.logger.info(
+ "Container is unhealthy and new, giving it time to boot."
+ )
+ sleep(60)
+ self.logger.info("Container is unhealthy, restarting...")
+ container.restart()
+ return None
+ # Check that container is running
+ if container.status != "running":
+ self.logger.info("Container is not running, restarting...")
+ container.start()
+ return None
+ return None
+ except DockerException as exc:
+ raise ControllerException from exc
+
+ def down(self):
+ try:
+ container, _ = self._get_container()
+ container.kill()
+ container.remove()
+ except DockerException as exc:
+ raise ControllerException from exc
+
+ def get_static_deployment(self) -> str:
+ """Generate docker-compose yaml for proxy, version 3.5"""
+ ports = [f"{x}:{x}" for _, x in self.deployment_ports.items()]
+ image_prefix = CONFIG.y("outposts.docker_image_base")
+ compose = {
+ "version": "3.5",
+ "services": {
+ f"authentik_{self.outpost.type}": {
+ "image": f"{image_prefix}-{self.outpost.type}:{__version__}",
+ "ports": ports,
+ "environment": {
+ "AUTHENTIK_HOST": self.outpost.config.authentik_host,
+ "AUTHENTIK_INSECURE": str(
+ self.outpost.config.authentik_host_insecure
+ ),
+ "AUTHENTIK_TOKEN": self.outpost.token.key,
+ },
+ }
+ },
+ }
+ return safe_dump(compose, default_flow_style=False)
diff --git a/passbook/outposts/controllers/k8s/__init__.py b/authentik/outposts/controllers/k8s/__init__.py
similarity index 100%
rename from passbook/outposts/controllers/k8s/__init__.py
rename to authentik/outposts/controllers/k8s/__init__.py
diff --git a/authentik/outposts/controllers/k8s/base.py b/authentik/outposts/controllers/k8s/base.py
new file mode 100644
index 000000000..0fbf5588a
--- /dev/null
+++ b/authentik/outposts/controllers/k8s/base.py
@@ -0,0 +1,126 @@
+"""Base Kubernetes Reconciler"""
+from typing import TYPE_CHECKING, Generic, TypeVar
+
+from kubernetes.client import V1ObjectMeta
+from kubernetes.client.rest import ApiException
+from structlog import get_logger
+
+from authentik import __version__
+from authentik.lib.sentry import SentryIgnoredException
+
+if TYPE_CHECKING:
+ from authentik.outposts.controllers.kubernetes import KubernetesController
+
+# pylint: disable=invalid-name
+T = TypeVar("T")
+
+
+class ReconcileTrigger(SentryIgnoredException):
+ """Base trigger raised by child classes to notify us"""
+
+
+class NeedsRecreate(ReconcileTrigger):
+ """Exception to trigger a complete recreate of the Kubernetes Object"""
+
+
+class NeedsUpdate(ReconcileTrigger):
+ """Exception to trigger an update to the Kubernetes Object"""
+
+
+class KubernetesObjectReconciler(Generic[T]):
+ """Base Kubernetes Reconciler, handles the basic logic."""
+
+ controller: "KubernetesController"
+
+ def __init__(self, controller: "KubernetesController"):
+ self.controller = controller
+ self.namespace = controller.outpost.config.kubernetes_namespace
+ self.logger = get_logger()
+
+ @property
+ def name(self) -> str:
+ """Get the name of the object this reconciler manages"""
+ raise NotImplementedError
+
+ def up(self):
+ """Create object if it doesn't exist, update if needed or recreate if needed."""
+ current = None
+ reference = self.get_reference_object()
+ try:
+ try:
+ current = self.retrieve()
+ except ApiException as exc:
+ if exc.status == 404:
+ self.logger.debug("Failed to get current, triggering recreate")
+ raise NeedsRecreate from exc
+ self.logger.debug("Other unhandled error", exc=exc)
+ raise exc
+ else:
+ self.logger.debug("Got current, running reconcile")
+ self.reconcile(current, reference)
+ except NeedsRecreate:
+ self.logger.debug("Recreate requested")
+ if current:
+ self.logger.debug("Deleted old")
+ self.delete(current)
+ else:
+ self.logger.debug("No old found, creating")
+ self.logger.debug("Created")
+ self.create(reference)
+ except NeedsUpdate:
+ self.logger.debug("Updating")
+ self.update(current, reference)
+ else:
+ self.logger.debug("Nothing to do...")
+
+ def down(self):
+ """Delete object if found"""
+ try:
+ current = self.retrieve()
+ self.delete(current)
+ self.logger.debug("Removing")
+ except ApiException as exc:
+ if exc.status == 404:
+ self.logger.debug("Failed to get current, assuming non-existant")
+ return
+ self.logger.debug("Other unhandled error", exc=exc)
+ raise exc
+
+ def get_reference_object(self) -> T:
+ """Return object as it should be"""
+ raise NotImplementedError
+
+ def reconcile(self, current: T, reference: T):
+ """Check what operations should be done, should be raised as
+ ReconcileTrigger"""
+ raise NotImplementedError
+
+ def create(self, reference: T):
+ """API Wrapper to create object"""
+ raise NotImplementedError
+
+ def retrieve(self) -> T:
+ """API Wrapper to retrive object"""
+ raise NotImplementedError
+
+ def delete(self, reference: T):
+ """API Wrapper to delete object"""
+ raise NotImplementedError
+
+ def update(self, current: T, reference: T):
+ """API Wrapper to update object"""
+ raise NotImplementedError
+
+ def get_object_meta(self, **kwargs) -> V1ObjectMeta:
+ """Get common object metadata"""
+ return V1ObjectMeta(
+ namespace=self.namespace,
+ labels={
+ "app.kubernetes.io/name": f"authentik-{self.controller.outpost.type.lower()}",
+ "app.kubernetes.io/instance": self.controller.outpost.name,
+ "app.kubernetes.io/version": __version__,
+ "app.kubernetes.io/managed-by": "goauthentik.io",
+ "goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
+ },
+ **kwargs,
+ )
diff --git a/authentik/outposts/controllers/k8s/deployment.py b/authentik/outposts/controllers/k8s/deployment.py
new file mode 100644
index 000000000..981cade84
--- /dev/null
+++ b/authentik/outposts/controllers/k8s/deployment.py
@@ -0,0 +1,134 @@
+"""Kubernetes Deployment Reconciler"""
+from typing import TYPE_CHECKING, Dict
+
+from kubernetes.client import (
+ AppsV1Api,
+ V1Container,
+ V1ContainerPort,
+ V1Deployment,
+ V1DeploymentSpec,
+ V1EnvVar,
+ V1EnvVarSource,
+ V1LabelSelector,
+ V1ObjectMeta,
+ V1PodSpec,
+ V1PodTemplateSpec,
+ V1SecretKeySelector,
+)
+
+from authentik import __version__
+from authentik.lib.config import CONFIG
+from authentik.outposts.controllers.k8s.base import (
+ KubernetesObjectReconciler,
+ NeedsUpdate,
+)
+from authentik.outposts.models import Outpost
+
+if TYPE_CHECKING:
+ from authentik.outposts.controllers.kubernetes import KubernetesController
+
+
+class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
+ """Kubernetes Deployment Reconciler"""
+
+ outpost: Outpost
+
+ def __init__(self, controller: "KubernetesController") -> None:
+ super().__init__(controller)
+ self.api = AppsV1Api(controller.client)
+ self.outpost = self.controller.outpost
+
+ @property
+ def name(self) -> str:
+ return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
+
+ def reconcile(self, current: V1Deployment, reference: V1Deployment):
+ if current.spec.replicas != reference.spec.replicas:
+ raise NeedsUpdate()
+ if (
+ current.spec.template.spec.containers[0].image
+ != reference.spec.template.spec.containers[0].image
+ ):
+ raise NeedsUpdate()
+
+ def get_pod_meta(self) -> Dict[str, str]:
+ """Get common object metadata"""
+ return {
+ "app.kubernetes.io/name": "authentik-outpost",
+ "app.kubernetes.io/managed-by": "goauthentik.io",
+ "goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
+ }
+
+ def get_reference_object(self) -> V1Deployment:
+ """Get deployment object for outpost"""
+ # Generate V1ContainerPort objects
+ container_ports = []
+ for port_name, port in self.controller.deployment_ports.items():
+ container_ports.append(V1ContainerPort(container_port=port, name=port_name))
+ meta = self.get_object_meta(name=self.name)
+ secret_name = f"authentik-outpost-{self.controller.outpost.uuid.hex}-api"
+ image_prefix = CONFIG.y("outposts.docker_image_base")
+ return V1Deployment(
+ metadata=meta,
+ spec=V1DeploymentSpec(
+ replicas=self.outpost.config.kubernetes_replicas,
+ selector=V1LabelSelector(match_labels=self.get_pod_meta()),
+ template=V1PodTemplateSpec(
+ metadata=V1ObjectMeta(labels=self.get_pod_meta()),
+ spec=V1PodSpec(
+ containers=[
+ V1Container(
+ name=str(self.outpost.type),
+ image=f"{image_prefix}-{self.outpost.type}:{__version__}",
+ ports=container_ports,
+ env=[
+ V1EnvVar(
+ name="AUTHENTIK_HOST",
+ value_from=V1EnvVarSource(
+ secret_key_ref=V1SecretKeySelector(
+ name=secret_name,
+ key="authentik_host",
+ )
+ ),
+ ),
+ V1EnvVar(
+ name="AUTHENTIK_TOKEN",
+ value_from=V1EnvVarSource(
+ secret_key_ref=V1SecretKeySelector(
+ name=secret_name,
+ key="token",
+ )
+ ),
+ ),
+ V1EnvVar(
+ name="AUTHENTIK_INSECURE",
+ value_from=V1EnvVarSource(
+ secret_key_ref=V1SecretKeySelector(
+ name=secret_name,
+ key="authentik_host_insecure",
+ )
+ ),
+ ),
+ ],
+ )
+ ]
+ ),
+ ),
+ ),
+ )
+
+ def create(self, reference: V1Deployment):
+ return self.api.create_namespaced_deployment(self.namespace, reference)
+
+ def delete(self, reference: V1Deployment):
+ return self.api.delete_namespaced_deployment(
+ reference.metadata.name, self.namespace
+ )
+
+ def retrieve(self) -> V1Deployment:
+ return self.api.read_namespaced_deployment(self.name, self.namespace)
+
+ def update(self, current: V1Deployment, reference: V1Deployment):
+ return self.api.patch_namespaced_deployment(
+ current.metadata.name, self.namespace, reference
+ )
diff --git a/authentik/outposts/controllers/k8s/secret.py b/authentik/outposts/controllers/k8s/secret.py
new file mode 100644
index 000000000..a70866c0c
--- /dev/null
+++ b/authentik/outposts/controllers/k8s/secret.py
@@ -0,0 +1,67 @@
+"""Kubernetes Secret Reconciler"""
+from base64 import b64encode
+from typing import TYPE_CHECKING
+
+from kubernetes.client import CoreV1Api, V1Secret
+
+from authentik.outposts.controllers.k8s.base import (
+ KubernetesObjectReconciler,
+ NeedsUpdate,
+)
+
+if TYPE_CHECKING:
+ from authentik.outposts.controllers.kubernetes import KubernetesController
+
+
+def b64string(source: str) -> str:
+ """Base64 Encode string"""
+ return b64encode(source.encode()).decode("utf-8")
+
+
+class SecretReconciler(KubernetesObjectReconciler[V1Secret]):
+ """Kubernetes Secret Reconciler"""
+
+ def __init__(self, controller: "KubernetesController") -> None:
+ super().__init__(controller)
+ self.api = CoreV1Api(controller.client)
+
+ @property
+ def name(self) -> str:
+ return f"authentik-outpost-{self.controller.outpost.uuid.hex}-api"
+
+ def reconcile(self, current: V1Secret, reference: V1Secret):
+ for key in reference.data.keys():
+ if current.data[key] != reference.data[key]:
+ raise NeedsUpdate()
+
+ def get_reference_object(self) -> V1Secret:
+ """Get deployment object for outpost"""
+ meta = self.get_object_meta(name=self.name)
+ return V1Secret(
+ metadata=meta,
+ data={
+ "authentik_host": b64string(
+ self.controller.outpost.config.authentik_host
+ ),
+ "authentik_host_insecure": b64string(
+ str(self.controller.outpost.config.authentik_host_insecure)
+ ),
+ "token": b64string(self.controller.outpost.token.token_uuid.hex),
+ },
+ )
+
+ def create(self, reference: V1Secret):
+ return self.api.create_namespaced_secret(self.namespace, reference)
+
+ def delete(self, reference: V1Secret):
+ return self.api.delete_namespaced_secret(
+ reference.metadata.name, self.namespace
+ )
+
+ def retrieve(self) -> V1Secret:
+ return self.api.read_namespaced_secret(self.name, self.namespace)
+
+ def update(self, current: V1Secret, reference: V1Secret):
+ return self.api.patch_namespaced_secret(
+ current.metadata.name, self.namespace, reference
+ )
diff --git a/authentik/outposts/controllers/k8s/service.py b/authentik/outposts/controllers/k8s/service.py
new file mode 100644
index 000000000..b710832f1
--- /dev/null
+++ b/authentik/outposts/controllers/k8s/service.py
@@ -0,0 +1,60 @@
+"""Kubernetes Service Reconciler"""
+from typing import TYPE_CHECKING
+
+from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec
+
+from authentik.outposts.controllers.k8s.base import (
+ KubernetesObjectReconciler,
+ NeedsUpdate,
+)
+from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
+
+if TYPE_CHECKING:
+ from authentik.outposts.controllers.kubernetes import KubernetesController
+
+
+class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
+ """Kubernetes Service Reconciler"""
+
+ def __init__(self, controller: "KubernetesController") -> None:
+ super().__init__(controller)
+ self.api = CoreV1Api(controller.client)
+
+ @property
+ def name(self) -> str:
+ return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
+
+ def reconcile(self, current: V1Service, reference: V1Service):
+ if len(current.spec.ports) != len(reference.spec.ports):
+ raise NeedsUpdate()
+ for port in reference.spec.ports:
+ if port not in current.spec.ports:
+ raise NeedsUpdate()
+
+ def get_reference_object(self) -> V1Service:
+ """Get deployment object for outpost"""
+ meta = self.get_object_meta(name=self.name)
+ ports = []
+ for port_name, port in self.controller.deployment_ports.items():
+ ports.append(V1ServicePort(name=port_name, port=port))
+ selector_labels = DeploymentReconciler(self.controller).get_pod_meta()
+ return V1Service(
+ metadata=meta,
+ spec=V1ServiceSpec(ports=ports, selector=selector_labels, type="ClusterIP"),
+ )
+
+ def create(self, reference: V1Service):
+ return self.api.create_namespaced_service(self.namespace, reference)
+
+ def delete(self, reference: V1Service):
+ return self.api.delete_namespaced_service(
+ reference.metadata.name, self.namespace
+ )
+
+ def retrieve(self) -> V1Service:
+ return self.api.read_namespaced_service(self.name, self.namespace)
+
+ def update(self, current: V1Service, reference: V1Service):
+ return self.api.patch_namespaced_service(
+ current.metadata.name, self.namespace, reference
+ )
diff --git a/authentik/outposts/controllers/kubernetes.py b/authentik/outposts/controllers/kubernetes.py
new file mode 100644
index 000000000..f75edf823
--- /dev/null
+++ b/authentik/outposts/controllers/kubernetes.py
@@ -0,0 +1,81 @@
+"""Kubernetes deployment controller"""
+from io import StringIO
+from typing import Dict, List, Type
+
+from kubernetes.client import OpenApiException
+from kubernetes.client.api_client import ApiClient
+from structlog.testing import capture_logs
+from yaml import dump_all
+
+from authentik.outposts.controllers.base import BaseController, ControllerException
+from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
+from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
+from authentik.outposts.controllers.k8s.secret import SecretReconciler
+from authentik.outposts.controllers.k8s.service import ServiceReconciler
+from authentik.outposts.models import KubernetesServiceConnection, Outpost
+
+
+class KubernetesController(BaseController):
+ """Manage deployment of outpost in kubernetes"""
+
+ reconcilers: Dict[str, Type[KubernetesObjectReconciler]]
+ reconcile_order: List[str]
+
+ client: ApiClient
+ connection: KubernetesServiceConnection
+
+ def __init__(
+ self, outpost: Outpost, connection: KubernetesServiceConnection
+ ) -> None:
+ super().__init__(outpost, connection)
+ self.client = connection.client()
+ self.reconcilers = {
+ "secret": SecretReconciler,
+ "deployment": DeploymentReconciler,
+ "service": ServiceReconciler,
+ }
+ self.reconcile_order = ["secret", "deployment", "service"]
+
+ def up(self):
+ try:
+ for reconcile_key in self.reconcile_order:
+ reconciler = self.reconcilers[reconcile_key](self)
+ reconciler.up()
+
+ except OpenApiException as exc:
+ raise ControllerException from exc
+
+ def up_with_logs(self) -> List[str]:
+ try:
+ all_logs = []
+ for reconcile_key in self.reconcile_order:
+ with capture_logs() as logs:
+ reconciler = self.reconcilers[reconcile_key](self)
+ reconciler.up()
+ all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs]
+ return all_logs
+ except OpenApiException as exc:
+ raise ControllerException from exc
+
+ def down(self):
+ try:
+ for reconcile_key in self.reconcile_order:
+ reconciler = self.reconcilers[reconcile_key](self)
+ reconciler.down()
+
+ except OpenApiException as exc:
+ raise ControllerException from exc
+
+ def get_static_deployment(self) -> str:
+ documents = []
+ for reconcile_key in self.reconcile_order:
+ reconciler = self.reconcilers[reconcile_key](self)
+ documents.append(reconciler.get_reference_object().to_dict())
+
+ with StringIO() as _str:
+ dump_all(
+ documents,
+ stream=_str,
+ default_flow_style=False,
+ )
+ return _str.getvalue()
diff --git a/authentik/outposts/docker_tls.py b/authentik/outposts/docker_tls.py
new file mode 100644
index 000000000..0ecbc8383
--- /dev/null
+++ b/authentik/outposts/docker_tls.py
@@ -0,0 +1,56 @@
+"""Create Docker TLSConfig from CertificateKeyPair"""
+from pathlib import Path
+from tempfile import gettempdir
+from typing import Optional
+
+from docker.tls import TLSConfig
+
+from authentik.crypto.models import CertificateKeyPair
+
+
+class DockerInlineTLS:
+ """Create Docker TLSConfig from CertificateKeyPair"""
+
+ verification_kp: Optional[CertificateKeyPair]
+ authentication_kp: Optional[CertificateKeyPair]
+
+ def __init__(
+ self,
+ verification_kp: Optional[CertificateKeyPair],
+ authentication_kp: Optional[CertificateKeyPair],
+ ) -> None:
+ self.verification_kp = verification_kp
+ self.authentication_kp = authentication_kp
+
+ def write_file(self, name: str, contents: str) -> str:
+ """Wrapper for mkstemp that uses fdopen"""
+ path = Path(gettempdir(), name)
+ with open(path, "w") as _file:
+ _file.write(contents)
+ return str(path)
+
+ def write(self) -> TLSConfig:
+ """Create TLSConfig with Certificate Keypairs"""
+ # So yes, this is quite ugly. But sadly, there is no clean way to pass
+ # docker-py (which is using requests (which is using urllib3)) a certificate
+ # for verification or authentication as string.
+ # Because we run in docker, and our tmpfs is isolated to us, we can just
+ # write out the certificates and keys to files and use their paths
+ config_args = {}
+ if self.verification_kp:
+ ca_cert_path = self.write_file(
+ f"{self.verification_kp.pk.hex}-cert.pem",
+ self.verification_kp.certificate_data,
+ )
+ config_args["ca_cert"] = ca_cert_path
+ if self.authentication_kp:
+ auth_cert_path = self.write_file(
+ f"{self.authentication_kp.pk.hex}-cert.pem",
+ self.authentication_kp.certificate_data,
+ )
+ auth_key_path = self.write_file(
+ f"{self.authentication_kp.pk.hex}-key.pem",
+ self.authentication_kp.key_data,
+ )
+ config_args["client_cert"] = (auth_cert_path, auth_key_path)
+ return TLSConfig(**config_args)
diff --git a/authentik/outposts/forms.py b/authentik/outposts/forms.py
new file mode 100644
index 000000000..812f7e5ac
--- /dev/null
+++ b/authentik/outposts/forms.py
@@ -0,0 +1,88 @@
+"""Outpost forms"""
+
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from authentik.admin.fields import CodeMirrorWidget, YAMLField
+from authentik.crypto.models import CertificateKeyPair
+from authentik.outposts.models import (
+ DockerServiceConnection,
+ KubernetesServiceConnection,
+ Outpost,
+ OutpostServiceConnection,
+)
+from authentik.providers.proxy.models import ProxyProvider
+
+
+class OutpostForm(forms.ModelForm):
+ """Outpost Form"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["providers"].queryset = ProxyProvider.objects.all()
+ self.fields[
+ "service_connection"
+ ].queryset = OutpostServiceConnection.objects.select_subclasses()
+
+ class Meta:
+
+ model = Outpost
+ fields = [
+ "name",
+ "type",
+ "service_connection",
+ "providers",
+ "_config",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "_config": CodeMirrorWidget,
+ }
+ field_classes = {
+ "_config": YAMLField,
+ }
+ labels = {"_config": _("Configuration")}
+
+
+class DockerServiceConnectionForm(forms.ModelForm):
+ """Docker service-connection form"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["tls_authentication"].queryset = CertificateKeyPair.objects.filter(
+ key_data__isnull=False
+ )
+
+ class Meta:
+
+ model = DockerServiceConnection
+ fields = ["name", "local", "url", "tls_verification", "tls_authentication"]
+ widgets = {
+ "name": forms.TextInput,
+ "url": forms.TextInput,
+ }
+ labels = {
+ "url": _("URL"),
+ "tls_verification": _("TLS Verification Certificate"),
+ "tls_authentication": _("TLS Authentication Certificate"),
+ }
+
+
+class KubernetesServiceConnectionForm(forms.ModelForm):
+ """Kubernetes service-connection form"""
+
+ class Meta:
+
+ model = KubernetesServiceConnection
+ fields = [
+ "name",
+ "local",
+ "kubeconfig",
+ ]
+ widgets = {
+ "name": forms.TextInput,
+ "kubeconfig": CodeMirrorWidget,
+ }
+ field_classes = {
+ "kubeconfig": YAMLField,
+ }
diff --git a/authentik/outposts/migrations/0001_initial.py b/authentik/outposts/migrations/0001_initial.py
new file mode 100644
index 000000000..ec5769d61
--- /dev/null
+++ b/authentik/outposts/migrations/0001_initial.py
@@ -0,0 +1,40 @@
+# Generated by Django 3.1 on 2020-08-25 20:45
+
+import uuid
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_core", "0008_auto_20200824_1532"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Outpost",
+ fields=[
+ (
+ "uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("name", models.TextField()),
+ (
+ "channels",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.TextField(), size=None
+ ),
+ ),
+ ("providers", models.ManyToManyField(to="authentik_core.Provider")),
+ ],
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0002_auto_20200826_1306.py b/authentik/outposts/migrations/0002_auto_20200826_1306.py
new file mode 100644
index 000000000..4a916f37d
--- /dev/null
+++ b/authentik/outposts/migrations/0002_auto_20200826_1306.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.1 on 2020-08-26 13:06
+
+from django.db import migrations, models
+
+import authentik.outposts.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_outposts", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="outpost",
+ name="_config",
+ field=models.JSONField(
+ default=authentik.outposts.models.default_outpost_config
+ ),
+ ),
+ migrations.AddField(
+ model_name="outpost",
+ name="type",
+ field=models.TextField(choices=[("proxy", "Proxy")], default="proxy"),
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0003_auto_20200827_2108.py b/authentik/outposts/migrations/0003_auto_20200827_2108.py
new file mode 100644
index 000000000..0c76de4c0
--- /dev/null
+++ b/authentik/outposts/migrations/0003_auto_20200827_2108.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.1 on 2020-08-27 21:08
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_outposts", "0002_auto_20200826_1306"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="outpost",
+ name="deployment_type",
+ field=models.TextField(
+ choices=[
+ ("docker_compose", "Docker Compose"),
+ ("kubernetes", "Kubernetes"),
+ ("custom", "Custom"),
+ ],
+ default="custom",
+ help_text="Select between authentik-managed deployment types or a custom deployment.",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="outpost",
+ name="channels",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=models.TextField(), default=list, size=None
+ ),
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0004_auto_20200830_1056.py b/authentik/outposts/migrations/0004_auto_20200830_1056.py
new file mode 100644
index 000000000..e4b9d3d74
--- /dev/null
+++ b/authentik/outposts/migrations/0004_auto_20200830_1056.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.1 on 2020-08-30 10:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_outposts", "0003_auto_20200827_2108"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="outpost",
+ name="deployment_type",
+ field=models.TextField(
+ choices=[("kubernetes", "Kubernetes"), ("custom", "Custom")],
+ default="custom",
+ help_text="Select between authentik-managed deployment types or a custom deployment.",
+ ),
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0005_auto_20200909_1733.py b/authentik/outposts/migrations/0005_auto_20200909_1733.py
new file mode 100644
index 000000000..9ec22e025
--- /dev/null
+++ b/authentik/outposts/migrations/0005_auto_20200909_1733.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.1.1 on 2020-09-09 17:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_outposts", "0004_auto_20200830_1056"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="outpost",
+ name="deployment_type",
+ field=models.TextField(
+ choices=[("custom", "Custom")],
+ default="custom",
+ help_text="Select between authentik-managed deployment types or a custom deployment.",
+ ),
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0006_auto_20201003_2239.py b/authentik/outposts/migrations/0006_auto_20201003_2239.py
new file mode 100644
index 000000000..ffc5bc0f7
--- /dev/null
+++ b/authentik/outposts/migrations/0006_auto_20201003_2239.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.1.2 on 2020-10-03 22:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_outposts", "0005_auto_20200909_1733"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="outpost",
+ name="deployment_type",
+ field=models.TextField(
+ choices=[
+ ("docker", "Docker"),
+ ("custom", "Custom"),
+ ],
+ default="custom",
+ help_text="Select between authentik-managed deployment types or a custom deployment.",
+ ),
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0007_remove_outpost_channels.py b/authentik/outposts/migrations/0007_remove_outpost_channels.py
new file mode 100644
index 000000000..e4b0950df
--- /dev/null
+++ b/authentik/outposts/migrations/0007_remove_outpost_channels.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1.2 on 2020-10-14 08:32
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_outposts", "0006_auto_20201003_2239"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="outpost",
+ name="channels",
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0008_auto_20201014_1547.py b/authentik/outposts/migrations/0008_auto_20201014_1547.py
new file mode 100644
index 000000000..bbed57b1f
--- /dev/null
+++ b/authentik/outposts/migrations/0008_auto_20201014_1547.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.1.2 on 2020-10-14 15:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_outposts", "0007_remove_outpost_channels"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="outpost",
+ name="deployment_type",
+ field=models.TextField(
+ choices=[
+ ("kubernetes", "Kubernetes"),
+ ("docker", "Docker"),
+ ("custom", "Custom"),
+ ],
+ default="custom",
+ help_text="Select between authentik-managed deployment types or a custom deployment.",
+ ),
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0009_fix_missing_token_identifier.py b/authentik/outposts/migrations/0009_fix_missing_token_identifier.py
new file mode 100644
index 000000000..c6a70abb6
--- /dev/null
+++ b/authentik/outposts/migrations/0009_fix_missing_token_identifier.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.1.2 on 2020-10-17 14:26
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ User = apps.get_model("authentik_core", "User")
+ Token = apps.get_model("authentik_core", "Token")
+ from authentik.outposts.models import Outpost
+
+ for outpost in (
+ Outpost.objects.using(schema_editor.connection.alias).all().only("pk")
+ ):
+ user_identifier = outpost.user_identifier
+ users = User.objects.filter(username=user_identifier)
+ if not users.exists():
+ continue
+ tokens = Token.objects.filter(user=users.first())
+ for token in tokens:
+ if token.identifier != outpost.token_identifier:
+ token.identifier = outpost.token_identifier
+ token.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0014_auto_20201018_1158"),
+ ("authentik_outposts", "0008_auto_20201014_1547"),
+ ]
+
+ operations = [
+ migrations.RunPython(fix_missing_token_identifier),
+ ]
diff --git a/authentik/outposts/migrations/0010_service_connection.py b/authentik/outposts/migrations/0010_service_connection.py
new file mode 100644
index 000000000..51a0c3283
--- /dev/null
+++ b/authentik/outposts/migrations/0010_service_connection.py
@@ -0,0 +1,168 @@
+# Generated by Django 3.1.3 on 2020-11-04 09:11
+
+import uuid
+
+import django.db.models.deletion
+from django.apps.registry import Apps
+from django.core.exceptions import FieldError
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+import authentik.lib.models
+
+
+def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ db_alias = schema_editor.connection.alias
+ Outpost = apps.get_model("authentik_outposts", "Outpost")
+ DockerServiceConnection = apps.get_model(
+ "authentik_outposts", "DockerServiceConnection"
+ )
+ KubernetesServiceConnection = apps.get_model(
+ "authentik_outposts", "KubernetesServiceConnection"
+ )
+
+ docker = DockerServiceConnection.objects.filter(local=True).first()
+ k8s = KubernetesServiceConnection.objects.filter(local=True).first()
+
+ try:
+ for outpost in (
+ Outpost.objects.using(db_alias).all().exclude(deployment_type="custom")
+ ):
+ if outpost.deployment_type == "kubernetes":
+ outpost.service_connection = k8s
+ elif outpost.deployment_type == "docker":
+ outpost.service_connection = docker
+ outpost.save()
+ except FieldError:
+ # This is triggered during e2e tests when this function is called on an already-upgraded
+ # schema
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_outposts", "0009_fix_missing_token_identifier"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="OutpostServiceConnection",
+ fields=[
+ (
+ "uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("name", models.TextField()),
+ (
+ "local",
+ models.BooleanField(
+ default=False,
+ help_text="If enabled, use the local connection. Required Docker socket/Kubernetes Integration",
+ unique=True,
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="DockerServiceConnection",
+ fields=[
+ (
+ "outpostserviceconnection_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_outposts.outpostserviceconnection",
+ ),
+ ),
+ ("url", models.TextField()),
+ ("tls", models.BooleanField()),
+ ],
+ bases=("authentik_outposts.outpostserviceconnection",),
+ ),
+ migrations.CreateModel(
+ name="KubernetesServiceConnection",
+ fields=[
+ (
+ "outpostserviceconnection_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_outposts.outpostserviceconnection",
+ ),
+ ),
+ ("kubeconfig", models.JSONField()),
+ ],
+ bases=("authentik_outposts.outpostserviceconnection",),
+ ),
+ migrations.AddField(
+ model_name="outpost",
+ name="service_connection",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="Select Service-Connection authentik should use to manage this outpost. Leave empty if authentik should not handle the deployment.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ to="authentik_outposts.outpostserviceconnection",
+ ),
+ ),
+ migrations.RunPython(migrate_to_service_connection),
+ migrations.RemoveField(
+ model_name="outpost",
+ name="deployment_type",
+ ),
+ migrations.AlterModelOptions(
+ name="dockerserviceconnection",
+ options={
+ "verbose_name": "Docker Service-Connection",
+ "verbose_name_plural": "Docker Service-Connections",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="kubernetesserviceconnection",
+ options={
+ "verbose_name": "Kubernetes Service-Connection",
+ "verbose_name_plural": "Kubernetes Service-Connections",
+ },
+ ),
+ migrations.AlterField(
+ model_name="outpost",
+ name="service_connection",
+ field=authentik.lib.models.InheritanceForeignKey(
+ blank=True,
+ default=None,
+ help_text="Select Service-Connection authentik should use to manage this outpost. Leave empty if authentik should not handle the deployment.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ to="authentik_outposts.outpostserviceconnection",
+ ),
+ ),
+ migrations.AlterModelOptions(
+ name="outpostserviceconnection",
+ options={
+ "verbose_name": "Outpost Service-Connection",
+ "verbose_name_plural": "Outpost Service-Connections",
+ },
+ ),
+ migrations.AlterField(
+ model_name="kubernetesserviceconnection",
+ name="kubeconfig",
+ field=models.JSONField(
+ default=None,
+ help_text="Paste your kubeconfig here. authentik will automatically use the currently selected context.",
+ ),
+ preserve_default=False,
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0011_docker_tls_auth.py b/authentik/outposts/migrations/0011_docker_tls_auth.py
new file mode 100644
index 000000000..0905d44af
--- /dev/null
+++ b/authentik/outposts/migrations/0011_docker_tls_auth.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.1.3 on 2020-11-18 21:51
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_crypto", "0002_create_self_signed_kp"),
+ ("authentik_outposts", "0010_service_connection"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="dockerserviceconnection",
+ name="tls",
+ ),
+ migrations.AddField(
+ model_name="dockerserviceconnection",
+ name="tls_authentication",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="Certificate/Key used for authentication. Can be left empty for no authentication.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ related_name="+",
+ to="authentik_crypto.certificatekeypair",
+ ),
+ ),
+ migrations.AddField(
+ model_name="dockerserviceconnection",
+ name="tls_verification",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="CA which the endpoint's Certificate is verified against. Can be left empty for no validation.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ related_name="+",
+ to="authentik_crypto.certificatekeypair",
+ ),
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0012_service_connection_non_unique.py b/authentik/outposts/migrations/0012_service_connection_non_unique.py
new file mode 100644
index 000000000..f7f7815ad
--- /dev/null
+++ b/authentik/outposts/migrations/0012_service_connection_non_unique.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1.3 on 2020-11-18 21:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_outposts", "0011_docker_tls_auth"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="outpostserviceconnection",
+ name="local",
+ field=models.BooleanField(
+ default=False,
+ help_text="If enabled, use the local connection. Required Docker socket/Kubernetes Integration",
+ ),
+ ),
+ ]
diff --git a/authentik/outposts/migrations/0013_auto_20201203_2009.py b/authentik/outposts/migrations/0013_auto_20201203_2009.py
new file mode 100644
index 000000000..58c67dcf9
--- /dev/null
+++ b/authentik/outposts/migrations/0013_auto_20201203_2009.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.1.4 on 2020-12-03 20:09
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def remove_pb_prefix_users(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ alias = schema_editor.connection.alias
+ User = apps.get_model("authentik_core", "User")
+ Outpost = apps.get_model("authentik_outposts", "Outpost")
+
+ for outpost in Outpost.objects.using(alias).all():
+ matching = User.objects.using(alias).filter(
+ username=f"pb-outpost-{outpost.uuid.hex}"
+ )
+ if matching.exists():
+ matching.delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0016_auto_20201202_2234"),
+ ("authentik_outposts", "0012_service_connection_non_unique"),
+ ]
+
+ operations = [
+ migrations.RunPython(remove_pb_prefix_users),
+ ]
diff --git a/passbook/outposts/migrations/__init__.py b/authentik/outposts/migrations/__init__.py
similarity index 100%
rename from passbook/outposts/migrations/__init__.py
rename to authentik/outposts/migrations/__init__.py
diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py
new file mode 100644
index 000000000..e21280d83
--- /dev/null
+++ b/authentik/outposts/models.py
@@ -0,0 +1,427 @@
+"""Outpost models"""
+from dataclasses import asdict, dataclass, field
+from datetime import datetime
+from typing import Dict, Iterable, List, Optional, Type, Union
+from uuid import uuid4
+
+from dacite import from_dict
+from django.core.cache import cache
+from django.db import models, transaction
+from django.db.models.base import Model
+from django.forms.models import ModelForm
+from django.http import HttpRequest
+from django.utils.translation import gettext_lazy as _
+from docker.client import DockerClient
+from docker.errors import DockerException
+from guardian.models import UserObjectPermission
+from guardian.shortcuts import assign_perm
+from kubernetes.client import VersionApi, VersionInfo
+from kubernetes.client.api_client import ApiClient
+from kubernetes.client.configuration import Configuration
+from kubernetes.client.exceptions import OpenApiException
+from kubernetes.config.config_exception import ConfigException
+from kubernetes.config.incluster_config import load_incluster_config
+from kubernetes.config.kube_config import load_kube_config_from_dict
+from model_utils.managers import InheritanceManager
+from packaging.version import LegacyVersion, Version, parse
+from structlog import get_logger
+from urllib3.exceptions import HTTPError
+
+from authentik import __version__
+from authentik.core.models import USER_ATTRIBUTE_SA, Provider, Token, TokenIntents, User
+from authentik.crypto.models import CertificateKeyPair
+from authentik.lib.config import CONFIG
+from authentik.lib.models import InheritanceForeignKey
+from authentik.lib.sentry import SentryIgnoredException
+from authentik.lib.utils.template import render_to_string
+from authentik.outposts.docker_tls import DockerInlineTLS
+
+OUR_VERSION = parse(__version__)
+OUTPOST_HELLO_INTERVAL = 10
+LOGGER = get_logger()
+
+
+class ServiceConnectionInvalid(SentryIgnoredException):
+ """"Exception raised when a Service Connection has invalid parameters"""
+
+
+@dataclass
+class OutpostConfig:
+ """Configuration an outpost uses to configure it self"""
+
+ authentik_host: str
+ authentik_host_insecure: bool = False
+
+ log_level: str = CONFIG.y("log_level")
+ error_reporting_enabled: bool = CONFIG.y_bool("error_reporting.enabled")
+ error_reporting_environment: str = CONFIG.y(
+ "error_reporting.environment", "customer"
+ )
+
+ kubernetes_replicas: int = field(default=1)
+ kubernetes_namespace: str = field(default="default")
+ kubernetes_ingress_annotations: Dict[str, str] = field(default_factory=dict)
+ kubernetes_ingress_secret_name: str = field(default="authentik-outpost")
+
+
+class OutpostModel(Model):
+ """Base model for providers that need more objects than just themselves"""
+
+ def get_required_objects(self) -> Iterable[models.Model]:
+ """Return a list of all required objects"""
+ return [self]
+
+ class Meta:
+
+ abstract = True
+
+
+class OutpostType(models.TextChoices):
+ """Outpost types, currently only the reverse proxy is available"""
+
+ PROXY = "proxy"
+
+
+def default_outpost_config():
+ """Get default outpost config"""
+ return asdict(OutpostConfig(authentik_host=""))
+
+
+@dataclass
+class OutpostServiceConnectionState:
+ """State of an Outpost Service Connection"""
+
+ version: str
+ healthy: bool
+
+
+class OutpostServiceConnection(models.Model):
+ """Connection details for an Outpost Controller, like Docker or Kubernetes"""
+
+ uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
+ name = models.TextField()
+
+ local = models.BooleanField(
+ default=False,
+ help_text=_(
+ (
+ "If enabled, use the local connection. Required Docker "
+ "socket/Kubernetes Integration"
+ )
+ ),
+ )
+
+ objects = InheritanceManager()
+
+ @property
+ def state(self) -> OutpostServiceConnectionState:
+ """Get state of service connection"""
+ state_key = f"outpost_service_connection_{self.pk.hex}"
+ state = cache.get(state_key, None)
+ if not state:
+ state = self._get_state()
+ cache.set(state_key, state, timeout=0)
+ return state
+
+ def _get_state(self) -> OutpostServiceConnectionState:
+ raise NotImplementedError
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ """Return Form class used to edit this object"""
+ raise NotImplementedError
+
+ class Meta:
+
+ verbose_name = _("Outpost Service-Connection")
+ verbose_name_plural = _("Outpost Service-Connections")
+
+
+class DockerServiceConnection(OutpostServiceConnection):
+ """Service Connection to a Docker endpoint"""
+
+ url = models.TextField()
+ tls_verification = models.ForeignKey(
+ CertificateKeyPair,
+ null=True,
+ blank=True,
+ default=None,
+ related_name="+",
+ on_delete=models.SET_DEFAULT,
+ help_text=_(
+ (
+ "CA which the endpoint's Certificate is verified against. "
+ "Can be left empty for no validation."
+ )
+ ),
+ )
+ tls_authentication = models.ForeignKey(
+ CertificateKeyPair,
+ null=True,
+ blank=True,
+ default=None,
+ related_name="+",
+ on_delete=models.SET_DEFAULT,
+ help_text=_(
+ "Certificate/Key used for authentication. Can be left empty for no authentication."
+ ),
+ )
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.outposts.forms import DockerServiceConnectionForm
+
+ return DockerServiceConnectionForm
+
+ def __str__(self) -> str:
+ return f"Docker Service-Connection {self.name}"
+
+ def client(self) -> DockerClient:
+ """Get DockerClient"""
+ try:
+ client = None
+ if self.local:
+ client = DockerClient.from_env()
+ else:
+ client = DockerClient(
+ base_url=self.url,
+ tls=DockerInlineTLS(
+ verification_kp=self.tls_verification,
+ authentication_kp=self.tls_authentication,
+ ).write(),
+ )
+ client.containers.list()
+ except DockerException as exc:
+ LOGGER.error(exc)
+ raise ServiceConnectionInvalid from exc
+ return client
+
+ def _get_state(self) -> OutpostServiceConnectionState:
+ try:
+ client = self.client()
+ return OutpostServiceConnectionState(
+ version=client.info()["ServerVersion"], healthy=True
+ )
+ except ServiceConnectionInvalid:
+ return OutpostServiceConnectionState(version="", healthy=False)
+
+ class Meta:
+
+ verbose_name = _("Docker Service-Connection")
+ verbose_name_plural = _("Docker Service-Connections")
+
+
+class KubernetesServiceConnection(OutpostServiceConnection):
+ """Service Connection to a Kubernetes cluster"""
+
+ kubeconfig = models.JSONField(
+ help_text=_(
+ (
+ "Paste your kubeconfig here. authentik will automatically use "
+ "the currently selected context."
+ )
+ )
+ )
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.outposts.forms import KubernetesServiceConnectionForm
+
+ return KubernetesServiceConnectionForm
+
+ def __str__(self) -> str:
+ return f"Kubernetes Service-Connection {self.name}"
+
+ def _get_state(self) -> OutpostServiceConnectionState:
+ try:
+ client = self.client()
+ api_instance = VersionApi(client)
+ version: VersionInfo = api_instance.get_code()
+ return OutpostServiceConnectionState(
+ version=version.git_version, healthy=True
+ )
+ except (OpenApiException, HTTPError):
+ return OutpostServiceConnectionState(version="", healthy=False)
+
+ def client(self) -> ApiClient:
+ """Get Kubernetes client configured from kubeconfig"""
+ config = Configuration()
+ try:
+ if self.local:
+ load_incluster_config(client_configuration=config)
+ else:
+ load_kube_config_from_dict(self.kubeconfig, client_configuration=config)
+ return ApiClient(config)
+ except ConfigException as exc:
+ raise ServiceConnectionInvalid from exc
+
+ class Meta:
+
+ verbose_name = _("Kubernetes Service-Connection")
+ verbose_name_plural = _("Kubernetes Service-Connections")
+
+
+class Outpost(models.Model):
+ """Outpost instance which manages a service user and token"""
+
+ uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
+ name = models.TextField()
+
+ type = models.TextField(choices=OutpostType.choices, default=OutpostType.PROXY)
+ service_connection = InheritanceForeignKey(
+ OutpostServiceConnection,
+ default=None,
+ null=True,
+ blank=True,
+ help_text=_(
+ (
+ "Select Service-Connection authentik should use to manage this outpost. "
+ "Leave empty if authentik should not handle the deployment."
+ )
+ ),
+ on_delete=models.SET_DEFAULT,
+ )
+
+ _config = models.JSONField(default=default_outpost_config)
+
+ providers = models.ManyToManyField(Provider)
+
+ @property
+ def config(self) -> OutpostConfig:
+ """Load config as OutpostConfig object"""
+ return from_dict(OutpostConfig, self._config)
+
+ @config.setter
+ def config(self, value):
+ """Dump config into json"""
+ self._config = asdict(value)
+
+ @property
+ def state_cache_prefix(self) -> str:
+ """Key by which the outposts status is saved"""
+ return f"outpost_{self.uuid.hex}_state"
+
+ @property
+ def state(self) -> List["OutpostState"]:
+ """Get outpost's health status"""
+ return OutpostState.for_outpost(self)
+
+ @property
+ def user_identifier(self):
+ """Username for service user"""
+ return f"ak-outpost-{self.uuid.hex}"
+
+ @property
+ def user(self) -> User:
+ """Get/create user with access to all required objects"""
+ users = User.objects.filter(username=self.user_identifier)
+ if not users.exists():
+ user: User = User.objects.create(username=self.user_identifier)
+ user.attributes[USER_ATTRIBUTE_SA] = True
+ user.set_unusable_password()
+ user.save()
+ else:
+ user = users.first()
+ # To ensure the user only has the correct permissions, we delete all of them and re-add
+ # the ones the user needs
+ with transaction.atomic():
+ UserObjectPermission.objects.filter(user=user).delete()
+ for model in self.get_required_objects():
+ code_name = f"{model._meta.app_label}.view_{model._meta.model_name}"
+ assign_perm(code_name, user, model)
+ return user
+
+ @property
+ def token_identifier(self) -> str:
+ """Get Token identifier"""
+ return f"ak-outpost-{self.pk}-api"
+
+ @property
+ def token(self) -> Token:
+ """Get/create token for auto-generated user"""
+ token = Token.filter_not_expired(user=self.user, intent=TokenIntents.INTENT_API)
+ if token.exists():
+ return token.first()
+ return Token.objects.create(
+ user=self.user,
+ identifier=self.token_identifier,
+ intent=TokenIntents.INTENT_API,
+ description=f"Autogenerated by authentik for Outpost {self.name}",
+ expiring=False,
+ )
+
+ def get_required_objects(self) -> Iterable[models.Model]:
+ """Get an iterator of all objects the user needs read access to"""
+ objects = [self]
+ for provider in (
+ Provider.objects.filter(outpost=self).select_related().select_subclasses()
+ ):
+ if isinstance(provider, OutpostModel):
+ objects.extend(provider.get_required_objects())
+ else:
+ objects.append(provider)
+ return objects
+
+ def html_deployment_view(self, request: HttpRequest) -> Optional[str]:
+ """return template and context modal to view token and other config info"""
+ return render_to_string(
+ "outposts/deployment_modal.html",
+ {"outpost": self, "full_url": request.build_absolute_uri("/")},
+ )
+
+ def __str__(self) -> str:
+ return f"Outpost {self.name}"
+
+
+@dataclass
+class OutpostState:
+ """Outpost instance state, last_seen and version"""
+
+ uid: str
+ last_seen: Optional[datetime] = field(default=None)
+ version: Optional[str] = field(default=None)
+ version_should: Union[Version, LegacyVersion] = field(default=OUR_VERSION)
+
+ _outpost: Optional[Outpost] = field(default=None)
+
+ @property
+ def version_outdated(self) -> bool:
+ """Check if outpost version matches our version"""
+ if not self.version:
+ return False
+ return parse(self.version) < OUR_VERSION
+
+ @staticmethod
+ def for_outpost(outpost: Outpost) -> List["OutpostState"]:
+ """Get all states for an outpost"""
+ keys = cache.keys(f"{outpost.state_cache_prefix}_*")
+ states = []
+ for key in keys:
+ channel = key.replace(f"{outpost.state_cache_prefix}_", "")
+ states.append(OutpostState.for_channel(outpost, channel))
+ return states
+
+ @staticmethod
+ def for_channel(outpost: Outpost, channel: str) -> "OutpostState":
+ """Get state for a single channel"""
+ key = f"{outpost.state_cache_prefix}_{channel}"
+ default_data = {"uid": channel}
+ data = cache.get(key, default_data)
+ if isinstance(data, str):
+ cache.delete(key)
+ data = default_data
+ state = from_dict(OutpostState, data)
+ state.uid = channel
+ # pylint: disable=protected-access
+ state._outpost = outpost
+ return state
+
+ def save(self, timeout=OUTPOST_HELLO_INTERVAL):
+ """Save current state to cache"""
+ full_key = f"{self._outpost.state_cache_prefix}_{self.uid}"
+ return cache.set(full_key, asdict(self), timeout=timeout)
+
+ def delete(self):
+ """Manually delete from cache, used on channel disconnect"""
+ full_key = f"{self._outpost.state_cache_prefix}_{self.uid}"
+ cache.delete(full_key)
diff --git a/authentik/outposts/settings.py b/authentik/outposts/settings.py
new file mode 100644
index 000000000..d6820a777
--- /dev/null
+++ b/authentik/outposts/settings.py
@@ -0,0 +1,15 @@
+"""Outposts Settings"""
+from celery.schedules import crontab
+
+CELERY_BEAT_SCHEDULE = {
+ "outposts_controller": {
+ "task": "authentik.outposts.tasks.outpost_controller_all",
+ "schedule": crontab(minute="*/5"),
+ "options": {"queue": "authentik_scheduled"},
+ },
+ "outposts_service_connection_check": {
+ "task": "authentik.outposts.tasks.outpost_service_connection_monitor",
+ "schedule": crontab(minute=0, hour="*"),
+ "options": {"queue": "authentik_scheduled"},
+ },
+}
diff --git a/authentik/outposts/signals.py b/authentik/outposts/signals.py
new file mode 100644
index 000000000..33bd66e58
--- /dev/null
+++ b/authentik/outposts/signals.py
@@ -0,0 +1,36 @@
+"""authentik outpost signals"""
+from django.db.models import Model
+from django.db.models.signals import post_save, pre_delete
+from django.dispatch import receiver
+from structlog import get_logger
+
+from authentik.lib.utils.reflection import class_to_path
+from authentik.outposts.models import Outpost
+from authentik.outposts.tasks import outpost_post_save, outpost_pre_delete
+
+LOGGER = get_logger()
+
+
+@receiver(post_save)
+# pylint: disable=unused-argument
+def post_save_update(sender, instance: Model, **_):
+ """If an Outpost is saved, Ensure that token is created/updated
+
+ If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved,
+ we send a message down the relevant OutpostModels WS connection to trigger an update"""
+ if instance.__module__ == "django.db.migrations.recorder":
+ return
+ if instance.__module__ == "__fake__":
+ return
+ outpost_post_save.delay(class_to_path(instance.__class__), instance.pk)
+
+
+@receiver(pre_delete, sender=Outpost)
+# pylint: disable=unused-argument
+def pre_delete_cleanup(sender, instance: Outpost, **_):
+ """Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
+ instance.user.delete()
+ # To ensure that deployment is cleaned up *consistently* we call the controller, and wait
+ # for it to finish. We don't want to call it in this thread, as we don't have the K8s
+ # credentials here
+ outpost_pre_delete.delay(instance.pk.hex).get()
diff --git a/authentik/outposts/tasks.py b/authentik/outposts/tasks.py
new file mode 100644
index 000000000..e0e455529
--- /dev/null
+++ b/authentik/outposts/tasks.py
@@ -0,0 +1,165 @@
+"""outpost tasks"""
+from typing import Any
+
+from asgiref.sync import async_to_sync
+from channels.layers import get_channel_layer
+from django.core.cache import cache
+from django.db.models.base import Model
+from django.utils.text import slugify
+from structlog import get_logger
+
+from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
+from authentik.lib.utils.reflection import path_to_class
+from authentik.outposts.controllers.base import ControllerException
+from authentik.outposts.models import (
+ DockerServiceConnection,
+ KubernetesServiceConnection,
+ Outpost,
+ OutpostModel,
+ OutpostServiceConnection,
+ OutpostState,
+ OutpostType,
+)
+from authentik.providers.proxy.controllers.docker import ProxyDockerController
+from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
+from authentik.root.celery import CELERY_APP
+
+LOGGER = get_logger()
+
+
+@CELERY_APP.task()
+def outpost_controller_all():
+ """Launch Controller for all Outposts which support it"""
+ for outpost in Outpost.objects.exclude(service_connection=None):
+ outpost_controller.delay(outpost.pk.hex)
+
+
+@CELERY_APP.task()
+def outpost_service_connection_state(state_pk: Any):
+ """Update cached state of a service connection"""
+ connection: OutpostServiceConnection = (
+ OutpostServiceConnection.objects.filter(pk=state_pk).select_subclasses().first()
+ )
+ cache.delete(f"outpost_service_connection_{connection.pk.hex}")
+ _ = connection.state
+
+
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def outpost_service_connection_monitor(self: MonitoredTask):
+ """Regularly check the state of Outpost Service Connections"""
+ for connection in OutpostServiceConnection.objects.select_subclasses():
+ cache.delete(f"outpost_service_connection_{connection.pk.hex}")
+ _ = connection.state
+ self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
+
+
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def outpost_controller(self: MonitoredTask, outpost_pk: str):
+ """Create/update/monitor the deployment of an Outpost"""
+ logs = []
+ outpost: Outpost = Outpost.objects.get(pk=outpost_pk)
+ self.set_uid(slugify(outpost.name))
+ try:
+ if outpost.type == OutpostType.PROXY:
+ service_connection = outpost.service_connection
+ if isinstance(service_connection, DockerServiceConnection):
+ logs = ProxyDockerController(outpost, service_connection).up_with_logs()
+ if isinstance(service_connection, KubernetesServiceConnection):
+ logs = ProxyKubernetesController(
+ outpost, service_connection
+ ).up_with_logs()
+ LOGGER.debug("---------------Outpost Controller logs starting----------------")
+ for log in logs:
+ LOGGER.debug(log)
+ LOGGER.debug("-----------------Outpost Controller logs end-------------------")
+ except ControllerException as exc:
+ self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
+ else:
+ self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, logs))
+
+
+@CELERY_APP.task()
+def outpost_pre_delete(outpost_pk: str):
+ """Delete outpost objects before deleting the DB Object"""
+ outpost = Outpost.objects.get(pk=outpost_pk)
+ if outpost.type == OutpostType.PROXY:
+ service_connection = outpost.service_connection
+ if isinstance(service_connection, DockerServiceConnection):
+ ProxyDockerController(outpost, service_connection).down()
+ if isinstance(service_connection, KubernetesServiceConnection):
+ ProxyKubernetesController(outpost, service_connection).down()
+
+
+@CELERY_APP.task()
+def outpost_post_save(model_class: str, model_pk: Any):
+ """If an Outpost is saved, Ensure that token is created/updated
+
+ If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved,
+ we send a message down the relevant OutpostModels WS connection to trigger an update"""
+ model: Model = path_to_class(model_class)
+ try:
+ instance = model.objects.get(pk=model_pk)
+ except model.DoesNotExist:
+ LOGGER.warning("Model does not exist", model=model, pk=model_pk)
+ return
+
+ if isinstance(instance, Outpost):
+ LOGGER.debug("Ensuring token for outpost", instance=instance)
+ _ = instance.token
+ LOGGER.debug("Trigger reconcile for outpost")
+ outpost_controller.delay(instance.pk)
+ return
+
+ if isinstance(instance, (OutpostModel, Outpost)):
+ LOGGER.debug(
+ "triggering outpost update from outpostmodel/outpost", instance=instance
+ )
+ outpost_send_update(instance)
+ return
+
+ if isinstance(instance, OutpostServiceConnection):
+ LOGGER.debug("triggering ServiceConnection state update", instance=instance)
+ outpost_service_connection_state.delay(instance.pk)
+
+ for field in instance._meta.get_fields():
+ # Each field is checked if it has a `related_model` attribute (when ForeginKeys or M2Ms)
+ # are used, and if it has a value
+ if not hasattr(field, "related_model"):
+ continue
+ if not field.related_model:
+ continue
+ if not issubclass(field.related_model, OutpostModel):
+ continue
+
+ field_name = f"{field.name}_set"
+ if not hasattr(instance, field_name):
+ continue
+
+ LOGGER.debug("triggering outpost update from from field", field=field.name)
+ # Because the Outpost Model has an M2M to Provider,
+ # we have to iterate over the entire QS
+ for reverse in getattr(instance, field_name).all():
+ outpost_send_update(reverse)
+
+
+def outpost_send_update(model_instace: Model):
+ """Send outpost update to all registered outposts, irregardless to which authentik
+ instance they are connected"""
+ channel_layer = get_channel_layer()
+ if isinstance(model_instace, OutpostModel):
+ for outpost in model_instace.outpost_set.all():
+ _outpost_single_update(outpost, channel_layer)
+ elif isinstance(model_instace, Outpost):
+ _outpost_single_update(model_instace, channel_layer)
+
+
+def _outpost_single_update(outpost: Outpost, layer=None):
+ """Update outpost instances connected to a single outpost"""
+ # Ensure token again, because this function is called when anything related to an
+ # OutpostModel is saved, so we can be sure permissions are right
+ _ = outpost.token
+ if not layer: # pragma: no cover
+ layer = get_channel_layer()
+ for state in OutpostState.for_outpost(outpost):
+ LOGGER.debug("sending update", channel=state.uid, outpost=outpost)
+ async_to_sync(layer.send)(state.uid, {"type": "event.update"})
diff --git a/authentik/outposts/templates/outposts/deployment_modal.html b/authentik/outposts/templates/outposts/deployment_modal.html
new file mode 100644
index 000000000..cbd29db90
--- /dev/null
+++ b/authentik/outposts/templates/outposts/deployment_modal.html
@@ -0,0 +1,43 @@
+{% load i18n %}
+
+
+
+ {% trans 'View Deployment Info' %}
+
+
+
diff --git a/authentik/outposts/tests.py b/authentik/outposts/tests.py
new file mode 100644
index 000000000..9491da0ba
--- /dev/null
+++ b/authentik/outposts/tests.py
@@ -0,0 +1,59 @@
+"""outpost tests"""
+from django.test import TestCase
+from guardian.models import UserObjectPermission
+
+from authentik.crypto.models import CertificateKeyPair
+from authentik.flows.models import Flow
+from authentik.outposts.models import Outpost, OutpostType
+from authentik.providers.proxy.models import ProxyProvider
+
+
+class OutpostTests(TestCase):
+ """Outpost Tests"""
+
+ def test_service_account_permissions(self):
+ """Test that the service account has correct permissions"""
+ provider: ProxyProvider = ProxyProvider.objects.create(
+ name="test",
+ internal_host="http://localhost",
+ external_host="http://localhost",
+ authorization_flow=Flow.objects.first(),
+ )
+ outpost: Outpost = Outpost.objects.create(
+ name="test",
+ type=OutpostType.PROXY,
+ )
+
+ # Before we add a provider, the user should only have access to the outpost
+ permissions = UserObjectPermission.objects.filter(user=outpost.user)
+ self.assertEqual(len(permissions), 1)
+ self.assertEqual(permissions[0].object_pk, str(outpost.pk))
+
+ # We add a provider, user should only have access to outpost and provider
+ outpost.providers.add(provider)
+ outpost.save()
+ permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by(
+ "content_type__model"
+ )
+ self.assertEqual(len(permissions), 2)
+ self.assertEqual(permissions[0].object_pk, str(outpost.pk))
+ self.assertEqual(permissions[1].object_pk, str(provider.pk))
+
+ # Provider requires a certificate-key-pair, user should have permissions for it
+ keypair = CertificateKeyPair.objects.first()
+ provider.certificate = keypair
+ provider.save()
+ permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by(
+ "content_type__model"
+ )
+ self.assertEqual(len(permissions), 3)
+ self.assertEqual(permissions[0].object_pk, str(keypair.pk))
+ self.assertEqual(permissions[1].object_pk, str(outpost.pk))
+ self.assertEqual(permissions[2].object_pk, str(provider.pk))
+
+ # Remove provider from outpost, user should only have access to outpost
+ outpost.providers.remove(provider)
+ outpost.save()
+ permissions = UserObjectPermission.objects.filter(user=outpost.user)
+ self.assertEqual(len(permissions), 1)
+ self.assertEqual(permissions[0].object_pk, str(outpost.pk))
diff --git a/authentik/outposts/urls.py b/authentik/outposts/urls.py
new file mode 100644
index 000000000..f6830ee50
--- /dev/null
+++ b/authentik/outposts/urls.py
@@ -0,0 +1,11 @@
+"""authentik outposts urls"""
+from django.urls import path
+
+from authentik.outposts.views import KubernetesManifestView, SetupView
+
+urlpatterns = [
+ path(
+ "/k8s/", KubernetesManifestView.as_view(), name="k8s-manifest"
+ ),
+ path("/", SetupView.as_view(), name="setup"),
+]
diff --git a/authentik/outposts/views.py b/authentik/outposts/views.py
new file mode 100644
index 000000000..f86062530
--- /dev/null
+++ b/authentik/outposts/views.py
@@ -0,0 +1,89 @@
+"""authentik outpost views"""
+from typing import Any, Dict, List
+
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.db.models import Model
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404
+from django.views import View
+from django.views.generic import TemplateView
+from guardian.shortcuts import get_objects_for_user
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.outposts.controllers.docker import DockerController
+from authentik.outposts.models import (
+ DockerServiceConnection,
+ KubernetesServiceConnection,
+ Outpost,
+ OutpostType,
+)
+from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
+
+LOGGER = get_logger()
+
+
+def get_object_for_user_or_404(user: User, perm: str, **filters) -> Model:
+ """Wrapper that combines get_objects_for_user and get_object_or_404"""
+ return get_object_or_404(get_objects_for_user(user, perm), **filters)
+
+
+class DockerComposeView(LoginRequiredMixin, View):
+ """Generate docker-compose yaml"""
+
+ def get(self, request: HttpRequest, outpost_pk: str) -> HttpResponse:
+ """Render docker-compose file"""
+ outpost: Outpost = get_object_for_user_or_404(
+ request.user,
+ "authentik_outposts.view_outpost",
+ pk=outpost_pk,
+ )
+ manifest = ""
+ if outpost.type == OutpostType.PROXY:
+ controller = DockerController(outpost, DockerServiceConnection())
+ manifest = controller.get_static_deployment()
+
+ return HttpResponse(manifest, content_type="text/vnd.yaml")
+
+
+class KubernetesManifestView(LoginRequiredMixin, View):
+ """Generate Kubernetes Deployment and SVC for proxy"""
+
+ def get(self, request: HttpRequest, outpost_pk: str) -> HttpResponse:
+ """Render deployment template"""
+ outpost: Outpost = get_object_for_user_or_404(
+ request.user,
+ "authentik_outposts.view_outpost",
+ pk=outpost_pk,
+ )
+ manifest = ""
+ if outpost.type == OutpostType.PROXY:
+ controller = ProxyKubernetesController(
+ outpost, KubernetesServiceConnection()
+ )
+ manifest = controller.get_static_deployment()
+
+ return HttpResponse(manifest, content_type="text/vnd.yaml")
+
+
+class SetupView(LoginRequiredMixin, TemplateView):
+ """Setup view"""
+
+ def get_template_names(self) -> List[str]:
+ allowed = ["dc", "custom", "k8s_manual", "k8s_integration"]
+ setup_type = self.request.GET.get("type", "dc")
+ if setup_type not in allowed:
+ setup_type = allowed[0]
+ return [f"outposts/setup_{setup_type}.html"]
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ outpost: Outpost = get_object_for_user_or_404(
+ self.request.user,
+ "authentik_outposts.view_outpost",
+ pk=self.kwargs["outpost_pk"],
+ )
+ kwargs.update(
+ {"host": self.request.build_absolute_uri("/"), "outpost": outpost}
+ )
+ return kwargs
diff --git a/passbook/policies/__init__.py b/authentik/policies/__init__.py
similarity index 100%
rename from passbook/policies/__init__.py
rename to authentik/policies/__init__.py
diff --git a/authentik/policies/api.py b/authentik/policies/api.py
new file mode 100644
index 000000000..4fb936343
--- /dev/null
+++ b/authentik/policies/api.py
@@ -0,0 +1,100 @@
+"""policy API Views"""
+from django.core.exceptions import ObjectDoesNotExist
+from rest_framework.serializers import (
+ ModelSerializer,
+ PrimaryKeyRelatedField,
+ SerializerMethodField,
+)
+from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
+
+from authentik.policies.forms import GENERAL_FIELDS
+from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
+
+
+class PolicyBindingModelForeignKey(PrimaryKeyRelatedField):
+ """rest_framework PrimaryKeyRelatedField which resolves
+ model_manager's InheritanceQuerySet"""
+
+ def use_pk_only_optimization(self):
+ return False
+
+ def to_internal_value(self, data):
+ if self.pk_field is not None:
+ data = self.pk_field.to_internal_value(data)
+ try:
+ # Due to inheritance, a direct DB lookup for the primary key
+ # won't return anything. This is because the direct lookup
+ # checks the PK of PolicyBindingModel (for example),
+ # but we get given the Primary Key of the inheriting class
+ for model in self.get_queryset().select_subclasses().all().select_related():
+ if model.pk == data:
+ return model
+ # as a fallback we still try a direct lookup
+ return self.get_queryset().get_subclass(pk=data)
+ except ObjectDoesNotExist:
+ self.fail("does_not_exist", pk_value=data)
+ except (TypeError, ValueError):
+ self.fail("incorrect_type", data_type=type(data).__name__)
+
+ def to_representation(self, value):
+ correct_model = PolicyBindingModel.objects.get_subclass(pbm_uuid=value.pbm_uuid)
+ return correct_model.pk
+
+
+class PolicySerializer(ModelSerializer):
+ """Policy Serializer"""
+
+ __type__ = SerializerMethodField(method_name="get_type")
+
+ def get_type(self, obj):
+ """Get object type so that we know which API Endpoint to use to get the full object"""
+ return obj._meta.object_name.lower().replace("policy", "")
+
+ def to_representation(self, instance: Policy):
+ # pyright: reportGeneralTypeIssues=false
+ if instance.__class__ == Policy:
+ return super().to_representation(instance)
+ return instance.serializer(instance=instance).data
+
+ class Meta:
+
+ model = Policy
+ fields = ["pk"] + GENERAL_FIELDS + ["__type__"]
+ depth = 3
+
+
+class PolicyViewSet(ReadOnlyModelViewSet):
+ """Policy Viewset"""
+
+ queryset = Policy.objects.all()
+ serializer_class = PolicySerializer
+
+ def get_queryset(self):
+ return Policy.objects.select_subclasses()
+
+
+class PolicyBindingSerializer(ModelSerializer):
+ """PolicyBinding Serializer"""
+
+ # Because we're not interested in the PolicyBindingModel's PK but rather the subclasses PK,
+ # we have to manually declare this field
+ target = PolicyBindingModelForeignKey(
+ queryset=PolicyBindingModel.objects.select_subclasses(),
+ required=True,
+ )
+
+ policy_obj = PolicySerializer(read_only=True, source="policy")
+
+ class Meta:
+
+ model = PolicyBinding
+ fields = ["pk", "policy", "policy_obj", "target", "enabled", "order", "timeout"]
+
+
+class PolicyBindingViewSet(ModelViewSet):
+ """PolicyBinding Viewset"""
+
+ queryset = PolicyBinding.objects.all()
+ serializer_class = PolicyBindingSerializer
+ filterset_fields = ["policy", "target", "enabled", "order", "timeout"]
+ search_fields = ["policy__name"]
diff --git a/authentik/policies/apps.py b/authentik/policies/apps.py
new file mode 100644
index 000000000..d36284fa2
--- /dev/null
+++ b/authentik/policies/apps.py
@@ -0,0 +1,15 @@
+"""authentik policies app config"""
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class AuthentikPoliciesConfig(AppConfig):
+ """authentik policies app config"""
+
+ name = "authentik.policies"
+ label = "authentik_policies"
+ verbose_name = "authentik Policies"
+
+ def ready(self):
+ import_module("authentik.policies.signals")
diff --git a/passbook/policies/dummy/__init__.py b/authentik/policies/dummy/__init__.py
similarity index 100%
rename from passbook/policies/dummy/__init__.py
rename to authentik/policies/dummy/__init__.py
diff --git a/authentik/policies/dummy/api.py b/authentik/policies/dummy/api.py
new file mode 100644
index 000000000..009524ab8
--- /dev/null
+++ b/authentik/policies/dummy/api.py
@@ -0,0 +1,21 @@
+"""Dummy Policy API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.policies.dummy.models import DummyPolicy
+from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS
+
+
+class DummyPolicySerializer(ModelSerializer):
+ """Dummy Policy Serializer"""
+
+ class Meta:
+ model = DummyPolicy
+ fields = GENERAL_SERIALIZER_FIELDS + ["result", "wait_min", "wait_max"]
+
+
+class DummyPolicyViewSet(ModelViewSet):
+ """Dummy Viewset"""
+
+ queryset = DummyPolicy.objects.all()
+ serializer_class = DummyPolicySerializer
diff --git a/authentik/policies/dummy/apps.py b/authentik/policies/dummy/apps.py
new file mode 100644
index 000000000..32792df9a
--- /dev/null
+++ b/authentik/policies/dummy/apps.py
@@ -0,0 +1,11 @@
+"""Authentik policy dummy app config"""
+
+from django.apps import AppConfig
+
+
+class AuthentikPolicyDummyConfig(AppConfig):
+ """Authentik policy_dummy app config"""
+
+ name = "authentik.policies.dummy"
+ label = "authentik_policies_dummy"
+ verbose_name = "authentik Policies.Dummy"
diff --git a/authentik/policies/dummy/forms.py b/authentik/policies/dummy/forms.py
new file mode 100644
index 000000000..bccdd107c
--- /dev/null
+++ b/authentik/policies/dummy/forms.py
@@ -0,0 +1,20 @@
+"""authentik Policy forms"""
+
+from django import forms
+from django.utils.translation import gettext as _
+
+from authentik.policies.dummy.models import DummyPolicy
+from authentik.policies.forms import GENERAL_FIELDS
+
+
+class DummyPolicyForm(forms.ModelForm):
+ """DummyPolicyForm Form"""
+
+ class Meta:
+
+ model = DummyPolicy
+ fields = GENERAL_FIELDS + ["result", "wait_min", "wait_max"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
+ labels = {"result": _("Allow user")}
diff --git a/authentik/policies/dummy/migrations/0001_initial.py b/authentik/policies/dummy/migrations/0001_initial.py
new file mode 100644
index 000000000..4da576a7e
--- /dev/null
+++ b/authentik/policies/dummy/migrations/0001_initial.py
@@ -0,0 +1,40 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_policies", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="DummyPolicy",
+ fields=[
+ (
+ "policy_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_policies.Policy",
+ ),
+ ),
+ ("result", models.BooleanField(default=False)),
+ ("wait_min", models.IntegerField(default=5)),
+ ("wait_max", models.IntegerField(default=30)),
+ ],
+ options={
+ "verbose_name": "Dummy Policy",
+ "verbose_name_plural": "Dummy Policies",
+ },
+ bases=("authentik_policies.policy",),
+ ),
+ ]
diff --git a/passbook/policies/dummy/migrations/__init__.py b/authentik/policies/dummy/migrations/__init__.py
similarity index 100%
rename from passbook/policies/dummy/migrations/__init__.py
rename to authentik/policies/dummy/migrations/__init__.py
diff --git a/authentik/policies/dummy/models.py b/authentik/policies/dummy/models.py
new file mode 100644
index 000000000..23fe29c2f
--- /dev/null
+++ b/authentik/policies/dummy/models.py
@@ -0,0 +1,50 @@
+"""Dummy policy"""
+from random import SystemRandom
+from time import sleep
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from rest_framework.serializers import BaseSerializer
+from structlog import get_logger
+
+from authentik.policies.models import Policy
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+LOGGER = get_logger()
+
+
+class DummyPolicy(Policy):
+ """Policy used for debugging the PolicyEngine. Returns a fixed result,
+ but takes a random time to process."""
+
+ __debug_only__ = True
+
+ result = models.BooleanField(default=False)
+ wait_min = models.IntegerField(default=5)
+ wait_max = models.IntegerField(default=30)
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.policies.dummy.api import DummyPolicySerializer
+
+ return DummyPolicySerializer
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.policies.dummy.forms import DummyPolicyForm
+
+ return DummyPolicyForm
+
+ def passes(self, request: PolicyRequest) -> PolicyResult:
+ """Wait random time then return result"""
+ wait = SystemRandom().randrange(self.wait_min, self.wait_max)
+ LOGGER.debug("Policy waiting", policy=self, delay=wait)
+ sleep(wait)
+ return PolicyResult(self.result, "dummy")
+
+ class Meta:
+
+ verbose_name = _("Dummy Policy")
+ verbose_name_plural = _("Dummy Policies")
diff --git a/authentik/policies/dummy/tests.py b/authentik/policies/dummy/tests.py
new file mode 100644
index 000000000..8d0cefd5c
--- /dev/null
+++ b/authentik/policies/dummy/tests.py
@@ -0,0 +1,39 @@
+"""dummy policy tests"""
+from django.test import TestCase
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.policies.dummy.forms import DummyPolicyForm
+from authentik.policies.dummy.models import DummyPolicy
+from authentik.policies.engine import PolicyRequest
+
+
+class TestDummyPolicy(TestCase):
+ """Test dummy policy"""
+
+ def setUp(self):
+ super().setUp()
+ self.request = PolicyRequest(user=get_anonymous_user())
+
+ def test_policy(self):
+ """test policy .passes"""
+ policy: DummyPolicy = DummyPolicy.objects.create(
+ name="dummy", wait_min=1, wait_max=2
+ )
+ result = policy.passes(self.request)
+ self.assertFalse(result.passing)
+ self.assertEqual(result.messages, ("dummy",))
+
+ def test_form(self):
+ """test form"""
+ form = DummyPolicyForm(
+ data={
+ "name": "dummy",
+ "negate": False,
+ "order": 0,
+ "timeout": 1,
+ "result": True,
+ "wait_min": 1,
+ "wait_max": 2,
+ }
+ )
+ self.assertTrue(form.is_valid())
diff --git a/authentik/policies/engine.py b/authentik/policies/engine.py
new file mode 100644
index 000000000..9e8dda5e2
--- /dev/null
+++ b/authentik/policies/engine.py
@@ -0,0 +1,135 @@
+"""authentik policy engine"""
+from multiprocessing import Pipe, set_start_method
+from multiprocessing.connection import Connection
+from typing import Iterator, List, Optional
+
+from django.core.cache import cache
+from django.http import HttpRequest
+from sentry_sdk.hub import Hub
+from sentry_sdk.tracing import Span
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
+from authentik.policies.process import PolicyProcess, cache_key
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+LOGGER = get_logger()
+# This is only really needed for macOS, because Python 3.8 changed the default to spawn
+# spawn causes issues with objects that aren't picklable, and also the django setup
+set_start_method("fork")
+
+
+class PolicyProcessInfo:
+ """Dataclass to hold all information and communication channels to a process"""
+
+ process: PolicyProcess
+ connection: Connection
+ result: Optional[PolicyResult]
+ binding: PolicyBinding
+
+ def __init__(
+ self, process: PolicyProcess, connection: Connection, binding: PolicyBinding
+ ):
+ self.process = process
+ self.connection = connection
+ self.binding = binding
+ self.result = None
+
+
+class PolicyEngine:
+ """Orchestrate policy checking, launch tasks and return result"""
+
+ use_cache: bool
+ request: PolicyRequest
+
+ __pbm: PolicyBindingModel
+ __cached_policies: List[PolicyResult]
+ __processes: List[PolicyProcessInfo]
+
+ def __init__(
+ self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None
+ ):
+ if not isinstance(pbm, PolicyBindingModel): # pragma: no cover
+ raise ValueError(f"{pbm} is not instance of PolicyBindingModel")
+ self.__pbm = pbm
+ self.request = PolicyRequest(user)
+ if request:
+ self.request.http_request = request
+ self.__cached_policies = []
+ self.__processes = []
+ self.use_cache = True
+
+ def _iter_bindings(self) -> Iterator[PolicyBinding]:
+ """Make sure all Policies are their respective classes"""
+ return PolicyBinding.objects.filter(target=self.__pbm, enabled=True).order_by(
+ "order"
+ )
+
+ def _check_policy_type(self, policy: Policy):
+ """Check policy type, make sure it's not the root class as that has no logic implemented"""
+ # pyright: reportGeneralTypeIssues=false
+ if policy.__class__ == Policy:
+ raise TypeError(f"Policy '{policy}' is root type")
+
+ def build(self) -> "PolicyEngine":
+ """Build wrapper which monitors performance"""
+ with Hub.current.start_span(op="policy.engine.build") as span:
+ span: Span
+ span.set_data("pbm", self.__pbm)
+ span.set_data("request", self.request)
+ for binding in self._iter_bindings():
+ self._check_policy_type(binding.policy)
+ key = cache_key(binding, self.request)
+ cached_policy = cache.get(key, None)
+ if cached_policy and self.use_cache:
+ LOGGER.debug(
+ "P_ENG: Taking result from cache",
+ policy=binding.policy,
+ cache_key=key,
+ )
+ self.__cached_policies.append(cached_policy)
+ continue
+ LOGGER.debug("P_ENG: Evaluating policy", policy=binding.policy)
+ our_end, task_end = Pipe(False)
+ task = PolicyProcess(binding, self.request, task_end)
+ LOGGER.debug("P_ENG: Starting Process", policy=binding.policy)
+ task.start()
+ self.__processes.append(
+ PolicyProcessInfo(process=task, connection=our_end, binding=binding)
+ )
+ # If all policies are cached, we have an empty list here.
+ for proc_info in self.__processes:
+ proc_info.process.join(proc_info.binding.timeout)
+ # Only call .recv() if no result is saved, otherwise we just deadlock here
+ if not proc_info.result:
+ proc_info.result = proc_info.connection.recv()
+ return self
+
+ @property
+ def result(self) -> PolicyResult:
+ """Get policy-checking result"""
+ process_results: List[PolicyResult] = [
+ x.result for x in self.__processes if x.result
+ ]
+ final_result = PolicyResult(False)
+ final_result.messages = []
+ final_result.source_results = list(process_results + self.__cached_policies)
+ for result in process_results + self.__cached_policies:
+ LOGGER.debug(
+ "P_ENG: result", passing=result.passing, messages=result.messages
+ )
+ if result.messages:
+ final_result.messages.extend(result.messages)
+ if not result.passing:
+ final_result.messages = tuple(final_result.messages)
+ final_result.passing = False
+ return final_result
+ final_result.messages = tuple(final_result.messages)
+ final_result.passing = True
+ return final_result
+
+ @property
+ def passing(self) -> bool:
+ """Only get true/false if user passes"""
+ return self.result.passing
diff --git a/authentik/policies/exceptions.py b/authentik/policies/exceptions.py
new file mode 100644
index 000000000..994095ff4
--- /dev/null
+++ b/authentik/policies/exceptions.py
@@ -0,0 +1,6 @@
+"""policy exceptions"""
+from authentik.lib.sentry import SentryIgnoredException
+
+
+class PolicyException(SentryIgnoredException):
+ """Exception that should be raised during Policy Evaluation, and can be recovered from."""
diff --git a/passbook/policies/expiry/__init__.py b/authentik/policies/expiry/__init__.py
similarity index 100%
rename from passbook/policies/expiry/__init__.py
rename to authentik/policies/expiry/__init__.py
diff --git a/authentik/policies/expiry/api.py b/authentik/policies/expiry/api.py
new file mode 100644
index 000000000..820ca7f63
--- /dev/null
+++ b/authentik/policies/expiry/api.py
@@ -0,0 +1,21 @@
+"""Password Expiry Policy API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.policies.expiry.models import PasswordExpiryPolicy
+from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS
+
+
+class PasswordExpiryPolicySerializer(ModelSerializer):
+ """Password Expiry Policy Serializer"""
+
+ class Meta:
+ model = PasswordExpiryPolicy
+ fields = GENERAL_SERIALIZER_FIELDS + ["days", "deny_only"]
+
+
+class PasswordExpiryPolicyViewSet(ModelViewSet):
+ """Password Expiry Viewset"""
+
+ queryset = PasswordExpiryPolicy.objects.all()
+ serializer_class = PasswordExpiryPolicySerializer
diff --git a/authentik/policies/expiry/apps.py b/authentik/policies/expiry/apps.py
new file mode 100644
index 000000000..db29f9fce
--- /dev/null
+++ b/authentik/policies/expiry/apps.py
@@ -0,0 +1,11 @@
+"""Authentik policy_expiry app config"""
+
+from django.apps import AppConfig
+
+
+class AuthentikPolicyExpiryConfig(AppConfig):
+ """Authentik policy_expiry app config"""
+
+ name = "authentik.policies.expiry"
+ label = "authentik_policies_expiry"
+ verbose_name = "authentik Policies.Expiry"
diff --git a/authentik/policies/expiry/forms.py b/authentik/policies/expiry/forms.py
new file mode 100644
index 000000000..223c00e76
--- /dev/null
+++ b/authentik/policies/expiry/forms.py
@@ -0,0 +1,22 @@
+"""authentik PasswordExpiry Policy forms"""
+
+from django import forms
+from django.utils.translation import gettext as _
+
+from authentik.policies.expiry.models import PasswordExpiryPolicy
+from authentik.policies.forms import GENERAL_FIELDS
+
+
+class PasswordExpiryPolicyForm(forms.ModelForm):
+ """Edit PasswordExpiryPolicy instances"""
+
+ class Meta:
+
+ model = PasswordExpiryPolicy
+ fields = GENERAL_FIELDS + ["days", "deny_only"]
+ widgets = {
+ "name": forms.TextInput(),
+ "order": forms.NumberInput(),
+ "days": forms.NumberInput(),
+ }
+ labels = {"deny_only": _("Only fail the policy, don't set user's password.")}
diff --git a/authentik/policies/expiry/migrations/0001_initial.py b/authentik/policies/expiry/migrations/0001_initial.py
new file mode 100644
index 000000000..401b6bfad
--- /dev/null
+++ b/authentik/policies/expiry/migrations/0001_initial.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_policies", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="PasswordExpiryPolicy",
+ fields=[
+ (
+ "policy_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_policies.Policy",
+ ),
+ ),
+ ("deny_only", models.BooleanField(default=False)),
+ ("days", models.IntegerField()),
+ ],
+ options={
+ "verbose_name": "Password Expiry Policy",
+ "verbose_name_plural": "Password Expiry Policies",
+ },
+ bases=("authentik_policies.policy",),
+ ),
+ ]
diff --git a/passbook/policies/expiry/migrations/__init__.py b/authentik/policies/expiry/migrations/__init__.py
similarity index 100%
rename from passbook/policies/expiry/migrations/__init__.py
rename to authentik/policies/expiry/migrations/__init__.py
diff --git a/authentik/policies/expiry/models.py b/authentik/policies/expiry/models.py
new file mode 100644
index 000000000..d9c1c4d2e
--- /dev/null
+++ b/authentik/policies/expiry/models.py
@@ -0,0 +1,62 @@
+"""authentik password_expiry_policy Models"""
+from datetime import timedelta
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.timezone import now
+from django.utils.translation import gettext as _
+from rest_framework.serializers import BaseSerializer
+from structlog import get_logger
+
+from authentik.policies.models import Policy
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+LOGGER = get_logger()
+
+
+class PasswordExpiryPolicy(Policy):
+ """If password change date is more than x days in the past, invalidate the user's password
+ and show a notice"""
+
+ deny_only = models.BooleanField(default=False)
+ days = models.IntegerField()
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.policies.expiry.api import PasswordExpiryPolicySerializer
+
+ return PasswordExpiryPolicySerializer
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.policies.expiry.forms import PasswordExpiryPolicyForm
+
+ return PasswordExpiryPolicyForm
+
+ def passes(self, request: PolicyRequest) -> PolicyResult:
+ """If password change date is more than x days in the past, call set_unusable_password
+ and show a notice"""
+ actual_days = (now() - request.user.password_change_date).days
+ days_since_expiry = (
+ now() - (request.user.password_change_date + timedelta(days=self.days))
+ ).days
+ if actual_days >= self.days:
+ if not self.deny_only:
+ request.user.set_unusable_password()
+ request.user.save()
+ message = _(
+ (
+ "Password expired %(days)d days ago. "
+ "Please update your password."
+ )
+ % {"days": days_since_expiry}
+ )
+ return PolicyResult(False, message)
+ return PolicyResult(False, _("Password has expired."))
+ return PolicyResult(True)
+
+ class Meta:
+
+ verbose_name = _("Password Expiry Policy")
+ verbose_name_plural = _("Password Expiry Policies")
diff --git a/passbook/policies/expression/__init__.py b/authentik/policies/expression/__init__.py
similarity index 100%
rename from passbook/policies/expression/__init__.py
rename to authentik/policies/expression/__init__.py
diff --git a/authentik/policies/expression/api.py b/authentik/policies/expression/api.py
new file mode 100644
index 000000000..c02f945a2
--- /dev/null
+++ b/authentik/policies/expression/api.py
@@ -0,0 +1,21 @@
+"""Expression Policy API"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.policies.expression.models import ExpressionPolicy
+from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS
+
+
+class ExpressionPolicySerializer(ModelSerializer):
+ """Group Membership Policy Serializer"""
+
+ class Meta:
+ model = ExpressionPolicy
+ fields = GENERAL_SERIALIZER_FIELDS + ["expression"]
+
+
+class ExpressionPolicyViewSet(ModelViewSet):
+ """Source Viewset"""
+
+ queryset = ExpressionPolicy.objects.all()
+ serializer_class = ExpressionPolicySerializer
diff --git a/authentik/policies/expression/apps.py b/authentik/policies/expression/apps.py
new file mode 100644
index 000000000..de7df61f9
--- /dev/null
+++ b/authentik/policies/expression/apps.py
@@ -0,0 +1,11 @@
+"""Authentik policy_expression app config"""
+
+from django.apps import AppConfig
+
+
+class AuthentikPolicyExpressionConfig(AppConfig):
+ """Authentik policy_expression app config"""
+
+ name = "authentik.policies.expression"
+ label = "authentik_policies_expression"
+ verbose_name = "authentik Policies.Expression"
diff --git a/authentik/policies/expression/evaluator.py b/authentik/policies/expression/evaluator.py
new file mode 100644
index 000000000..9dfba8c3c
--- /dev/null
+++ b/authentik/policies/expression/evaluator.py
@@ -0,0 +1,72 @@
+"""authentik expression policy evaluator"""
+from ipaddress import ip_address, ip_network
+from typing import List
+
+from django.http import HttpRequest
+from structlog import get_logger
+
+from authentik.flows.planner import PLAN_CONTEXT_SSO
+from authentik.lib.expression.evaluator import BaseEvaluator
+from authentik.lib.utils.http import get_client_ip
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+LOGGER = get_logger()
+
+
+class PolicyEvaluator(BaseEvaluator):
+ """Validate and evaluate python-based expressions"""
+
+ _messages: List[str]
+
+ def __init__(self, policy_name: str):
+ super().__init__()
+ self._messages = []
+ self._context["ak_message"] = self.expr_func_message
+ self._context["ip_address"] = ip_address
+ self._context["ip_network"] = ip_network
+ self._filename = policy_name or "PolicyEvaluator"
+
+ def expr_func_message(self, message: str):
+ """Wrapper to append to messages list, which is returned with PolicyResult"""
+ self._messages.append(message)
+
+ def set_policy_request(self, request: PolicyRequest):
+ """Update context based on policy request (if http request is given, update that too)"""
+ # update website/docs/policies/expression.md
+ self._context["ak_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
+ if request.http_request:
+ self.set_http_request(request.http_request)
+ self._context["request"] = request
+ self._context["context"] = request.context
+
+ def set_http_request(self, request: HttpRequest):
+ """Update context based on http request"""
+ # update website/docs/policies/expression.md
+ self._context["ak_client_ip"] = ip_address(
+ get_client_ip(request) or "255.255.255.255"
+ )
+ self._context["request"] = request
+
+ def evaluate(self, expression_source: str) -> PolicyResult:
+ """Parse and evaluate expression. Policy is expected to return a truthy object.
+ Messages can be added using 'do ak_message()'."""
+ try:
+ result = super().evaluate(expression_source)
+ except (ValueError, SyntaxError) as exc:
+ return PolicyResult(False, str(exc))
+ except Exception as exc: # pylint: disable=broad-except
+ LOGGER.warning("Expression error", exc=exc)
+ return PolicyResult(False, str(exc))
+ else:
+ policy_result = PolicyResult(False)
+ policy_result.messages = tuple(self._messages)
+ if result is None:
+ LOGGER.warning(
+ "Expression policy returned None",
+ src=expression_source,
+ req=self._context,
+ )
+ policy_result.passing = False
+ if result:
+ policy_result.passing = bool(result)
+ return policy_result
diff --git a/authentik/policies/expression/forms.py b/authentik/policies/expression/forms.py
new file mode 100644
index 000000000..505c6ec37
--- /dev/null
+++ b/authentik/policies/expression/forms.py
@@ -0,0 +1,31 @@
+"""authentik Expression Policy forms"""
+
+from django import forms
+
+from authentik.admin.fields import CodeMirrorWidget
+from authentik.policies.expression.evaluator import PolicyEvaluator
+from authentik.policies.expression.models import ExpressionPolicy
+from authentik.policies.forms import GENERAL_FIELDS
+
+
+class ExpressionPolicyForm(forms.ModelForm):
+ """ExpressionPolicy Form"""
+
+ template_name = "policy/expression/form.html"
+
+ def clean_expression(self):
+ """Test Syntax"""
+ expression = self.cleaned_data.get("expression")
+ PolicyEvaluator(self.instance.name).validate(expression)
+ return expression
+
+ class Meta:
+
+ model = ExpressionPolicy
+ fields = GENERAL_FIELDS + [
+ "expression",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "expression": CodeMirrorWidget(mode="python"),
+ }
diff --git a/authentik/policies/expression/migrations/0001_initial.py b/authentik/policies/expression/migrations/0001_initial.py
new file mode 100644
index 000000000..2087532a1
--- /dev/null
+++ b/authentik/policies/expression/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_policies", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ExpressionPolicy",
+ fields=[
+ (
+ "policy_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_policies.Policy",
+ ),
+ ),
+ ("expression", models.TextField()),
+ ],
+ options={
+ "verbose_name": "Expression Policy",
+ "verbose_name_plural": "Expression Policies",
+ },
+ bases=("authentik_policies.policy",),
+ ),
+ ]
diff --git a/authentik/policies/expression/migrations/0002_auto_20200926_1156.py b/authentik/policies/expression/migrations/0002_auto_20200926_1156.py
new file mode 100644
index 000000000..0a9f1ddcc
--- /dev/null
+++ b/authentik/policies/expression/migrations/0002_auto_20200926_1156.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.1.1 on 2020-09-26 11:56
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def remove_pb_flow_plan(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ ExpressionPolicy = apps.get_model(
+ "authentik_policies_expression", "ExpressionPolicy"
+ )
+
+ db_alias = schema_editor.connection.alias
+
+ for policy in ExpressionPolicy.objects.using(db_alias).all():
+ policy.expression = policy.expression.replace("pb_flow_plan.", "context.")
+ policy.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_policies_expression", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RunPython(remove_pb_flow_plan),
+ ]
diff --git a/authentik/policies/expression/migrations/0003_auto_20201203_1223.py b/authentik/policies/expression/migrations/0003_auto_20201203_1223.py
new file mode 100644
index 000000000..f0a0c4086
--- /dev/null
+++ b/authentik/policies/expression/migrations/0003_auto_20201203_1223.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.1.3 on 2020-12-03 12:23
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def replace_pb_prefix(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ ExpressionPolicy = apps.get_model(
+ "authentik_policies_expression", "ExpressionPolicy"
+ )
+
+ db_alias = schema_editor.connection.alias
+
+ for policy in ExpressionPolicy.objects.using(db_alias).all():
+ # Because the previous migration had a broken replace, we have to replace here again
+ policy.expression = policy.expression.replace("pb_flow_plan.", "context.")
+ policy.expression = policy.expression.replace(
+ "pb_is_sso_flow", "ak_is_sso_flow"
+ )
+ policy.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_policies_expression", "0002_auto_20200926_1156"),
+ ]
+
+ operations = [
+ migrations.RunPython(replace_pb_prefix),
+ ]
diff --git a/passbook/policies/expression/migrations/__init__.py b/authentik/policies/expression/migrations/__init__.py
similarity index 100%
rename from passbook/policies/expression/migrations/__init__.py
rename to authentik/policies/expression/migrations/__init__.py
diff --git a/authentik/policies/expression/models.py b/authentik/policies/expression/models.py
new file mode 100644
index 000000000..66e92eb17
--- /dev/null
+++ b/authentik/policies/expression/models.py
@@ -0,0 +1,44 @@
+"""authentik expression Policy Models"""
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext as _
+from rest_framework.serializers import BaseSerializer
+
+from authentik.policies.expression.evaluator import PolicyEvaluator
+from authentik.policies.models import Policy
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+
+class ExpressionPolicy(Policy):
+ """Execute arbitrary Python code to implement custom checks and validation."""
+
+ expression = models.TextField()
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.policies.expression.api import ExpressionPolicySerializer
+
+ return ExpressionPolicySerializer
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.policies.expression.forms import ExpressionPolicyForm
+
+ return ExpressionPolicyForm
+
+ def passes(self, request: PolicyRequest) -> PolicyResult:
+ """Evaluate and render expression. Returns PolicyResult(false) on error."""
+ evaluator = PolicyEvaluator(self.name)
+ evaluator.set_policy_request(request)
+ return evaluator.evaluate(self.expression)
+
+ def save(self, *args, **kwargs):
+ PolicyEvaluator(self.name).validate(self.expression)
+ return super().save(*args, **kwargs)
+
+ class Meta:
+
+ verbose_name = _("Expression Policy")
+ verbose_name_plural = _("Expression Policies")
diff --git a/authentik/policies/expression/templates/policy/expression/form.html b/authentik/policies/expression/templates/policy/expression/form.html
new file mode 100644
index 000000000..c9da91d5f
--- /dev/null
+++ b/authentik/policies/expression/templates/policy/expression/form.html
@@ -0,0 +1,14 @@
+{% extends "generic/form.html" %}
+
+{% load i18n %}
+
+{% block beneath_form %}
+
+{% endblock %}
diff --git a/authentik/policies/expression/tests.py b/authentik/policies/expression/tests.py
new file mode 100644
index 000000000..8cd51bccc
--- /dev/null
+++ b/authentik/policies/expression/tests.py
@@ -0,0 +1,62 @@
+"""evaluator tests"""
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.policies.expression.evaluator import PolicyEvaluator
+from authentik.policies.types import PolicyRequest
+
+
+class TestEvaluator(TestCase):
+ """Evaluator tests"""
+
+ def setUp(self):
+ self.request = PolicyRequest(user=get_anonymous_user())
+
+ def test_valid(self):
+ """test simple value expression"""
+ template = "return True"
+ evaluator = PolicyEvaluator("test")
+ evaluator.set_policy_request(self.request)
+ self.assertEqual(evaluator.evaluate(template).passing, True)
+
+ def test_messages(self):
+ """test expression with message return"""
+ template = 'ak_message("some message");return False'
+ evaluator = PolicyEvaluator("test")
+ evaluator.set_policy_request(self.request)
+ result = evaluator.evaluate(template)
+ self.assertEqual(result.passing, False)
+ self.assertEqual(result.messages, ("some message",))
+
+ def test_invalid_syntax(self):
+ """test invalid syntax"""
+ template = ";"
+ evaluator = PolicyEvaluator("test")
+ evaluator.set_policy_request(self.request)
+ result = evaluator.evaluate(template)
+ self.assertEqual(result.passing, False)
+ self.assertEqual(result.messages, ("invalid syntax (test, line 3)",))
+
+ def test_undefined(self):
+ """test undefined result"""
+ template = "{{ foo.bar }}"
+ evaluator = PolicyEvaluator("test")
+ evaluator.set_policy_request(self.request)
+ result = evaluator.evaluate(template)
+ self.assertEqual(result.passing, False)
+ self.assertEqual(result.messages, ("name 'foo' is not defined",))
+
+ def test_validate(self):
+ """test validate"""
+ template = "True"
+ evaluator = PolicyEvaluator("test")
+ result = evaluator.validate(template)
+ self.assertEqual(result, True)
+
+ def test_validate_invalid(self):
+ """test validate"""
+ template = ";"
+ evaluator = PolicyEvaluator("test")
+ with self.assertRaises(ValidationError):
+ evaluator.validate(template)
diff --git a/authentik/policies/forms.py b/authentik/policies/forms.py
new file mode 100644
index 000000000..78a5e0daa
--- /dev/null
+++ b/authentik/policies/forms.py
@@ -0,0 +1,26 @@
+"""General fields"""
+
+from django import forms
+
+from authentik.lib.widgets import GroupedModelChoiceField
+from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
+
+GENERAL_FIELDS = ["name"]
+GENERAL_SERIALIZER_FIELDS = ["pk", "name"]
+
+
+class PolicyBindingForm(forms.ModelForm):
+ """Form to edit Policy to PolicyBindingModel Binding"""
+
+ target = GroupedModelChoiceField(
+ queryset=PolicyBindingModel.objects.all().select_subclasses(),
+ to_field_name="pbm_uuid",
+ )
+ policy = GroupedModelChoiceField(
+ queryset=Policy.objects.all().select_subclasses(),
+ )
+
+ class Meta:
+
+ model = PolicyBinding
+ fields = ["enabled", "policy", "target", "order", "timeout"]
diff --git a/passbook/policies/group_membership/__init__.py b/authentik/policies/group_membership/__init__.py
similarity index 100%
rename from passbook/policies/group_membership/__init__.py
rename to authentik/policies/group_membership/__init__.py
diff --git a/authentik/policies/group_membership/api.py b/authentik/policies/group_membership/api.py
new file mode 100644
index 000000000..6aa5e2986
--- /dev/null
+++ b/authentik/policies/group_membership/api.py
@@ -0,0 +1,23 @@
+"""Group Membership Policy API"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS
+from authentik.policies.group_membership.models import GroupMembershipPolicy
+
+
+class GroupMembershipPolicySerializer(ModelSerializer):
+ """Group Membership Policy Serializer"""
+
+ class Meta:
+ model = GroupMembershipPolicy
+ fields = GENERAL_SERIALIZER_FIELDS + [
+ "group",
+ ]
+
+
+class GroupMembershipPolicyViewSet(ModelViewSet):
+ """Group Membership Policy Viewset"""
+
+ queryset = GroupMembershipPolicy.objects.all()
+ serializer_class = GroupMembershipPolicySerializer
diff --git a/authentik/policies/group_membership/apps.py b/authentik/policies/group_membership/apps.py
new file mode 100644
index 000000000..fa3acbffb
--- /dev/null
+++ b/authentik/policies/group_membership/apps.py
@@ -0,0 +1,11 @@
+"""authentik Group Membership policy app config"""
+
+from django.apps import AppConfig
+
+
+class AuthentikPoliciesGroupMembershipConfig(AppConfig):
+ """authentik Group Membership policy app config"""
+
+ name = "authentik.policies.group_membership"
+ label = "authentik_policies_group_membership"
+ verbose_name = "authentik Policies.Group Membership"
diff --git a/authentik/policies/group_membership/forms.py b/authentik/policies/group_membership/forms.py
new file mode 100644
index 000000000..250f74ca6
--- /dev/null
+++ b/authentik/policies/group_membership/forms.py
@@ -0,0 +1,20 @@
+"""authentik Group Membership Policy forms"""
+
+from django import forms
+
+from authentik.policies.forms import GENERAL_FIELDS
+from authentik.policies.group_membership.models import GroupMembershipPolicy
+
+
+class GroupMembershipPolicyForm(forms.ModelForm):
+ """GroupMembershipPolicy Form"""
+
+ class Meta:
+
+ model = GroupMembershipPolicy
+ fields = GENERAL_FIELDS + [
+ "group",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/authentik/policies/group_membership/migrations/0001_initial.py b/authentik/policies/group_membership/migrations/0001_initial.py
new file mode 100644
index 000000000..1b48a5004
--- /dev/null
+++ b/authentik/policies/group_membership/migrations/0001_initial.py
@@ -0,0 +1,47 @@
+# Generated by Django 3.0.7 on 2020-07-01 19:01
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_policies", "0002_auto_20200528_1647"),
+ ("authentik_core", "0003_default_user"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="GroupMembershipPolicy",
+ fields=[
+ (
+ "policy_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_policies.Policy",
+ ),
+ ),
+ (
+ "group",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_core.Group",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Group Membership Policy",
+ "verbose_name_plural": "Group Membership Policies",
+ },
+ bases=("authentik_policies.policy",),
+ ),
+ ]
diff --git a/passbook/policies/group_membership/migrations/__init__.py b/authentik/policies/group_membership/migrations/__init__.py
similarity index 100%
rename from passbook/policies/group_membership/migrations/__init__.py
rename to authentik/policies/group_membership/migrations/__init__.py
diff --git a/authentik/policies/group_membership/models.py b/authentik/policies/group_membership/models.py
new file mode 100644
index 000000000..c53b3550d
--- /dev/null
+++ b/authentik/policies/group_membership/models.py
@@ -0,0 +1,39 @@
+"""user field matcher models"""
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext as _
+from rest_framework.serializers import BaseSerializer
+
+from authentik.core.models import Group
+from authentik.policies.models import Policy
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+
+class GroupMembershipPolicy(Policy):
+ """Check that the user is member of the selected group."""
+
+ group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.SET_NULL)
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.policies.group_membership.api import (
+ GroupMembershipPolicySerializer,
+ )
+
+ return GroupMembershipPolicySerializer
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.policies.group_membership.forms import GroupMembershipPolicyForm
+
+ return GroupMembershipPolicyForm
+
+ def passes(self, request: PolicyRequest) -> PolicyResult:
+ return PolicyResult(self.group.users.filter(pk=request.user.pk).exists())
+
+ class Meta:
+
+ verbose_name = _("Group Membership Policy")
+ verbose_name_plural = _("Group Membership Policies")
diff --git a/authentik/policies/group_membership/tests.py b/authentik/policies/group_membership/tests.py
new file mode 100644
index 000000000..5c53b3950
--- /dev/null
+++ b/authentik/policies/group_membership/tests.py
@@ -0,0 +1,32 @@
+"""evaluator tests"""
+from django.test import TestCase
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.core.models import Group
+from authentik.policies.group_membership.models import GroupMembershipPolicy
+from authentik.policies.types import PolicyRequest
+
+
+class TestGroupMembershipPolicy(TestCase):
+ """GroupMembershipPolicy tests"""
+
+ def setUp(self):
+ self.request = PolicyRequest(user=get_anonymous_user())
+
+ def test_invalid(self):
+ """user not in group"""
+ group = Group.objects.create(name="test")
+ policy: GroupMembershipPolicy = GroupMembershipPolicy.objects.create(
+ group=group
+ )
+ self.assertFalse(policy.passes(self.request).passing)
+
+ def test_valid(self):
+ """user in group"""
+ group = Group.objects.create(name="test")
+ group.users.add(get_anonymous_user())
+ group.save()
+ policy: GroupMembershipPolicy = GroupMembershipPolicy.objects.create(
+ group=group
+ )
+ self.assertTrue(policy.passes(self.request).passing)
diff --git a/passbook/policies/hibp/__init__.py b/authentik/policies/hibp/__init__.py
similarity index 100%
rename from passbook/policies/hibp/__init__.py
rename to authentik/policies/hibp/__init__.py
diff --git a/authentik/policies/hibp/api.py b/authentik/policies/hibp/api.py
new file mode 100644
index 000000000..ab0ff1ff7
--- /dev/null
+++ b/authentik/policies/hibp/api.py
@@ -0,0 +1,21 @@
+"""Source API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS
+from authentik.policies.hibp.models import HaveIBeenPwendPolicy
+
+
+class HaveIBeenPwendPolicySerializer(ModelSerializer):
+ """Have I Been Pwned Policy Serializer"""
+
+ class Meta:
+ model = HaveIBeenPwendPolicy
+ fields = GENERAL_SERIALIZER_FIELDS + ["password_field", "allowed_count"]
+
+
+class HaveIBeenPwendPolicyViewSet(ModelViewSet):
+ """Source Viewset"""
+
+ queryset = HaveIBeenPwendPolicy.objects.all()
+ serializer_class = HaveIBeenPwendPolicySerializer
diff --git a/authentik/policies/hibp/apps.py b/authentik/policies/hibp/apps.py
new file mode 100644
index 000000000..75fe9a275
--- /dev/null
+++ b/authentik/policies/hibp/apps.py
@@ -0,0 +1,11 @@
+"""Authentik hibp app config"""
+
+from django.apps import AppConfig
+
+
+class AuthentikPolicyHIBPConfig(AppConfig):
+ """Authentik hibp app config"""
+
+ name = "authentik.policies.hibp"
+ label = "authentik_policies_hibp"
+ verbose_name = "authentik Policies.HaveIBeenPwned"
diff --git a/authentik/policies/hibp/forms.py b/authentik/policies/hibp/forms.py
new file mode 100644
index 000000000..e389847f5
--- /dev/null
+++ b/authentik/policies/hibp/forms.py
@@ -0,0 +1,19 @@
+"""authentik HaveIBeenPwned Policy forms"""
+
+from django import forms
+
+from authentik.policies.forms import GENERAL_FIELDS
+from authentik.policies.hibp.models import HaveIBeenPwendPolicy
+
+
+class HaveIBeenPwnedPolicyForm(forms.ModelForm):
+ """Edit HaveIBeenPwendPolicy instances"""
+
+ class Meta:
+
+ model = HaveIBeenPwendPolicy
+ fields = GENERAL_FIELDS + ["password_field", "allowed_count"]
+ widgets = {
+ "name": forms.TextInput(),
+ "password_field": forms.TextInput(),
+ }
diff --git a/authentik/policies/hibp/migrations/0001_initial.py b/authentik/policies/hibp/migrations/0001_initial.py
new file mode 100644
index 000000000..3ffaa4101
--- /dev/null
+++ b/authentik/policies/hibp/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_policies", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="HaveIBeenPwendPolicy",
+ fields=[
+ (
+ "policy_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_policies.Policy",
+ ),
+ ),
+ ("allowed_count", models.IntegerField(default=0)),
+ ],
+ options={
+ "verbose_name": "Have I Been Pwned Policy",
+ "verbose_name_plural": "Have I Been Pwned Policies",
+ },
+ bases=("authentik_policies.policy",),
+ ),
+ ]
diff --git a/authentik/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py b/authentik/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py
new file mode 100644
index 000000000..a05054217
--- /dev/null
+++ b/authentik/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.0.8 on 2020-07-10 18:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_policies_hibp", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="haveibeenpwendpolicy",
+ name="password_field",
+ field=models.TextField(
+ default="password",
+ help_text="Field key to check, field keys defined in Prompt stages are available.",
+ ),
+ ),
+ ]
diff --git a/passbook/policies/hibp/migrations/__init__.py b/authentik/policies/hibp/migrations/__init__.py
similarity index 100%
rename from passbook/policies/hibp/migrations/__init__.py
rename to authentik/policies/hibp/migrations/__init__.py
diff --git a/authentik/policies/hibp/models.py b/authentik/policies/hibp/models.py
new file mode 100644
index 000000000..916252629
--- /dev/null
+++ b/authentik/policies/hibp/models.py
@@ -0,0 +1,74 @@
+"""authentik HIBP Models"""
+from hashlib import sha1
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext as _
+from requests import get
+from rest_framework.serializers import BaseSerializer
+from structlog import get_logger
+
+from authentik.policies.models import Policy, PolicyResult
+from authentik.policies.types import PolicyRequest
+
+LOGGER = get_logger()
+
+
+class HaveIBeenPwendPolicy(Policy):
+ """Check if password is on HaveIBeenPwned's list by uploading the first
+ 5 characters of the SHA1 Hash."""
+
+ password_field = models.TextField(
+ default="password",
+ help_text=_(
+ "Field key to check, field keys defined in Prompt stages are available."
+ ),
+ )
+
+ allowed_count = models.IntegerField(default=0)
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.policies.hibp.api import HaveIBeenPwendPolicySerializer
+
+ return HaveIBeenPwendPolicySerializer
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.policies.hibp.forms import HaveIBeenPwnedPolicyForm
+
+ return HaveIBeenPwnedPolicyForm
+
+ def passes(self, request: PolicyRequest) -> PolicyResult:
+ """Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5
+ characters of Password in request and checks if full hash is in response. Returns 0
+ if Password is not in result otherwise the count of how many times it was used."""
+ if self.password_field not in request.context:
+ LOGGER.warning(
+ "Password field not set in Policy Request",
+ field=self.password_field,
+ fields=request.context.keys(),
+ )
+ password = request.context[self.password_field]
+
+ pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec
+ url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}"
+ result = get(url).text
+ final_count = 0
+ for line in result.split("\r\n"):
+ full_hash, count = line.split(":")
+ if pw_hash[5:] == full_hash.lower():
+ final_count = int(count)
+ LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5])
+ if final_count > self.allowed_count:
+ message = _(
+ "Password exists on %(count)d online lists." % {"count": final_count}
+ )
+ return PolicyResult(False, message)
+ return PolicyResult(True)
+
+ class Meta:
+
+ verbose_name = _("Have I Been Pwned Policy")
+ verbose_name_plural = _("Have I Been Pwned Policies")
diff --git a/authentik/policies/hibp/tests.py b/authentik/policies/hibp/tests.py
new file mode 100644
index 000000000..f74994711
--- /dev/null
+++ b/authentik/policies/hibp/tests.py
@@ -0,0 +1,33 @@
+"""HIBP Policy tests"""
+from django.test import TestCase
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.policies.hibp.models import HaveIBeenPwendPolicy
+from authentik.policies.types import PolicyRequest, PolicyResult
+from authentik.providers.oauth2.generators import generate_client_secret
+
+
+class TestHIBPPolicy(TestCase):
+ """Test HIBP Policy"""
+
+ def test_false(self):
+ """Failing password case"""
+ policy = HaveIBeenPwendPolicy.objects.create(
+ name="test_false",
+ )
+ request = PolicyRequest(get_anonymous_user())
+ request.context["password"] = "password"
+ result: PolicyResult = policy.passes(request)
+ self.assertFalse(result.passing)
+ self.assertTrue(result.messages[0].startswith("Password exists on "))
+
+ def test_true(self):
+ """Positive password case"""
+ policy = HaveIBeenPwendPolicy.objects.create(
+ name="test_true",
+ )
+ request = PolicyRequest(get_anonymous_user())
+ request.context["password"] = generate_client_secret()
+ result: PolicyResult = policy.passes(request)
+ self.assertTrue(result.passing)
+ self.assertEqual(result.messages, tuple())
diff --git a/authentik/policies/http.py b/authentik/policies/http.py
new file mode 100644
index 000000000..72a0b5f82
--- /dev/null
+++ b/authentik/policies/http.py
@@ -0,0 +1,43 @@
+"""policy http response"""
+from typing import Any, Dict, Optional
+
+from django.http.request import HttpRequest
+from django.template.response import TemplateResponse
+from django.utils.translation import gettext as _
+
+from authentik.core.models import USER_ATTRIBUTE_DEBUG
+from authentik.policies.types import PolicyResult
+
+
+class AccessDeniedResponse(TemplateResponse):
+ """Response used for access denied messages. Can optionally show an error message,
+ and if the user is a superuser or has user_debug enabled, shows a policy result."""
+
+ title: str
+
+ error_message: Optional[str] = None
+ policy_result: Optional[PolicyResult] = None
+
+ # pyright: reportGeneralTypeIssues=false
+ def __init__(self, request: HttpRequest, template="policies/denied.html") -> None:
+ super().__init__(request, template)
+ self.title = _("Access denied")
+
+ def resolve_context(
+ self, context: Optional[Dict[str, Any]]
+ ) -> Optional[Dict[str, Any]]:
+ if not context:
+ context = {}
+ context["title"] = self.title
+ if self.error_message:
+ context["error"] = self.error_message
+ # Only show policy result if user is authenticated and
+ # either superuser or has USER_ATTRIBUTE_DEBUG set
+ if self.policy_result:
+ if self._request.user and self._request.user.is_authenticated:
+ if (
+ self._request.user.is_superuser
+ or self._request.user.attributes.get(USER_ATTRIBUTE_DEBUG, False)
+ ):
+ context["policy_result"] = self.policy_result
+ return context
diff --git a/authentik/policies/migrations/0001_initial.py b/authentik/policies/migrations/0001_initial.py
new file mode 100644
index 000000000..5b2d0ff45
--- /dev/null
+++ b/authentik/policies/migrations/0001_initial.py
@@ -0,0 +1,103 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:07
+
+import uuid
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ migrations.CreateModel(
+ name="Policy",
+ fields=[
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("last_updated", models.DateTimeField(auto_now=True)),
+ (
+ "policy_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("name", models.TextField(blank=True, null=True)),
+ ("negate", models.BooleanField(default=False)),
+ ("order", models.IntegerField(default=0)),
+ ("timeout", models.IntegerField(default=30)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="PolicyBinding",
+ fields=[
+ (
+ "policy_binding_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("enabled", models.BooleanField(default=True)),
+ ("order", models.IntegerField(default=0)),
+ (
+ "policy",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="+",
+ to="authentik_policies.Policy",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Policy Binding",
+ "verbose_name_plural": "Policy Bindings",
+ },
+ ),
+ migrations.CreateModel(
+ name="PolicyBindingModel",
+ fields=[
+ (
+ "pbm_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ (
+ "policies",
+ models.ManyToManyField(
+ blank=True,
+ related_name="bindings",
+ through="authentik_policies.PolicyBinding",
+ to="authentik_policies.Policy",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Policy Binding Model",
+ "verbose_name_plural": "Policy Binding Models",
+ },
+ ),
+ migrations.AddField(
+ model_name="policybinding",
+ name="target",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="+",
+ to="authentik_policies.PolicyBindingModel",
+ ),
+ ),
+ ]
diff --git a/authentik/policies/migrations/0002_auto_20200528_1647.py b/authentik/policies/migrations/0002_auto_20200528_1647.py
new file mode 100644
index 000000000..3c0d636a6
--- /dev/null
+++ b/authentik/policies/migrations/0002_auto_20200528_1647.py
@@ -0,0 +1,70 @@
+# Generated by Django 3.0.6 on 2020-05-28 16:47
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import authentik.lib.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_policies", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="policy",
+ options={
+ "base_manager_name": "objects",
+ "verbose_name": "Policy",
+ "verbose_name_plural": "Policies",
+ },
+ ),
+ migrations.RemoveField(
+ model_name="policy",
+ name="negate",
+ ),
+ migrations.RemoveField(
+ model_name="policy",
+ name="order",
+ ),
+ migrations.RemoveField(
+ model_name="policy",
+ name="timeout",
+ ),
+ migrations.AddField(
+ model_name="policybinding",
+ name="negate",
+ field=models.BooleanField(
+ default=False,
+ help_text="Negates the outcome of the policy. Messages are unaffected.",
+ ),
+ ),
+ migrations.AddField(
+ model_name="policybinding",
+ name="timeout",
+ field=models.IntegerField(
+ default=30,
+ help_text="Timeout after which Policy execution is terminated.",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="policybinding",
+ name="order",
+ field=models.IntegerField(),
+ ),
+ migrations.AlterField(
+ model_name="policybinding",
+ name="policy",
+ field=authentik.lib.models.InheritanceForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="+",
+ to="authentik_policies.Policy",
+ ),
+ ),
+ migrations.AlterUniqueTogether(
+ name="policybinding",
+ unique_together={("policy", "target", "order")},
+ ),
+ ]
diff --git a/authentik/policies/migrations/0003_auto_20200908_1542.py b/authentik/policies/migrations/0003_auto_20200908_1542.py
new file mode 100644
index 000000000..ed808ed79
--- /dev/null
+++ b/authentik/policies/migrations/0003_auto_20200908_1542.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.1.1 on 2020-09-08 15:42
+
+import django.db.models.deletion
+from django.db import migrations
+
+import authentik.lib.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_policies", "0002_auto_20200528_1647"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="policybinding",
+ name="target",
+ field=authentik.lib.models.InheritanceForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="+",
+ to="authentik_policies.policybindingmodel",
+ ),
+ ),
+ ]
diff --git a/passbook/policies/migrations/__init__.py b/authentik/policies/migrations/__init__.py
similarity index 100%
rename from passbook/policies/migrations/__init__.py
rename to authentik/policies/migrations/__init__.py
diff --git a/authentik/policies/models.py b/authentik/policies/models.py
new file mode 100644
index 000000000..3cd8a2bb2
--- /dev/null
+++ b/authentik/policies/models.py
@@ -0,0 +1,102 @@
+"""Policy base models"""
+from typing import Type
+from uuid import uuid4
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from model_utils.managers import InheritanceManager
+from rest_framework.serializers import BaseSerializer
+
+from authentik.lib.models import (
+ CreatedUpdatedModel,
+ InheritanceAutoManager,
+ InheritanceForeignKey,
+ SerializerModel,
+)
+from authentik.policies.exceptions import PolicyException
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+
+class PolicyBindingModel(models.Model):
+ """Base Model for objects that have policies applied to them."""
+
+ pbm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+
+ policies = models.ManyToManyField(
+ "Policy", through="PolicyBinding", related_name="bindings", blank=True
+ )
+
+ objects = InheritanceManager()
+
+ class Meta:
+ verbose_name = _("Policy Binding Model")
+ verbose_name_plural = _("Policy Binding Models")
+
+
+class PolicyBinding(SerializerModel):
+ """Relationship between a Policy and a PolicyBindingModel."""
+
+ policy_binding_uuid = models.UUIDField(
+ primary_key=True, editable=False, default=uuid4
+ )
+
+ enabled = models.BooleanField(default=True)
+
+ policy = InheritanceForeignKey("Policy", on_delete=models.CASCADE, related_name="+")
+ target = InheritanceForeignKey(
+ PolicyBindingModel, on_delete=models.CASCADE, related_name="+"
+ )
+ negate = models.BooleanField(
+ default=False,
+ help_text=_("Negates the outcome of the policy. Messages are unaffected."),
+ )
+ timeout = models.IntegerField(
+ default=30, help_text=_("Timeout after which Policy execution is terminated.")
+ )
+
+ order = models.IntegerField()
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.policies.api import PolicyBindingSerializer
+
+ return PolicyBindingSerializer
+
+ def __str__(self) -> str:
+ return f"Policy Binding {self.target} #{self.order} {self.policy}"
+
+ class Meta:
+
+ verbose_name = _("Policy Binding")
+ verbose_name_plural = _("Policy Bindings")
+ unique_together = ("policy", "target", "order")
+
+
+class Policy(SerializerModel, CreatedUpdatedModel):
+ """Policies which specify if a user is authorized to use an Application. Can be overridden by
+ other types to add other fields, more logic, etc."""
+
+ policy_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+
+ name = models.TextField(blank=True, null=True)
+
+ objects = InheritanceAutoManager()
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ """Return Form class used to edit this object"""
+ raise NotImplementedError
+
+ def __str__(self):
+ return f"{self.__class__.__name__} {self.name}"
+
+ def passes(self, request: PolicyRequest) -> PolicyResult: # pragma: no cover
+ """Check if user instance passes this policy"""
+ raise PolicyException()
+
+ class Meta:
+ base_manager_name = "objects"
+
+ verbose_name = _("Policy")
+ verbose_name_plural = _("Policies")
diff --git a/passbook/policies/password/__init__.py b/authentik/policies/password/__init__.py
similarity index 100%
rename from passbook/policies/password/__init__.py
rename to authentik/policies/password/__init__.py
diff --git a/authentik/policies/password/api.py b/authentik/policies/password/api.py
new file mode 100644
index 000000000..5dae9b47f
--- /dev/null
+++ b/authentik/policies/password/api.py
@@ -0,0 +1,29 @@
+"""Password Policy API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS
+from authentik.policies.password.models import PasswordPolicy
+
+
+class PasswordPolicySerializer(ModelSerializer):
+ """Password Policy Serializer"""
+
+ class Meta:
+ model = PasswordPolicy
+ fields = GENERAL_SERIALIZER_FIELDS + [
+ "password_field",
+ "amount_uppercase",
+ "amount_lowercase",
+ "amount_symbols",
+ "length_min",
+ "symbol_charset",
+ "error_message",
+ ]
+
+
+class PasswordPolicyViewSet(ModelViewSet):
+ """Password Policy Viewset"""
+
+ queryset = PasswordPolicy.objects.all()
+ serializer_class = PasswordPolicySerializer
diff --git a/authentik/policies/password/apps.py b/authentik/policies/password/apps.py
new file mode 100644
index 000000000..7125647d7
--- /dev/null
+++ b/authentik/policies/password/apps.py
@@ -0,0 +1,11 @@
+"""authentik Password policy app config"""
+
+from django.apps import AppConfig
+
+
+class AuthentikPoliciesPasswordConfig(AppConfig):
+ """authentik Password policy app config"""
+
+ name = "authentik.policies.password"
+ label = "authentik_policies_password"
+ verbose_name = "authentik Policies.Password"
diff --git a/authentik/policies/password/forms.py b/authentik/policies/password/forms.py
new file mode 100644
index 000000000..2707da69b
--- /dev/null
+++ b/authentik/policies/password/forms.py
@@ -0,0 +1,36 @@
+"""authentik Policy forms"""
+
+from django import forms
+from django.utils.translation import gettext as _
+
+from authentik.policies.forms import GENERAL_FIELDS
+from authentik.policies.password.models import PasswordPolicy
+
+
+class PasswordPolicyForm(forms.ModelForm):
+ """PasswordPolicy Form"""
+
+ class Meta:
+
+ model = PasswordPolicy
+ fields = GENERAL_FIELDS + [
+ "password_field",
+ "amount_uppercase",
+ "amount_lowercase",
+ "amount_symbols",
+ "length_min",
+ "symbol_charset",
+ "error_message",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "password_field": forms.TextInput(),
+ "symbol_charset": forms.TextInput(),
+ "error_message": forms.TextInput(),
+ }
+ labels = {
+ "amount_uppercase": _("Minimum amount of Uppercase Characters"),
+ "amount_lowercase": _("Minimum amount of Lowercase Characters"),
+ "amount_symbols": _("Minimum amount of Symbols Characters"),
+ "length_min": _("Minimum Length"),
+ }
diff --git a/authentik/policies/password/migrations/0001_initial.py b/authentik/policies/password/migrations/0001_initial.py
new file mode 100644
index 000000000..4352a661a
--- /dev/null
+++ b/authentik/policies/password/migrations/0001_initial.py
@@ -0,0 +1,46 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_policies", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="PasswordPolicy",
+ fields=[
+ (
+ "policy_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_policies.Policy",
+ ),
+ ),
+ ("amount_uppercase", models.IntegerField(default=0)),
+ ("amount_lowercase", models.IntegerField(default=0)),
+ ("amount_symbols", models.IntegerField(default=0)),
+ ("length_min", models.IntegerField(default=0)),
+ (
+ "symbol_charset",
+ models.TextField(default="!\\\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ "),
+ ),
+ ("error_message", models.TextField()),
+ ],
+ options={
+ "verbose_name": "Password Policy",
+ "verbose_name_plural": "Password Policies",
+ },
+ bases=("authentik_policies.policy",),
+ ),
+ ]
diff --git a/authentik/policies/password/migrations/0002_passwordpolicy_password_field.py b/authentik/policies/password/migrations/0002_passwordpolicy_password_field.py
new file mode 100644
index 000000000..b0f16010b
--- /dev/null
+++ b/authentik/policies/password/migrations/0002_passwordpolicy_password_field.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.0.8 on 2020-07-10 18:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_policies_password", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="passwordpolicy",
+ name="password_field",
+ field=models.TextField(
+ default="password",
+ help_text="Field key to check, field keys defined in Prompt stages are available.",
+ ),
+ ),
+ ]
diff --git a/passbook/policies/password/migrations/__init__.py b/authentik/policies/password/migrations/__init__.py
similarity index 100%
rename from passbook/policies/password/migrations/__init__.py
rename to authentik/policies/password/migrations/__init__.py
diff --git a/authentik/policies/password/models.py b/authentik/policies/password/models.py
new file mode 100644
index 000000000..30cac3c90
--- /dev/null
+++ b/authentik/policies/password/models.py
@@ -0,0 +1,77 @@
+"""user field matcher models"""
+import re
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext as _
+from rest_framework.serializers import BaseSerializer
+from structlog import get_logger
+
+from authentik.policies.models import Policy
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+LOGGER = get_logger()
+
+
+class PasswordPolicy(Policy):
+ """Policy to make sure passwords have certain properties"""
+
+ password_field = models.TextField(
+ default="password",
+ help_text=_(
+ "Field key to check, field keys defined in Prompt stages are available."
+ ),
+ )
+
+ amount_uppercase = models.IntegerField(default=0)
+ amount_lowercase = models.IntegerField(default=0)
+ amount_symbols = models.IntegerField(default=0)
+ length_min = models.IntegerField(default=0)
+ symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ")
+ error_message = models.TextField()
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.policies.password.api import PasswordPolicySerializer
+
+ return PasswordPolicySerializer
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.policies.password.forms import PasswordPolicyForm
+
+ return PasswordPolicyForm
+
+ def passes(self, request: PolicyRequest) -> PolicyResult:
+ if self.password_field not in request.context:
+ LOGGER.warning(
+ "Password field not set in Policy Request",
+ field=self.password_field,
+ fields=request.context.keys(),
+ )
+ password = request.context[self.password_field]
+
+ filter_regex = []
+ if self.amount_lowercase > 0:
+ filter_regex.append(r"[a-z]{%d,}" % self.amount_lowercase)
+ if self.amount_uppercase > 0:
+ filter_regex.append(r"[A-Z]{%d,}" % self.amount_uppercase)
+ if self.amount_symbols > 0:
+ filter_regex.append(
+ r"[%s]{%d,}" % (self.symbol_charset, self.amount_symbols)
+ )
+ full_regex = "|".join(filter_regex)
+ LOGGER.debug("Built regex", regexp=full_regex)
+ result = bool(re.compile(full_regex).match(password))
+
+ result = result and len(password) >= self.length_min
+
+ if not result:
+ return PolicyResult(result, self.error_message)
+ return PolicyResult(result)
+
+ class Meta:
+
+ verbose_name = _("Password Policy")
+ verbose_name_plural = _("Password Policies")
diff --git a/authentik/policies/password/tests.py b/authentik/policies/password/tests.py
new file mode 100644
index 000000000..c16175286
--- /dev/null
+++ b/authentik/policies/password/tests.py
@@ -0,0 +1,42 @@
+"""Password Policy tests"""
+from django.test import TestCase
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.policies.password.models import PasswordPolicy
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+
+class TestPasswordPolicy(TestCase):
+ """Test Password Policy"""
+
+ def test_false(self):
+ """Failing password case"""
+ policy = PasswordPolicy.objects.create(
+ name="test_false",
+ amount_uppercase=1,
+ amount_lowercase=2,
+ amount_symbols=3,
+ length_min=24,
+ error_message="test message",
+ )
+ request = PolicyRequest(get_anonymous_user())
+ request.context["password"] = "test"
+ result: PolicyResult = policy.passes(request)
+ self.assertFalse(result.passing)
+ self.assertEqual(result.messages, ("test message",))
+
+ def test_true(self):
+ """Positive password case"""
+ policy = PasswordPolicy.objects.create(
+ name="test_true",
+ amount_uppercase=1,
+ amount_lowercase=2,
+ amount_symbols=3,
+ length_min=3,
+ error_message="test message",
+ )
+ request = PolicyRequest(get_anonymous_user())
+ request.context["password"] = "Test()!"
+ result: PolicyResult = policy.passes(request)
+ self.assertTrue(result.passing)
+ self.assertEqual(result.messages, tuple())
diff --git a/authentik/policies/process.py b/authentik/policies/process.py
new file mode 100644
index 000000000..0737ef433
--- /dev/null
+++ b/authentik/policies/process.py
@@ -0,0 +1,87 @@
+"""authentik policy task"""
+from multiprocessing import Process
+from multiprocessing.connection import Connection
+from typing import Optional
+
+from django.core.cache import cache
+from sentry_sdk.hub import Hub
+from sentry_sdk.tracing import Span
+from structlog import get_logger
+
+from authentik.policies.exceptions import PolicyException
+from authentik.policies.models import PolicyBinding
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+LOGGER = get_logger()
+
+
+def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
+ """Generate Cache key for policy"""
+ prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}"
+ if request.http_request:
+ prefix += f"_{request.http_request.session.session_key}"
+ if request.user:
+ prefix += f"#{request.user.pk}"
+ return prefix
+
+
+class PolicyProcess(Process):
+ """Evaluate a single policy within a seprate process"""
+
+ connection: Connection
+ binding: PolicyBinding
+ request: PolicyRequest
+
+ def __init__(
+ self,
+ binding: PolicyBinding,
+ request: PolicyRequest,
+ connection: Optional[Connection],
+ ):
+ super().__init__()
+ self.binding = binding
+ self.request = request
+ if not isinstance(self.request, PolicyRequest):
+ raise ValueError(f"{self.request} is not a Policy Request.")
+ if connection:
+ self.connection = connection
+
+ def execute(self) -> PolicyResult:
+ """Run actual policy, returns result"""
+ with Hub.current.start_span(
+ op="policy.process.execute",
+ ) as span:
+ span: Span
+ span.set_data("policy", self.binding.policy)
+ span.set_data("request", self.request)
+ LOGGER.debug(
+ "P_ENG(proc): Running policy",
+ policy=self.binding.policy,
+ user=self.request.user,
+ process="PolicyProcess",
+ )
+ try:
+ policy_result = self.binding.policy.passes(self.request)
+ except PolicyException as exc:
+ LOGGER.debug("P_ENG(proc): error", exc=exc)
+ policy_result = PolicyResult(False, str(exc))
+ policy_result.source_policy = self.binding.policy
+ # Invert result if policy.negate is set
+ if self.binding.negate:
+ policy_result.passing = not policy_result.passing
+ LOGGER.debug(
+ "P_ENG(proc): Finished",
+ policy=self.binding.policy,
+ result=policy_result,
+ process="PolicyProcess",
+ passing=policy_result.passing,
+ user=self.request.user,
+ )
+ key = cache_key(self.binding, self.request)
+ cache.set(key, policy_result)
+ LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
+ return policy_result
+
+ def run(self):
+ """Task wrapper to run policy checking"""
+ self.connection.send(self.execute())
diff --git a/passbook/policies/reputation/__init__.py b/authentik/policies/reputation/__init__.py
similarity index 100%
rename from passbook/policies/reputation/__init__.py
rename to authentik/policies/reputation/__init__.py
diff --git a/authentik/policies/reputation/api.py b/authentik/policies/reputation/api.py
new file mode 100644
index 000000000..2a5fd7c7b
--- /dev/null
+++ b/authentik/policies/reputation/api.py
@@ -0,0 +1,21 @@
+"""Source API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS
+from authentik.policies.reputation.models import ReputationPolicy
+
+
+class ReputationPolicySerializer(ModelSerializer):
+ """Reputation Policy Serializer"""
+
+ class Meta:
+ model = ReputationPolicy
+ fields = GENERAL_SERIALIZER_FIELDS + ["check_ip", "check_username", "threshold"]
+
+
+class ReputationPolicyViewSet(ModelViewSet):
+ """Source Viewset"""
+
+ queryset = ReputationPolicy.objects.all()
+ serializer_class = ReputationPolicySerializer
diff --git a/authentik/policies/reputation/apps.py b/authentik/policies/reputation/apps.py
new file mode 100644
index 000000000..594b471f7
--- /dev/null
+++ b/authentik/policies/reputation/apps.py
@@ -0,0 +1,15 @@
+"""Authentik reputation_policy app config"""
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class AuthentikPolicyReputationConfig(AppConfig):
+ """Authentik reputation app config"""
+
+ name = "authentik.policies.reputation"
+ label = "authentik_policies_reputation"
+ verbose_name = "authentik Policies.Reputation"
+
+ def ready(self):
+ import_module("authentik.policies.reputation.signals")
diff --git a/authentik/policies/reputation/forms.py b/authentik/policies/reputation/forms.py
new file mode 100644
index 000000000..ce3a23ed0
--- /dev/null
+++ b/authentik/policies/reputation/forms.py
@@ -0,0 +1,22 @@
+"""authentik reputation request forms"""
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from authentik.policies.forms import GENERAL_FIELDS
+from authentik.policies.reputation.models import ReputationPolicy
+
+
+class ReputationPolicyForm(forms.ModelForm):
+ """Form to edit ReputationPolicy"""
+
+ class Meta:
+
+ model = ReputationPolicy
+ fields = GENERAL_FIELDS + ["check_ip", "check_username", "threshold"]
+ widgets = {
+ "name": forms.TextInput(),
+ "value": forms.TextInput(),
+ }
+ labels = {
+ "check_ip": _("Check IP"),
+ }
diff --git a/authentik/policies/reputation/migrations/0001_initial.py b/authentik/policies/reputation/migrations/0001_initial.py
new file mode 100644
index 000000000..fe7eaf9c2
--- /dev/null
+++ b/authentik/policies/reputation/migrations/0001_initial.py
@@ -0,0 +1,82 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("authentik_policies", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="IPReputation",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("ip", models.GenericIPAddressField(unique=True)),
+ ("score", models.IntegerField(default=0)),
+ ("updated", models.DateTimeField(auto_now=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name="ReputationPolicy",
+ fields=[
+ (
+ "policy_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_policies.Policy",
+ ),
+ ),
+ ("check_ip", models.BooleanField(default=True)),
+ ("check_username", models.BooleanField(default=True)),
+ ("threshold", models.IntegerField(default=-5)),
+ ],
+ options={
+ "verbose_name": "Reputation Policy",
+ "verbose_name_plural": "Reputation Policies",
+ },
+ bases=("authentik_policies.policy",),
+ ),
+ migrations.CreateModel(
+ name="UserReputation",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("score", models.IntegerField(default=0)),
+ ("updated", models.DateTimeField(auto_now=True)),
+ (
+ "user",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/passbook/policies/reputation/migrations/__init__.py b/authentik/policies/reputation/migrations/__init__.py
similarity index 100%
rename from passbook/policies/reputation/migrations/__init__.py
rename to authentik/policies/reputation/migrations/__init__.py
diff --git a/authentik/policies/reputation/models.py b/authentik/policies/reputation/models.py
new file mode 100644
index 000000000..2dfaa834e
--- /dev/null
+++ b/authentik/policies/reputation/models.py
@@ -0,0 +1,74 @@
+"""authentik reputation request policy"""
+from typing import Type
+
+from django.core.cache import cache
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext as _
+from rest_framework.serializers import BaseSerializer
+
+from authentik.core.models import User
+from authentik.lib.utils.http import get_client_ip
+from authentik.policies.models import Policy
+from authentik.policies.types import PolicyRequest, PolicyResult
+
+CACHE_KEY_IP_PREFIX = "authentik_reputation_ip_"
+CACHE_KEY_USER_PREFIX = "authentik_reputation_user_"
+
+
+class ReputationPolicy(Policy):
+ """Return true if request IP/target username's score is below a certain threshold"""
+
+ check_ip = models.BooleanField(default=True)
+ check_username = models.BooleanField(default=True)
+ threshold = models.IntegerField(default=-5)
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.policies.reputation.api import ReputationPolicySerializer
+
+ return ReputationPolicySerializer
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.policies.reputation.forms import ReputationPolicyForm
+
+ return ReputationPolicyForm
+
+ def passes(self, request: PolicyRequest) -> PolicyResult:
+ remote_ip = get_client_ip(request.http_request) or "255.255.255.255"
+ passing = True
+ if self.check_ip:
+ score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0)
+ passing = passing and score <= self.threshold
+ if self.check_username:
+ score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0)
+ passing = passing and score <= self.threshold
+ return PolicyResult(passing)
+
+ class Meta:
+
+ verbose_name = _("Reputation Policy")
+ verbose_name_plural = _("Reputation Policies")
+
+
+class IPReputation(models.Model):
+ """Store score coming from the same IP"""
+
+ ip = models.GenericIPAddressField(unique=True)
+ score = models.IntegerField(default=0)
+ updated = models.DateTimeField(auto_now=True)
+
+ def __str__(self):
+ return f"IPReputation for {self.ip} @ {self.score}"
+
+
+class UserReputation(models.Model):
+ """Store score attempting to log in as the same username"""
+
+ user = models.OneToOneField(User, on_delete=models.CASCADE)
+ score = models.IntegerField(default=0)
+ updated = models.DateTimeField(auto_now=True)
+
+ def __str__(self):
+ return f"UserReputation for {self.user} @ {self.score}"
diff --git a/authentik/policies/reputation/settings.py b/authentik/policies/reputation/settings.py
new file mode 100644
index 000000000..401ba5485
--- /dev/null
+++ b/authentik/policies/reputation/settings.py
@@ -0,0 +1,15 @@
+"""Reputation Settings"""
+from celery.schedules import crontab
+
+CELERY_BEAT_SCHEDULE = {
+ "policies_reputation_ip_save": {
+ "task": "authentik.policies.reputation.tasks.save_ip_reputation",
+ "schedule": crontab(minute="*/5"),
+ "options": {"queue": "authentik_scheduled"},
+ },
+ "policies_reputation_user_save": {
+ "task": "authentik.policies.reputation.tasks.save_user_reputation",
+ "schedule": crontab(minute="*/5"),
+ "options": {"queue": "authentik_scheduled"},
+ },
+}
diff --git a/authentik/policies/reputation/signals.py b/authentik/policies/reputation/signals.py
new file mode 100644
index 000000000..fce16d704
--- /dev/null
+++ b/authentik/policies/reputation/signals.py
@@ -0,0 +1,43 @@
+"""authentik reputation request signals"""
+from django.contrib.auth.signals import user_logged_in, user_login_failed
+from django.core.cache import cache
+from django.dispatch import receiver
+from django.http import HttpRequest
+from structlog import get_logger
+
+from authentik.lib.utils.http import get_client_ip
+from authentik.policies.reputation.models import (
+ CACHE_KEY_IP_PREFIX,
+ CACHE_KEY_USER_PREFIX,
+)
+
+LOGGER = get_logger()
+
+
+def update_score(request: HttpRequest, username: str, amount: int):
+ """Update score for IP and User"""
+ remote_ip = get_client_ip(request) or "255.255.255.255"
+
+ # We only update the cache here, as its faster than writing to the DB
+ cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0)
+ cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount)
+
+ cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0)
+ cache.incr(CACHE_KEY_USER_PREFIX + username, amount)
+
+ LOGGER.debug("Updated score", amount=amount, for_user=username, for_ip=remote_ip)
+
+
+@receiver(user_login_failed)
+# pylint: disable=unused-argument
+def handle_failed_login(sender, request, credentials, **_):
+ """Lower Score for failed loging attempts"""
+ if "username" in credentials:
+ update_score(request, credentials.get("username"), -1)
+
+
+@receiver(user_logged_in)
+# pylint: disable=unused-argument
+def handle_successful_login(sender, request, user, **_):
+ """Raise score for successful attempts"""
+ update_score(request, user.username, 1)
diff --git a/authentik/policies/reputation/tasks.py b/authentik/policies/reputation/tasks.py
new file mode 100644
index 000000000..78fafee53
--- /dev/null
+++ b/authentik/policies/reputation/tasks.py
@@ -0,0 +1,50 @@
+"""Reputation tasks"""
+from django.core.cache import cache
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
+from authentik.policies.reputation.models import IPReputation, UserReputation
+from authentik.policies.reputation.signals import (
+ CACHE_KEY_IP_PREFIX,
+ CACHE_KEY_USER_PREFIX,
+)
+from authentik.root.celery import CELERY_APP
+
+LOGGER = get_logger()
+
+
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def save_ip_reputation(self: MonitoredTask):
+ """Save currently cached reputation to database"""
+ objects_to_update = []
+ for key, score in cache.get_many(cache.keys(CACHE_KEY_IP_PREFIX + "*")).items():
+ remote_ip = key.replace(CACHE_KEY_IP_PREFIX, "")
+ rep, _ = IPReputation.objects.get_or_create(ip=remote_ip)
+ rep.score = score
+ objects_to_update.append(rep)
+ IPReputation.objects.bulk_update(objects_to_update, ["score"])
+ self.set_status(
+ TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated IP Reputation"])
+ )
+
+
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def save_user_reputation(self: MonitoredTask):
+ """Save currently cached reputation to database"""
+ objects_to_update = []
+ for key, score in cache.get_many(cache.keys(CACHE_KEY_USER_PREFIX + "*")).items():
+ username = key.replace(CACHE_KEY_USER_PREFIX, "")
+ users = User.objects.filter(username=username)
+ if not users.exists():
+ LOGGER.info("User in cache does not exist, ignoring", username=username)
+ continue
+ rep, _ = UserReputation.objects.get_or_create(user=users.first())
+ rep.score = score
+ objects_to_update.append(rep)
+ UserReputation.objects.bulk_update(objects_to_update, ["score"])
+ self.set_status(
+ TaskResult(
+ TaskResultStatus.SUCCESSFUL, ["Successfully updated User Reputation"]
+ )
+ )
diff --git a/authentik/policies/reputation/tests.py b/authentik/policies/reputation/tests.py
new file mode 100644
index 000000000..00c5e6894
--- /dev/null
+++ b/authentik/policies/reputation/tests.py
@@ -0,0 +1,55 @@
+"""test reputation signals and policy"""
+from django.contrib.auth import authenticate
+from django.core.cache import cache
+from django.test import TestCase
+
+from authentik.core.models import User
+from authentik.policies.reputation.models import (
+ CACHE_KEY_IP_PREFIX,
+ CACHE_KEY_USER_PREFIX,
+ IPReputation,
+ ReputationPolicy,
+ UserReputation,
+)
+from authentik.policies.reputation.tasks import save_ip_reputation, save_user_reputation
+from authentik.policies.types import PolicyRequest
+
+
+class TestReputationPolicy(TestCase):
+ """test reputation signals and policy"""
+
+ def setUp(self):
+ self.test_ip = "255.255.255.255"
+ self.test_username = "test"
+ cache.delete(CACHE_KEY_IP_PREFIX + self.test_ip)
+ cache.delete(CACHE_KEY_USER_PREFIX + self.test_username)
+ # We need a user for the one-to-one in userreputation
+ self.user = User.objects.create(username=self.test_username)
+
+ def test_ip_reputation(self):
+ """test IP reputation"""
+ # Trigger negative reputation
+ authenticate(None, username=self.test_username, password=self.test_username)
+ # Test value in cache
+ self.assertEqual(cache.get(CACHE_KEY_IP_PREFIX + self.test_ip), -1)
+ # Save cache and check db values
+ save_ip_reputation.delay().get()
+ self.assertEqual(IPReputation.objects.get(ip=self.test_ip).score, -1)
+
+ def test_user_reputation(self):
+ """test User reputation"""
+ # Trigger negative reputation
+ authenticate(None, username=self.test_username, password=self.test_username)
+ # Test value in cache
+ self.assertEqual(cache.get(CACHE_KEY_USER_PREFIX + self.test_username), -1)
+ # Save cache and check db values
+ save_user_reputation.delay().get()
+ self.assertEqual(UserReputation.objects.get(user=self.user).score, -1)
+
+ def test_policy(self):
+ """Test Policy"""
+ request = PolicyRequest(user=self.user)
+ policy: ReputationPolicy = ReputationPolicy.objects.create(
+ name="reputation-test", threshold=0
+ )
+ self.assertTrue(policy.passes(request).passing)
diff --git a/authentik/policies/signals.py b/authentik/policies/signals.py
new file mode 100644
index 000000000..1c5595240
--- /dev/null
+++ b/authentik/policies/signals.py
@@ -0,0 +1,25 @@
+"""authentik policy signals"""
+from django.core.cache import cache
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from structlog import get_logger
+
+LOGGER = get_logger()
+
+
+@receiver(post_save)
+# pylint: disable=unused-argument
+def invalidate_policy_cache(sender, instance, **_):
+ """Invalidate Policy cache when policy is updated"""
+ from authentik.policies.models import Policy, PolicyBinding
+
+ if isinstance(instance, Policy):
+ total = 0
+ for binding in PolicyBinding.objects.filter(policy=instance):
+ prefix = (
+ f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}*"
+ )
+ keys = cache.keys(prefix)
+ total += len(keys)
+ cache.delete_many(keys)
+ LOGGER.debug("Invalidating policy cache", policy=instance, keys=total)
diff --git a/authentik/policies/templates/policies/denied.html b/authentik/policies/templates/policies/denied.html
new file mode 100644
index 000000000..d84d860bb
--- /dev/null
+++ b/authentik/policies/templates/policies/denied.html
@@ -0,0 +1,57 @@
+{% extends 'login/base_full.html' %}
+
+{% load static %}
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block card_title %}
+{% trans 'Permission denied' %}
+{% endblock %}
+
+{% block title %}
+{% trans 'Permission denied' %}
+{% endblock %}
+
+{% block card %}
+
+{% endblock %}
diff --git a/passbook/policies/tests/__init__.py b/authentik/policies/tests/__init__.py
similarity index 100%
rename from passbook/policies/tests/__init__.py
rename to authentik/policies/tests/__init__.py
diff --git a/authentik/policies/tests/test_engine.py b/authentik/policies/tests/test_engine.py
new file mode 100644
index 000000000..fe2c808bd
--- /dev/null
+++ b/authentik/policies/tests/test_engine.py
@@ -0,0 +1,84 @@
+"""policy engine tests"""
+from django.core.cache import cache
+from django.test import TestCase
+
+from authentik.core.models import User
+from authentik.policies.dummy.models import DummyPolicy
+from authentik.policies.engine import PolicyEngine
+from authentik.policies.expression.models import ExpressionPolicy
+from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
+
+
+class TestPolicyEngine(TestCase):
+ """PolicyEngine tests"""
+
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username="policyuser")
+ self.policy_false = DummyPolicy.objects.create(
+ result=False, wait_min=0, wait_max=1
+ )
+ self.policy_true = DummyPolicy.objects.create(
+ result=True, wait_min=0, wait_max=1
+ )
+ self.policy_wrong_type = Policy.objects.create(name="wrong_type")
+ self.policy_raises = ExpressionPolicy.objects.create(
+ name="raises", expression="{{ 0/0 }}"
+ )
+
+ def test_engine_empty(self):
+ """Ensure empty policy list passes"""
+ pbm = PolicyBindingModel.objects.create()
+ engine = PolicyEngine(pbm, self.user)
+ result = engine.build().result
+ self.assertEqual(result.passing, True)
+ self.assertEqual(result.messages, ())
+
+ def test_engine(self):
+ """Ensure all policies passes (Mix of false and true -> false)"""
+ pbm = PolicyBindingModel.objects.create()
+ PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0)
+ PolicyBinding.objects.create(target=pbm, policy=self.policy_true, order=1)
+ engine = PolicyEngine(pbm, self.user)
+ result = engine.build().result
+ self.assertEqual(result.passing, False)
+ self.assertEqual(result.messages, ("dummy",))
+
+ def test_engine_negate(self):
+ """Test negate flag"""
+ pbm = PolicyBindingModel.objects.create()
+ PolicyBinding.objects.create(
+ target=pbm, policy=self.policy_true, negate=True, order=0
+ )
+ engine = PolicyEngine(pbm, self.user)
+ result = engine.build().result
+ self.assertEqual(result.passing, False)
+ self.assertEqual(result.messages, ("dummy",))
+
+ def test_engine_policy_error(self):
+ """Test policy raising an error flag"""
+ pbm = PolicyBindingModel.objects.create()
+ PolicyBinding.objects.create(target=pbm, policy=self.policy_raises, order=0)
+ engine = PolicyEngine(pbm, self.user)
+ result = engine.build().result
+ self.assertEqual(result.passing, False)
+ self.assertEqual(result.messages, ("division by zero",))
+
+ def test_engine_policy_type(self):
+ """Test invalid policy type"""
+ pbm = PolicyBindingModel.objects.create()
+ PolicyBinding.objects.create(target=pbm, policy=self.policy_wrong_type, order=0)
+ with self.assertRaises(TypeError):
+ engine = PolicyEngine(pbm, self.user)
+ engine.build()
+
+ def test_engine_cache(self):
+ """Ensure empty policy list passes"""
+ pbm = PolicyBindingModel.objects.create()
+ PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0)
+ engine = PolicyEngine(pbm, self.user)
+ self.assertEqual(len(cache.keys("policy_*")), 0)
+ self.assertEqual(engine.build().passing, False)
+ self.assertEqual(len(cache.keys("policy_*")), 1)
+ self.assertEqual(engine.build().passing, False)
+ self.assertEqual(len(cache.keys("policy_*")), 1)
diff --git a/authentik/policies/tests/test_models.py b/authentik/policies/tests/test_models.py
new file mode 100644
index 000000000..3e13b8528
--- /dev/null
+++ b/authentik/policies/tests/test_models.py
@@ -0,0 +1,30 @@
+"""flow model tests"""
+from typing import Callable, Type
+
+from django.forms import ModelForm
+from django.test import TestCase
+
+from authentik.lib.utils.reflection import all_subclasses
+from authentik.policies.models import Policy
+
+
+class TestPolicyProperties(TestCase):
+ """Generic model properties tests"""
+
+
+def policy_tester_factory(model: Type[Policy]) -> Callable:
+ """Test a form"""
+
+ def tester(self: TestPolicyProperties):
+ model_inst = model()
+ self.assertTrue(issubclass(model_inst.form, ModelForm))
+
+ return tester
+
+
+for policy_type in all_subclasses(Policy):
+ setattr(
+ TestPolicyProperties,
+ f"test_policy_{policy_type.__name__}",
+ policy_tester_factory(policy_type),
+ )
diff --git a/authentik/policies/types.py b/authentik/policies/types.py
new file mode 100644
index 000000000..2abaf444a
--- /dev/null
+++ b/authentik/policies/types.py
@@ -0,0 +1,53 @@
+"""policy structures"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
+
+from django.db.models import Model
+from django.http import HttpRequest
+
+if TYPE_CHECKING:
+ from authentik.core.models import User
+ from authentik.policies.models import Policy
+
+
+class PolicyRequest:
+ """Data-class to hold policy request data"""
+
+ user: User
+ http_request: Optional[HttpRequest]
+ obj: Optional[Model]
+ context: Dict[str, str]
+
+ def __init__(self, user: User):
+ self.user = user
+ self.http_request = None
+ self.obj = None
+ self.context = {}
+
+ def __str__(self):
+ return f""
+
+
+class PolicyResult:
+ """Small data-class to hold policy results"""
+
+ passing: bool
+ messages: Tuple[str, ...]
+
+ source_policy: Optional[Policy]
+ source_results: Optional[List["PolicyResult"]]
+
+ def __init__(self, passing: bool, *messages: str):
+ self.passing = passing
+ self.messages = messages
+ self.source_policy = None
+ self.source_results = []
+
+ def __repr__(self):
+ return self.__str__()
+
+ def __str__(self):
+ if self.messages:
+ return f"PolicyResult passing={self.passing} messages={self.messages}"
+ return f"PolicyResult passing={self.passing}"
diff --git a/passbook/policies/utils.py b/authentik/policies/utils.py
similarity index 100%
rename from passbook/policies/utils.py
rename to authentik/policies/utils.py
diff --git a/authentik/policies/views.py b/authentik/policies/views.py
new file mode 100644
index 000000000..844a939e2
--- /dev/null
+++ b/authentik/policies/views.py
@@ -0,0 +1,93 @@
+"""authentik access helper classes"""
+from typing import Any, Optional
+
+from django.contrib import messages
+from django.contrib.auth.mixins import AccessMixin
+from django.contrib.auth.views import redirect_to_login
+from django.http import HttpRequest, HttpResponse
+from django.utils.translation import gettext as _
+from django.views.generic.base import View
+from structlog import get_logger
+
+from authentik.core.models import Application, Provider, User
+from authentik.flows.views import SESSION_KEY_APPLICATION_PRE
+from authentik.policies.engine import PolicyEngine
+from authentik.policies.http import AccessDeniedResponse
+from authentik.policies.types import PolicyResult
+
+LOGGER = get_logger()
+
+
+class BaseMixin:
+ """Base Mixin class, used to annotate View Member variables"""
+
+ request: HttpRequest
+
+
+class PolicyAccessView(AccessMixin, View):
+ """Mixin class for usage in Authorization views.
+ Provider functions to check application access, etc"""
+
+ provider: Provider
+ application: Application
+
+ def resolve_provider_application(self):
+ """Resolve self.provider and self.application. *.DoesNotExist Exceptions cause a normal
+ AccessDenied view to be shown. An Http404 exception
+ is not caught, and will return directly"""
+ raise NotImplementedError
+
+ def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ try:
+ self.resolve_provider_application()
+ except (Application.DoesNotExist, Provider.DoesNotExist):
+ return self.handle_no_permission_authenticated()
+ # Check if user is unauthenticated, so we pass the application
+ # for the identification stage
+ if not request.user.is_authenticated:
+ return self.handle_no_permission()
+ # Check permissions
+ result = self.user_has_access()
+ if not result.passing:
+ return self.handle_no_permission_authenticated(result)
+ return super().dispatch(request, *args, **kwargs)
+
+ def handle_no_permission(self) -> HttpResponse:
+ """User has no access and is not authenticated, so we remember the application
+ they try to access and redirect to the login URL. The application is saved to show
+ a hint on the Identification Stage what the user should login for."""
+ if self.application:
+ self.request.session[SESSION_KEY_APPLICATION_PRE] = self.application
+ return redirect_to_login(
+ self.request.get_full_path(),
+ self.get_login_url(),
+ self.get_redirect_field_name(),
+ )
+
+ def handle_no_permission_authenticated(
+ self, result: Optional[PolicyResult] = None
+ ) -> HttpResponse:
+ """Function called when user has no permissions but is authenticated"""
+ response = AccessDeniedResponse(self.request)
+ if result:
+ response.policy_result = result
+ return response
+
+ def user_has_access(self, user: Optional[User] = None) -> PolicyResult:
+ """Check if user has access to application."""
+ user = user or self.request.user
+ policy_engine = PolicyEngine(
+ self.application, user or self.request.user, self.request
+ )
+ policy_engine.build()
+ result = policy_engine.result
+ LOGGER.debug(
+ "AccessMixin user_has_access",
+ user=user,
+ app=self.application,
+ result=result,
+ )
+ if not result.passing:
+ for message in result.messages:
+ messages.error(self.request, _(message))
+ return result
diff --git a/passbook/providers/__init__.py b/authentik/providers/__init__.py
similarity index 100%
rename from passbook/providers/__init__.py
rename to authentik/providers/__init__.py
diff --git a/passbook/providers/oauth2/__init__.py b/authentik/providers/oauth2/__init__.py
similarity index 100%
rename from passbook/providers/oauth2/__init__.py
rename to authentik/providers/oauth2/__init__.py
diff --git a/authentik/providers/oauth2/api.py b/authentik/providers/oauth2/api.py
new file mode 100644
index 000000000..91ae6711b
--- /dev/null
+++ b/authentik/providers/oauth2/api.py
@@ -0,0 +1,51 @@
+"""OAuth2Provider API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
+
+
+class OAuth2ProviderSerializer(ModelSerializer):
+ """OAuth2Provider Serializer"""
+
+ class Meta:
+
+ model = OAuth2Provider
+ fields = [
+ "pk",
+ "name",
+ "authorization_flow",
+ "client_type",
+ "client_id",
+ "client_secret",
+ "token_validity",
+ "response_type",
+ "jwt_alg",
+ "rsa_key",
+ "redirect_uris",
+ "sub_mode",
+ "property_mappings",
+ ]
+
+
+class OAuth2ProviderViewSet(ModelViewSet):
+ """OAuth2Provider Viewset"""
+
+ queryset = OAuth2Provider.objects.all()
+ serializer_class = OAuth2ProviderSerializer
+
+
+class ScopeMappingSerializer(ModelSerializer):
+ """ScopeMapping Serializer"""
+
+ class Meta:
+
+ model = ScopeMapping
+ fields = ["pk", "name", "scope_name", "description", "expression"]
+
+
+class ScopeMappingViewSet(ModelViewSet):
+ """ScopeMapping Viewset"""
+
+ queryset = ScopeMapping.objects.all()
+ serializer_class = ScopeMappingSerializer
diff --git a/authentik/providers/oauth2/apps.py b/authentik/providers/oauth2/apps.py
new file mode 100644
index 000000000..68ccbb76c
--- /dev/null
+++ b/authentik/providers/oauth2/apps.py
@@ -0,0 +1,14 @@
+"""authentik auth oauth provider app config"""
+from django.apps import AppConfig
+
+
+class AuthentikProviderOAuth2Config(AppConfig):
+ """authentik auth oauth provider app config"""
+
+ name = "authentik.providers.oauth2"
+ label = "authentik_providers_oauth2"
+ verbose_name = "authentik Providers.OAuth2"
+ mountpoints = {
+ "authentik.providers.oauth2.urls": "application/o/",
+ "authentik.providers.oauth2.urls_github": "",
+ }
diff --git a/passbook/providers/oauth2/constants.py b/authentik/providers/oauth2/constants.py
similarity index 100%
rename from passbook/providers/oauth2/constants.py
rename to authentik/providers/oauth2/constants.py
diff --git a/passbook/providers/oauth2/errors.py b/authentik/providers/oauth2/errors.py
similarity index 100%
rename from passbook/providers/oauth2/errors.py
rename to authentik/providers/oauth2/errors.py
diff --git a/authentik/providers/oauth2/forms.py b/authentik/providers/oauth2/forms.py
new file mode 100644
index 000000000..071b6434a
--- /dev/null
+++ b/authentik/providers/oauth2/forms.py
@@ -0,0 +1,100 @@
+"""authentik OAuth2 Provider Forms"""
+
+from django import forms
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext as _
+
+from authentik.admin.fields import CodeMirrorWidget
+from authentik.core.expression import PropertyMappingEvaluator
+from authentik.crypto.models import CertificateKeyPair
+from authentik.flows.models import Flow, FlowDesignation
+from authentik.providers.oauth2.generators import (
+ generate_client_id,
+ generate_client_secret,
+)
+from authentik.providers.oauth2.models import (
+ JWTAlgorithms,
+ OAuth2Provider,
+ ScopeMapping,
+)
+
+
+class OAuth2ProviderForm(forms.ModelForm):
+ """OAuth2 Provider form"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["authorization_flow"].queryset = Flow.objects.filter(
+ designation=FlowDesignation.AUTHORIZATION
+ )
+ self.fields["client_id"].initial = generate_client_id()
+ self.fields["client_secret"].initial = generate_client_secret()
+ self.fields["rsa_key"].queryset = CertificateKeyPair.objects.exclude(
+ key_data__exact=""
+ )
+ self.fields["property_mappings"].queryset = ScopeMapping.objects.all()
+
+ def clean_jwt_alg(self):
+ """Ensure that when RS256 is selected, a certificate-key-pair is selected"""
+ if (
+ self.data["rsa_key"] == ""
+ and self.cleaned_data["jwt_alg"] == JWTAlgorithms.RS256
+ ):
+ raise ValidationError(
+ _("RS256 requires a Certificate-Key-Pair to be selected.")
+ )
+ return self.cleaned_data["jwt_alg"]
+
+ class Meta:
+ model = OAuth2Provider
+ fields = [
+ "name",
+ "authorization_flow",
+ "client_type",
+ "client_id",
+ "client_secret",
+ "response_type",
+ "token_validity",
+ "jwt_alg",
+ "rsa_key",
+ "redirect_uris",
+ "sub_mode",
+ "property_mappings",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "token_validity": forms.TextInput(),
+ }
+ labels = {"property_mappings": _("Scopes")}
+ help_texts = {
+ "property_mappings": _(
+ (
+ "Select which scopes can be used by the client. "
+ "The client stil has to specify the scope to access the data."
+ )
+ )
+ }
+
+
+class ScopeMappingForm(forms.ModelForm):
+ """Form to edit ScopeMappings"""
+
+ template_name = "providers/oauth2/property_mapping_form.html"
+
+ def clean_expression(self):
+ """Test Syntax"""
+ expression = self.cleaned_data.get("expression")
+ evaluator = PropertyMappingEvaluator()
+ evaluator.validate(expression)
+ return expression
+
+ class Meta:
+
+ model = ScopeMapping
+ fields = ["name", "scope_name", "description", "expression"]
+ widgets = {
+ "name": forms.TextInput(),
+ "scope_name": forms.TextInput(),
+ "description": forms.TextInput(),
+ "expression": CodeMirrorWidget(mode="python"),
+ }
diff --git a/passbook/providers/oauth2/generators.py b/authentik/providers/oauth2/generators.py
similarity index 100%
rename from passbook/providers/oauth2/generators.py
rename to authentik/providers/oauth2/generators.py
diff --git a/authentik/providers/oauth2/migrations/0001_initial.py b/authentik/providers/oauth2/migrations/0001_initial.py
new file mode 100644
index 000000000..0a234d64d
--- /dev/null
+++ b/authentik/providers/oauth2/migrations/0001_initial.py
@@ -0,0 +1,362 @@
+# Generated by Django 3.1 on 2020-08-18 15:59
+
+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
+import authentik.lib.utils.time
+import authentik.providers.oauth2.generators
+
+SCOPE_OPENID_EXPRESSION = """# This is only required for OpenID Applications, but does not grant any information by itself.
+return {}
+"""
+SCOPE_EMAIL_EXPRESSION = """return {
+ "email": user.email,
+ "email_verified": True
+}
+"""
+SCOPE_PROFILE_EXPRESSION = """return {
+ "name": user.name,
+ "given_name": user.name,
+ "family_name": "",
+ "preferred_username": user.username,
+ "nickname": user.username,
+}
+"""
+
+
+def create_default_scopes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
+ ScopeMapping.objects.update_or_create(
+ scope_name="openid",
+ defaults={
+ "name": "Autogenerated OAuth2 Mapping: OpenID 'openid'",
+ "scope_name": "openid",
+ "description": "",
+ "expression": SCOPE_OPENID_EXPRESSION,
+ },
+ )
+ ScopeMapping.objects.update_or_create(
+ scope_name="email",
+ defaults={
+ "name": "Autogenerated OAuth2 Mapping: OpenID 'email'",
+ "scope_name": "email",
+ "description": "Email address",
+ "expression": SCOPE_EMAIL_EXPRESSION,
+ },
+ )
+ ScopeMapping.objects.update_or_create(
+ scope_name="profile",
+ defaults={
+ "name": "Autogenerated OAuth2 Mapping: OpenID 'profile'",
+ "scope_name": "profile",
+ "description": "General Profile Information",
+ "expression": SCOPE_PROFILE_EXPRESSION,
+ },
+ )
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("authentik_core", "0007_auto_20200815_1841"),
+ ("authentik_crypto", "0002_create_self_signed_kp"),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ "DROP TABLE IF EXISTS authentik_providers_oauth_oauth2provider CASCADE;"
+ ),
+ migrations.RunSQL(
+ "DROP TABLE IF EXISTS authentik_providers_oidc_openidprovider CASCADE;"
+ ),
+ migrations.CreateModel(
+ name="OAuth2Provider",
+ fields=[
+ (
+ "provider_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_core.provider",
+ ),
+ ),
+ ("name", models.TextField()),
+ (
+ "client_type",
+ models.CharField(
+ choices=[
+ ("confidential", "Confidential"),
+ ("public", "Public"),
+ ],
+ default="confidential",
+ help_text="Confidential clients are capable of maintaining the confidentiality\n of their credentials. Public clients are incapable.",
+ max_length=30,
+ verbose_name="Client Type",
+ ),
+ ),
+ (
+ "client_id",
+ models.CharField(
+ default=authentik.providers.oauth2.generators.generate_client_id,
+ max_length=255,
+ unique=True,
+ verbose_name="Client ID",
+ ),
+ ),
+ (
+ "client_secret",
+ models.CharField(
+ blank=True,
+ default=authentik.providers.oauth2.generators.generate_client_secret,
+ max_length=255,
+ verbose_name="Client Secret",
+ ),
+ ),
+ (
+ "response_type",
+ models.TextField(
+ choices=[
+ ("code", "code (Authorization Code Flow)"),
+ ("id_token", "id_token (Implicit Flow)"),
+ ("id_token token", "id_token token (Implicit Flow)"),
+ ("code token", "code token (Hybrid Flow)"),
+ ("code id_token", "code id_token (Hybrid Flow)"),
+ (
+ "code id_token token",
+ "code id_token token (Hybrid Flow)",
+ ),
+ ],
+ default="code",
+ help_text="Response Type required by the client.",
+ ),
+ ),
+ (
+ "jwt_alg",
+ models.CharField(
+ choices=[
+ ("HS256", "HS256 (Symmetric Encryption)"),
+ ("RS256", "RS256 (Asymmetric Encryption)"),
+ ],
+ default="RS256",
+ help_text="Algorithm used to sign the JWT Token",
+ max_length=10,
+ verbose_name="JWT Algorithm",
+ ),
+ ),
+ (
+ "redirect_uris",
+ models.TextField(
+ default="",
+ help_text="Enter each URI on a new line.",
+ verbose_name="Redirect URIs",
+ ),
+ ),
+ (
+ "post_logout_redirect_uris",
+ models.TextField(
+ blank=True,
+ default="",
+ help_text="Enter each URI on a new line.",
+ verbose_name="Post Logout Redirect URIs",
+ ),
+ ),
+ (
+ "include_claims_in_id_token",
+ models.BooleanField(
+ default=True,
+ help_text="Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.",
+ verbose_name="Include claims in id_token",
+ ),
+ ),
+ (
+ "token_validity",
+ models.TextField(
+ default="minutes=10",
+ help_text="Tokens not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
+ validators=[
+ authentik.lib.utils.time.timedelta_string_validator
+ ],
+ ),
+ ),
+ (
+ "rsa_key",
+ models.ForeignKey(
+ help_text="Key used to sign the tokens. Only required when JWT Algorithm is set to RS256.",
+ on_delete=django.db.models.deletion.CASCADE,
+ to="authentik_crypto.certificatekeypair",
+ verbose_name="RSA Key",
+ blank=True,
+ null=True,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "OAuth2/OpenID Provider",
+ "verbose_name_plural": "OAuth2/OpenID Providers",
+ },
+ bases=("authentik_core.provider",),
+ ),
+ migrations.CreateModel(
+ name="ScopeMapping",
+ fields=[
+ (
+ "propertymapping_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_core.propertymapping",
+ ),
+ ),
+ ("scope_name", models.TextField(help_text="Scope used by the client")),
+ (
+ "description",
+ models.TextField(
+ blank=True,
+ help_text="Description shown to the user when consenting. If left empty, the user won't be informed.",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Scope Mapping",
+ "verbose_name_plural": "Scope Mappings",
+ },
+ bases=("authentik_core.propertymapping",),
+ ),
+ migrations.RunPython(create_default_scopes),
+ migrations.CreateModel(
+ name="RefreshToken",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "expires",
+ models.DateTimeField(
+ default=authentik.core.models.default_token_duration
+ ),
+ ),
+ ("expiring", models.BooleanField(default=True)),
+ ("_scope", models.TextField(default="", verbose_name="Scopes")),
+ (
+ "access_token",
+ models.CharField(
+ max_length=255, unique=True, verbose_name="Access Token"
+ ),
+ ),
+ (
+ "refresh_token",
+ models.CharField(
+ max_length=255, unique=True, verbose_name="Refresh Token"
+ ),
+ ),
+ ("_id_token", models.TextField(verbose_name="ID Token")),
+ (
+ "provider",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="authentik_providers_oauth2.oauth2provider",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="User",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Token",
+ "verbose_name_plural": "Tokens",
+ },
+ ),
+ migrations.CreateModel(
+ name="AuthorizationCode",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "expires",
+ models.DateTimeField(
+ default=authentik.core.models.default_token_duration
+ ),
+ ),
+ ("expiring", models.BooleanField(default=True)),
+ ("_scope", models.TextField(default="", verbose_name="Scopes")),
+ (
+ "code",
+ models.CharField(max_length=255, unique=True, verbose_name="Code"),
+ ),
+ (
+ "nonce",
+ models.CharField(
+ blank=True, default="", max_length=255, verbose_name="Nonce"
+ ),
+ ),
+ (
+ "is_open_id",
+ models.BooleanField(
+ default=False, verbose_name="Is Authentication?"
+ ),
+ ),
+ (
+ "code_challenge",
+ models.CharField(
+ max_length=255, null=True, verbose_name="Code Challenge"
+ ),
+ ),
+ (
+ "code_challenge_method",
+ models.CharField(
+ max_length=255, null=True, verbose_name="Code Challenge Method"
+ ),
+ ),
+ (
+ "provider",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="authentik_providers_oauth2.oauth2provider",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="User",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Authorization Code",
+ "verbose_name_plural": "Authorization Codes",
+ },
+ ),
+ ]
diff --git a/authentik/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py b/authentik/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py
new file mode 100644
index 000000000..895d6fa03
--- /dev/null
+++ b/authentik/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1.1 on 2020-09-15 18:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_oauth2", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="oauth2provider",
+ name="sub_mode",
+ field=models.TextField(
+ choices=[
+ ("hashed_user_id", "Based on the Hashed User ID"),
+ ("user_username", "Based on the username"),
+ (
+ "user_email",
+ "Based on the User's Email. This is recommended over the UPN method.",
+ ),
+ (
+ "user_upn",
+ "Based on the User's UPN, only works if user has a 'upn' attribute set. Use this method only if you have different UPN and Mail domains.",
+ ),
+ ],
+ default="hashed_user_id",
+ help_text="Configure what data should be used as unique User Identifier. For most cases, the default should be fine.",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/oauth2/migrations/0003_auto_20200916_2129.py b/authentik/providers/oauth2/migrations/0003_auto_20200916_2129.py
new file mode 100644
index 000000000..bc14353c3
--- /dev/null
+++ b/authentik/providers/oauth2/migrations/0003_auto_20200916_2129.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.1.1 on 2020-09-16 21:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_oauth2", "0002_oauth2provider_sub_mode"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="oauth2provider",
+ name="client_type",
+ field=models.CharField(
+ choices=[("confidential", "Confidential"), ("public", "Public")],
+ default="confidential",
+ help_text="Confidential clients are capable of maintaining the confidentiality\n of their credentials. Public clients are incapable.",
+ max_length=30,
+ verbose_name="Client Type",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="oauth2provider",
+ name="response_type",
+ field=models.TextField(
+ choices=[
+ ("code", "code (Authorization Code Flow)"),
+ (
+ "code_adfs",
+ "code (ADFS Compatibility Mode, sends id_token as access_token)",
+ ),
+ ("id_token", "id_token (Implicit Flow)"),
+ ("id_token token", "id_token token (Implicit Flow)"),
+ ("code token", "code token (Hybrid Flow)"),
+ ("code id_token", "code id_token (Hybrid Flow)"),
+ ("code id_token token", "code id_token token (Hybrid Flow)"),
+ ],
+ default="code",
+ help_text="Response Type required by the client.",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py b/authentik/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py
new file mode 100644
index 000000000..a5776fee8
--- /dev/null
+++ b/authentik/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1.1 on 2020-09-18 21:16
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_oauth2", "0003_auto_20200916_2129"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="oauth2provider",
+ name="post_logout_redirect_uris",
+ ),
+ ]
diff --git a/authentik/providers/oauth2/migrations/0005_auto_20200920_1240.py b/authentik/providers/oauth2/migrations/0005_auto_20200920_1240.py
new file mode 100644
index 000000000..eb50bb39d
--- /dev/null
+++ b/authentik/providers/oauth2/migrations/0005_auto_20200920_1240.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.1.1 on 2020-09-20 12:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ (
+ "authentik_providers_oauth2",
+ "0004_remove_oauth2provider_post_logout_redirect_uris",
+ ),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="oauth2provider",
+ name="response_type",
+ field=models.TextField(
+ choices=[
+ ("code", "code (Authorization Code Flow)"),
+ (
+ "code#adfs",
+ "code (ADFS Compatibility Mode, sends id_token as access_token)",
+ ),
+ ("id_token", "id_token (Implicit Flow)"),
+ ("id_token token", "id_token token (Implicit Flow)"),
+ ("code token", "code token (Hybrid Flow)"),
+ ("code id_token", "code id_token (Hybrid Flow)"),
+ ("code id_token token", "code id_token token (Hybrid Flow)"),
+ ],
+ default="code",
+ help_text="Response Type required by the client.",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/oauth2/migrations/0006_remove_oauth2provider_name.py b/authentik/providers/oauth2/migrations/0006_remove_oauth2provider_name.py
new file mode 100644
index 000000000..cead8957f
--- /dev/null
+++ b/authentik/providers/oauth2/migrations/0006_remove_oauth2provider_name.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.1.2 on 2020-10-03 17:37
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def update_name_temp(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ OAuth2Provider = apps.get_model("authentik_providers_oauth2", "OAuth2Provider")
+ db_alias = schema_editor.connection.alias
+
+ for provider in OAuth2Provider.objects.using(db_alias).all():
+ provider.name_temp = provider.name
+ provider.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0011_provider_name_temp"),
+ ("authentik_providers_oauth2", "0005_auto_20200920_1240"),
+ ]
+
+ operations = [
+ migrations.RunPython(update_name_temp),
+ migrations.RemoveField(
+ model_name="oauth2provider",
+ name="name",
+ ),
+ ]
diff --git a/authentik/providers/oauth2/migrations/0007_auto_20201016_1107.py b/authentik/providers/oauth2/migrations/0007_auto_20201016_1107.py
new file mode 100644
index 000000000..1f5cab066
--- /dev/null
+++ b/authentik/providers/oauth2/migrations/0007_auto_20201016_1107.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.1.2 on 2020-10-16 11:07
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_oauth2", "0006_remove_oauth2provider_name"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="refreshtoken",
+ options={
+ "verbose_name": "OAuth2 Token",
+ "verbose_name_plural": "OAuth2 Tokens",
+ },
+ ),
+ ]
diff --git a/passbook/providers/oauth2/migrations/__init__.py b/authentik/providers/oauth2/migrations/__init__.py
similarity index 100%
rename from passbook/providers/oauth2/migrations/__init__.py
rename to authentik/providers/oauth2/migrations/__init__.py
diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py
new file mode 100644
index 000000000..ebaa96dc6
--- /dev/null
+++ b/authentik/providers/oauth2/models.py
@@ -0,0 +1,499 @@
+"""OAuth Provider Models"""
+import base64
+import binascii
+import json
+import time
+from dataclasses import asdict, dataclass, field
+from hashlib import sha256
+from typing import Any, Dict, List, Optional, Type
+from urllib.parse import urlparse
+from uuid import uuid4
+
+from django.conf import settings
+from django.db import models
+from django.forms import ModelForm
+from django.http import HttpRequest
+from django.shortcuts import reverse
+from django.utils import dateformat, timezone
+from django.utils.translation import gettext_lazy as _
+from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key
+from jwkest.jws import JWS
+
+from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
+from authentik.crypto.models import CertificateKeyPair
+from authentik.lib.utils.template import render_to_string
+from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
+from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config
+from authentik.providers.oauth2.generators import (
+ generate_client_id,
+ generate_client_secret,
+)
+
+
+class ClientTypes(models.TextChoices):
+ """Confidential clients are capable of maintaining the confidentiality
+ of their credentials. Public clients are incapable."""
+
+ CONFIDENTIAL = "confidential", _("Confidential")
+ PUBLIC = "public", _("Public")
+
+
+class GrantTypes(models.TextChoices):
+ """OAuth2 Grant types we support"""
+
+ AUTHORIZATION_CODE = "authorization_code"
+ IMPLICIT = "implicit"
+ HYBRID = "hybrid"
+
+
+class SubModes(models.TextChoices):
+ """Mode after which 'sub' attribute is generateed, for compatibility reasons"""
+
+ HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
+ USER_USERNAME = "user_username", _("Based on the username")
+ USER_EMAIL = (
+ "user_email",
+ _("Based on the User's Email. This is recommended over the UPN method."),
+ )
+ USER_UPN = (
+ "user_upn",
+ _(
+ (
+ "Based on the User's UPN, only works if user has a 'upn' attribute set. "
+ "Use this method only if you have different UPN and Mail domains."
+ )
+ ),
+ )
+
+
+class ResponseTypes(models.TextChoices):
+ """Response Type required by the client."""
+
+ CODE = "code", _("code (Authorization Code Flow)")
+ CODE_ADFS = (
+ "code#adfs",
+ _("code (ADFS Compatibility Mode, sends id_token as access_token)"),
+ )
+ ID_TOKEN = "id_token", _("id_token (Implicit Flow)")
+ ID_TOKEN_TOKEN = "id_token token", _("id_token token (Implicit Flow)")
+ CODE_TOKEN = "code token", _("code token (Hybrid Flow)")
+ CODE_ID_TOKEN = "code id_token", _("code id_token (Hybrid Flow)")
+ CODE_ID_TOKEN_TOKEN = "code id_token token", _("code id_token token (Hybrid Flow)")
+
+
+class JWTAlgorithms(models.TextChoices):
+ """Algorithm used to sign the JWT Token"""
+
+ HS256 = "HS256", _("HS256 (Symmetric Encryption)")
+ RS256 = "RS256", _("RS256 (Asymmetric Encryption)")
+
+
+class ScopeMapping(PropertyMapping):
+ """Map an OAuth Scope to users properties"""
+
+ scope_name = models.TextField(help_text=_("Scope used by the client"))
+ description = models.TextField(
+ blank=True,
+ help_text=_(
+ (
+ "Description shown to the user when consenting. "
+ "If left empty, the user won't be informed."
+ )
+ ),
+ )
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.providers.oauth2.forms import ScopeMappingForm
+
+ return ScopeMappingForm
+
+ def __str__(self):
+ return f"Scope Mapping {self.name} ({self.scope_name})"
+
+ class Meta:
+
+ verbose_name = _("Scope Mapping")
+ verbose_name_plural = _("Scope Mappings")
+
+
+class OAuth2Provider(Provider):
+ """OAuth2 Provider for generic OAuth and OpenID Connect Applications."""
+
+ client_type = models.CharField(
+ max_length=30,
+ choices=ClientTypes.choices,
+ default=ClientTypes.CONFIDENTIAL,
+ verbose_name=_("Client Type"),
+ help_text=_(ClientTypes.__doc__),
+ )
+ client_id = models.CharField(
+ max_length=255,
+ unique=True,
+ verbose_name=_("Client ID"),
+ default=generate_client_id,
+ )
+ client_secret = models.CharField(
+ max_length=255,
+ blank=True,
+ verbose_name=_("Client Secret"),
+ default=generate_client_secret,
+ )
+ response_type = models.TextField(
+ choices=ResponseTypes.choices,
+ default=ResponseTypes.CODE,
+ help_text=_(ResponseTypes.__doc__),
+ )
+ jwt_alg = models.CharField(
+ max_length=10,
+ choices=JWTAlgorithms.choices,
+ default=JWTAlgorithms.RS256,
+ verbose_name=_("JWT Algorithm"),
+ help_text=_(JWTAlgorithms.__doc__),
+ )
+ redirect_uris = models.TextField(
+ default="",
+ verbose_name=_("Redirect URIs"),
+ help_text=_("Enter each URI on a new line."),
+ )
+
+ include_claims_in_id_token = models.BooleanField(
+ default=True,
+ verbose_name=_("Include claims in id_token"),
+ help_text=_(
+ (
+ "Include User claims from scopes in the id_token, for applications "
+ "that don't access the userinfo endpoint."
+ )
+ ),
+ )
+
+ token_validity = models.TextField(
+ default="minutes=10",
+ validators=[timedelta_string_validator],
+ help_text=_(
+ (
+ "Tokens not valid on or after current time + this value "
+ "(Format: hours=1;minutes=2;seconds=3)."
+ )
+ ),
+ )
+
+ sub_mode = models.TextField(
+ choices=SubModes.choices,
+ default=SubModes.HASHED_USER_ID,
+ help_text=_(
+ (
+ "Configure what data should be used as unique User Identifier. For most cases, "
+ "the default should be fine."
+ )
+ ),
+ )
+
+ rsa_key = models.ForeignKey(
+ CertificateKeyPair,
+ verbose_name=_("RSA Key"),
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True,
+ help_text=_(
+ "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256."
+ ),
+ )
+
+ def create_refresh_token(
+ self, user: User, scope: List[str], id_token: Optional["IDToken"] = None
+ ) -> "RefreshToken":
+ """Create and populate a RefreshToken object."""
+ token = RefreshToken(
+ user=user,
+ provider=self,
+ access_token=uuid4().hex,
+ refresh_token=uuid4().hex,
+ expires=timezone.now() + timedelta_from_string(self.token_validity),
+ scope=scope,
+ )
+ if id_token:
+ token.id_token = id_token
+ return token
+
+ def get_jwt_keys(self) -> List[Key]:
+ """
+ Takes a provider and returns the set of keys associated with it.
+ Returns a list of keys.
+ """
+ if self.jwt_alg == JWTAlgorithms.RS256:
+ # if the user selected RS256 but didn't select a
+ # CertificateKeyPair, we fall back to HS256
+ if not self.rsa_key:
+ self.jwt_alg = JWTAlgorithms.HS256
+ self.save()
+ else:
+ # Because the JWT Library uses python cryptodome,
+ # we can't directly pass the RSAPublicKey
+ # object, but have to load it ourselves
+ key = import_rsa_key(self.rsa_key.key_data)
+ keys = [RSAKey(key=key, kid=self.rsa_key.kid)]
+ if not keys:
+ raise Exception("You must add at least one RSA Key.")
+ return keys
+
+ if self.jwt_alg == JWTAlgorithms.HS256:
+ return [SYMKey(key=self.client_secret, alg=self.jwt_alg)]
+
+ raise Exception("Unsupported key algorithm.")
+
+ def get_issuer(self, request: HttpRequest) -> Optional[str]:
+ """Get issuer, based on request"""
+ try:
+ mountpoint = AuthentikProviderOAuth2Config.mountpoints[
+ "authentik.providers.oauth2.urls"
+ ]
+ # pylint: disable=no-member
+ return request.build_absolute_uri(f"/{mountpoint}{self.application.slug}/")
+ except Provider.application.RelatedObjectDoesNotExist:
+ return None
+
+ @property
+ def launch_url(self) -> Optional[str]:
+ """Guess launch_url based on first redirect_uri"""
+ if self.redirect_uris == "":
+ return None
+ main_url = self.redirect_uris.split("\n")[0]
+ launch_url = urlparse(main_url)
+ return main_url.replace(launch_url.path, "")
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.providers.oauth2.forms import OAuth2ProviderForm
+
+ return OAuth2ProviderForm
+
+ def __str__(self):
+ return f"OAuth2 Provider {self.name}"
+
+ def encode(self, payload: Dict[str, Any]) -> str:
+ """Represent the ID Token as a JSON Web Token (JWT)."""
+ keys = self.get_jwt_keys()
+ # If the provider does not have an RSA Key assigned, it was switched to Symmetric
+ self.refresh_from_db()
+ jws = JWS(payload, alg=self.jwt_alg)
+ return jws.sign_compact(keys)
+
+ def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
+ """return template and context modal with URLs for authorize, token, openid-config, etc"""
+ try:
+ # pylint: disable=no-member
+ return render_to_string(
+ "providers/oauth2/setup_url_modal.html",
+ {
+ "provider": self,
+ "issuer": self.get_issuer(request),
+ "authorize": request.build_absolute_uri(
+ reverse(
+ "authentik_providers_oauth2:authorize",
+ )
+ ),
+ "token": request.build_absolute_uri(
+ reverse(
+ "authentik_providers_oauth2:token",
+ )
+ ),
+ "userinfo": request.build_absolute_uri(
+ reverse(
+ "authentik_providers_oauth2:userinfo",
+ )
+ ),
+ "provider_info": request.build_absolute_uri(
+ reverse(
+ "authentik_providers_oauth2:provider-info",
+ kwargs={"application_slug": self.application.slug},
+ )
+ ),
+ },
+ )
+ except Provider.application.RelatedObjectDoesNotExist:
+ return None
+
+ class Meta:
+
+ verbose_name = _("OAuth2/OpenID Provider")
+ verbose_name_plural = _("OAuth2/OpenID Providers")
+
+
+class BaseGrantModel(models.Model):
+ """Base Model for all grants"""
+
+ provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE)
+ user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
+ _scope = models.TextField(default="", verbose_name=_("Scopes"))
+
+ @property
+ def scope(self) -> List[str]:
+ """Return scopes as list of strings"""
+ return self._scope.split()
+
+ @scope.setter
+ def scope(self, value):
+ self._scope = " ".join(value)
+
+ class Meta:
+ abstract = True
+
+
+class AuthorizationCode(ExpiringModel, BaseGrantModel):
+ """OAuth2 Authorization Code"""
+
+ code = models.CharField(max_length=255, unique=True, verbose_name=_("Code"))
+ nonce = models.CharField(
+ max_length=255, blank=True, default="", verbose_name=_("Nonce")
+ )
+ is_open_id = models.BooleanField(
+ default=False, verbose_name=_("Is Authentication?")
+ )
+ code_challenge = models.CharField(
+ max_length=255, null=True, verbose_name=_("Code Challenge")
+ )
+ code_challenge_method = models.CharField(
+ max_length=255, null=True, verbose_name=_("Code Challenge Method")
+ )
+
+ class Meta:
+ verbose_name = _("Authorization Code")
+ verbose_name_plural = _("Authorization Codes")
+
+ def __str__(self):
+ return "{0} - {1}".format(self.provider, self.code)
+
+
+@dataclass
+class IDToken:
+ """The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be
+ Authenticated is the ID Token data structure. The ID Token is a security token that contains
+ Claims about the Authentication of an End-User by an Authorization Server when using a Client,
+ and potentially other requested Claims. The ID Token is represented as a
+ JSON Web Token (JWT) [JWT].
+
+ https://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
+
+ # All these fields need to optional so we can save an empty IDToken for non-OpenID flows.
+ iss: Optional[str] = None
+ sub: Optional[str] = None
+ aud: Optional[str] = None
+ exp: Optional[int] = None
+ iat: Optional[int] = None
+ auth_time: Optional[int] = None
+
+ nonce: Optional[str] = None
+ at_hash: Optional[str] = None
+
+ claims: Dict[str, Any] = field(default_factory=dict)
+
+ @staticmethod
+ def from_dict(data: Dict[str, Any]) -> "IDToken":
+ """Reconstruct ID Token from json dictionary"""
+ token = IDToken()
+ for key, value in data.items():
+ setattr(token, key, value)
+ return token
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert dataclass to dict, and update with keys from `claims`"""
+ dic = asdict(self)
+ dic.pop("claims")
+ dic.update(self.claims)
+ return dic
+
+
+class RefreshToken(ExpiringModel, BaseGrantModel):
+ """OAuth2 Refresh Token"""
+
+ access_token = models.CharField(
+ max_length=255, unique=True, verbose_name=_("Access Token")
+ )
+ refresh_token = models.CharField(
+ max_length=255, unique=True, verbose_name=_("Refresh Token")
+ )
+ _id_token = models.TextField(verbose_name=_("ID Token"))
+
+ class Meta:
+ verbose_name = _("OAuth2 Token")
+ verbose_name_plural = _("OAuth2 Tokens")
+
+ @property
+ def id_token(self) -> IDToken:
+ """Load ID Token from json"""
+ if self._id_token:
+ raw_token = json.loads(self._id_token)
+ return IDToken.from_dict(raw_token)
+ return IDToken()
+
+ @id_token.setter
+ def id_token(self, value: IDToken):
+ self._id_token = json.dumps(asdict(value))
+
+ def __str__(self):
+ return f"{self.provider} - {self.access_token}"
+
+ @property
+ def at_hash(self):
+ """Get hashed access_token"""
+ hashed_access_token = (
+ sha256(self.access_token.encode("ascii")).hexdigest().encode("ascii")
+ )
+ return (
+ base64.urlsafe_b64encode(
+ binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2])
+ )
+ .rstrip(b"=")
+ .decode("ascii")
+ )
+
+ def create_id_token(self, user: User, request: HttpRequest) -> IDToken:
+ """Creates the id_token.
+ See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
+ sub = ""
+ if self.provider.sub_mode == SubModes.HASHED_USER_ID:
+ sub = sha256(f"{user.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
+ elif self.provider.sub_mode == SubModes.USER_EMAIL:
+ sub = user.email
+ elif self.provider.sub_mode == SubModes.USER_USERNAME:
+ sub = user.username
+ elif self.provider.sub_mode == SubModes.USER_UPN:
+ sub = user.attributes["upn"]
+ else:
+ raise ValueError(
+ (
+ f"Provider {self.provider} has invalid sub_mode "
+ f"selected: {self.provider.sub_mode}"
+ )
+ )
+
+ # Convert datetimes into timestamps.
+ now = int(time.time())
+ iat_time = now
+ exp_time = int(
+ now + timedelta_from_string(self.provider.token_validity).seconds
+ )
+ user_auth_time = user.last_login or user.date_joined
+ auth_time = int(dateformat.format(user_auth_time, "U"))
+
+ token = IDToken(
+ iss=self.provider.get_issuer(request),
+ sub=sub,
+ aud=self.provider.client_id,
+ exp=exp_time,
+ iat=iat_time,
+ auth_time=auth_time,
+ )
+
+ # Include (or not) user standard claims in the id_token.
+ if self.provider.include_claims_in_id_token:
+ from authentik.providers.oauth2.views.userinfo import UserInfoView
+
+ user_info = UserInfoView()
+ user_info.request = request
+ claims = user_info.get_claims(self)
+ token.claims = claims
+
+ return token
diff --git a/passbook/providers/oauth2/templates/providers/oauth2/consent.html b/authentik/providers/oauth2/templates/providers/oauth2/consent.html
similarity index 100%
rename from passbook/providers/oauth2/templates/providers/oauth2/consent.html
rename to authentik/providers/oauth2/templates/providers/oauth2/consent.html
diff --git a/authentik/providers/oauth2/templates/providers/oauth2/end_session.html b/authentik/providers/oauth2/templates/providers/oauth2/end_session.html
new file mode 100644
index 000000000..c87b36347
--- /dev/null
+++ b/authentik/providers/oauth2/templates/providers/oauth2/end_session.html
@@ -0,0 +1,38 @@
+{% extends 'login/base_full.html' %}
+
+{% load static %}
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block title %}
+{% trans 'End session' %}
+{% endblock %}
+
+{% block card_title %}
+{% blocktrans with application=application.name %}
+You've logged out of {{ application }}.
+{% endblocktrans %}
+{% endblock %}
+
+{% block card %}
+
+{% endblock %}
diff --git a/authentik/providers/oauth2/templates/providers/oauth2/property_mapping_form.html b/authentik/providers/oauth2/templates/providers/oauth2/property_mapping_form.html
new file mode 100644
index 000000000..c202b5b29
--- /dev/null
+++ b/authentik/providers/oauth2/templates/providers/oauth2/property_mapping_form.html
@@ -0,0 +1,14 @@
+{% extends "generic/form.html" %}
+
+{% load i18n %}
+
+{% block beneath_form %}
+
+{% endblock %}
diff --git a/authentik/providers/oauth2/templates/providers/oauth2/setup_url_modal.html b/authentik/providers/oauth2/templates/providers/oauth2/setup_url_modal.html
new file mode 100644
index 000000000..f17b87810
--- /dev/null
+++ b/authentik/providers/oauth2/templates/providers/oauth2/setup_url_modal.html
@@ -0,0 +1,50 @@
+{% load i18n %}
+
+
+
+ {% trans 'View Setup URLs' %}
+
+
+
diff --git a/authentik/providers/oauth2/urls.py b/authentik/providers/oauth2/urls.py
new file mode 100644
index 000000000..2f15b34f6
--- /dev/null
+++ b/authentik/providers/oauth2/urls.py
@@ -0,0 +1,43 @@
+"""OAuth provider URLs"""
+from django.urls import path
+from django.views.decorators.csrf import csrf_exempt
+
+from authentik.providers.oauth2.constants import SCOPE_OPENID
+from authentik.providers.oauth2.utils import protected_resource_view
+from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView
+from authentik.providers.oauth2.views.introspection import TokenIntrospectionView
+from authentik.providers.oauth2.views.jwks import JWKSView
+from authentik.providers.oauth2.views.provider import ProviderInfoView
+from authentik.providers.oauth2.views.session import EndSessionView
+from authentik.providers.oauth2.views.token import TokenView
+from authentik.providers.oauth2.views.userinfo import UserInfoView
+
+urlpatterns = [
+ path(
+ "authorize/",
+ AuthorizationFlowInitView.as_view(),
+ name="authorize",
+ ),
+ path("token/", csrf_exempt(TokenView.as_view()), name="token"),
+ path(
+ "userinfo/",
+ csrf_exempt(protected_resource_view([SCOPE_OPENID])(UserInfoView.as_view())),
+ name="userinfo",
+ ),
+ path(
+ "introspect/",
+ csrf_exempt(TokenIntrospectionView.as_view()),
+ name="token-introspection",
+ ),
+ path(
+ "/end-session/",
+ EndSessionView.as_view(),
+ name="end-session",
+ ),
+ path("/jwks/", JWKSView.as_view(), name="jwks"),
+ path(
+ "/.well-known/openid-configuration",
+ ProviderInfoView.as_view(),
+ name="provider-info",
+ ),
+]
diff --git a/authentik/providers/oauth2/urls_github.py b/authentik/providers/oauth2/urls_github.py
new file mode 100644
index 000000000..77dba0826
--- /dev/null
+++ b/authentik/providers/oauth2/urls_github.py
@@ -0,0 +1,45 @@
+"""authentik oauth_provider urls"""
+from django.urls import include, path
+from django.views.decorators.csrf import csrf_exempt
+
+from authentik.providers.oauth2.constants import (
+ SCOPE_GITHUB_ORG_READ,
+ SCOPE_GITHUB_USER_EMAIL,
+)
+from authentik.providers.oauth2.utils import protected_resource_view
+from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView
+from authentik.providers.oauth2.views.github import GitHubUserTeamsView, GitHubUserView
+from authentik.providers.oauth2.views.token import TokenView
+
+github_urlpatterns = [
+ path(
+ "login/oauth/authorize",
+ AuthorizationFlowInitView.as_view(),
+ name="github-authorize",
+ ),
+ path(
+ "login/oauth/access_token",
+ csrf_exempt(TokenView.as_view()),
+ name="github-access-token",
+ ),
+ path(
+ "user",
+ csrf_exempt(
+ protected_resource_view([SCOPE_GITHUB_USER_EMAIL])(GitHubUserView.as_view())
+ ),
+ name="github-user",
+ ),
+ path(
+ "user/teams",
+ csrf_exempt(
+ protected_resource_view([SCOPE_GITHUB_ORG_READ])(
+ GitHubUserTeamsView.as_view()
+ )
+ ),
+ name="github-user-teams",
+ ),
+]
+
+urlpatterns = [
+ path("", include(github_urlpatterns)),
+]
diff --git a/authentik/providers/oauth2/utils.py b/authentik/providers/oauth2/utils.py
new file mode 100644
index 000000000..23a6a9a2c
--- /dev/null
+++ b/authentik/providers/oauth2/utils.py
@@ -0,0 +1,156 @@
+"""OAuth2/OpenID Utils"""
+import re
+from base64 import b64decode
+from binascii import Error
+from typing import List, Optional, Tuple
+
+from django.http import HttpRequest, HttpResponse, JsonResponse
+from django.utils.cache import patch_vary_headers
+from jwkest.jwt import JWT
+from structlog import get_logger
+
+from authentik.providers.oauth2.errors import BearerTokenError
+from authentik.providers.oauth2.models import RefreshToken
+
+LOGGER = get_logger()
+
+
+class TokenResponse(JsonResponse):
+ """JSON Response with headers that it should never be cached
+
+ https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self["Cache-Control"] = "no-store"
+ self["Pragma"] = "no-cache"
+
+
+def cors_allow_any(request, response):
+ """
+ Add headers to permit CORS requests from any origin, with or without credentials,
+ with any headers.
+ """
+ origin = request.META.get("HTTP_ORIGIN")
+ if not origin:
+ return response
+
+ # From the CORS spec: The string "*" cannot be used for a resource that supports credentials.
+ response["Access-Control-Allow-Origin"] = origin
+ patch_vary_headers(response, ["Origin"])
+ response["Access-Control-Allow-Credentials"] = "true"
+
+ if request.method == "OPTIONS":
+ if "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" in request.META:
+ response["Access-Control-Allow-Headers"] = request.META[
+ "HTTP_ACCESS_CONTROL_REQUEST_HEADERS"
+ ]
+ response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
+
+ return response
+
+
+def extract_access_token(request: HttpRequest) -> Optional[str]:
+ """
+ Get the access token using Authorization Request Header Field method.
+ Or try getting via GET.
+ See: http://tools.ietf.org/html/rfc6750#section-2.1
+
+ Return a string.
+ """
+ auth_header = request.META.get("HTTP_AUTHORIZATION", "")
+
+ if re.compile(r"^[Bb]earer\s{1}.+$").match(auth_header):
+ return auth_header.split()[1]
+ if "access_token" in request.POST:
+ return request.POST.get("access_token")
+ if "access_token" in request.GET:
+ return request.GET.get("access_token")
+ return None
+
+
+def extract_client_auth(request: HttpRequest) -> Tuple[str, str]:
+ """
+ Get client credentials using HTTP Basic Authentication method.
+ Or try getting parameters via POST.
+ See: http://tools.ietf.org/html/rfc6750#section-2.1
+
+ Return a tuple `(client_id, client_secret)`.
+ """
+ auth_header = request.META.get("HTTP_AUTHORIZATION", "")
+
+ if re.compile(r"^Basic\s{1}.+$").match(auth_header):
+ b64_user_pass = auth_header.split()[1]
+ try:
+ user_pass = b64decode(b64_user_pass).decode("utf-8").split(":")
+ client_id, client_secret = user_pass
+ except (ValueError, Error):
+ client_id = client_secret = ""
+ else:
+ client_id = request.POST.get("client_id", "")
+ client_secret = request.POST.get("client_secret", "")
+
+ return (client_id, client_secret)
+
+
+def protected_resource_view(scopes: List[str]):
+ """View decorator. The client accesses protected resources by presenting the
+ access token to the resource server.
+
+ https://tools.ietf.org/html/rfc6749#section-7
+
+ This decorator also injects the token into `kwargs`"""
+
+ def wrapper(view):
+ def view_wrapper(request, *args, **kwargs):
+ try:
+ access_token = extract_access_token(request)
+ if not access_token:
+ LOGGER.debug("No token passed")
+ raise BearerTokenError("invalid_token")
+
+ try:
+ kwargs["token"] = RefreshToken.objects.get(
+ access_token=access_token
+ )
+ except RefreshToken.DoesNotExist:
+ LOGGER.debug("Token does not exist", access_token=access_token)
+ raise BearerTokenError("invalid_token")
+
+ if kwargs["token"].is_expired:
+ LOGGER.debug("Token has expired", access_token=access_token)
+ raise BearerTokenError("invalid_token")
+
+ if not set(scopes).issubset(set(kwargs["token"].scope)):
+ LOGGER.warning(
+ "Scope missmatch.",
+ required=set(scopes),
+ token_has=set(kwargs["token"].scope),
+ )
+ raise BearerTokenError("insufficient_scope")
+ except BearerTokenError as error:
+ response = HttpResponse(status=error.status)
+ response[
+ "WWW-Authenticate"
+ ] = f'error="{error.code}", error_description="{error.description}"'
+ return response
+
+ return view(request, *args, **kwargs)
+
+ return view_wrapper
+
+ return wrapper
+
+
+def client_id_from_id_token(id_token):
+ """
+ Extracts the client id from a JSON Web Token (JWT).
+ Returns a string or None.
+ """
+ payload = JWT().unpack(id_token).payload()
+ aud = payload.get("aud", None)
+ if aud is None:
+ return None
+ if isinstance(aud, list):
+ return aud[0]
+ return aud
diff --git a/passbook/providers/oauth2/views/__init__.py b/authentik/providers/oauth2/views/__init__.py
similarity index 100%
rename from passbook/providers/oauth2/views/__init__.py
rename to authentik/providers/oauth2/views/__init__.py
diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py
new file mode 100644
index 000000000..f13bd8c50
--- /dev/null
+++ b/authentik/providers/oauth2/views/authorize.py
@@ -0,0 +1,382 @@
+"""authentik OAuth2 Authorization views"""
+from dataclasses import dataclass, field
+from typing import List, Optional, Set
+from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
+from uuid import uuid4
+
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404, redirect
+from django.utils import timezone
+from structlog import get_logger
+
+from authentik.audit.models import Event, EventAction
+from authentik.core.models import Application
+from authentik.flows.models import in_memory_stage
+from authentik.flows.planner import (
+ PLAN_CONTEXT_APPLICATION,
+ PLAN_CONTEXT_SSO,
+ FlowPlan,
+ FlowPlanner,
+)
+from authentik.flows.stage import StageView
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.lib.utils.time import timedelta_from_string
+from authentik.lib.utils.urls import redirect_with_qs
+from authentik.lib.views import bad_request_message
+from authentik.policies.views import PolicyAccessView
+from authentik.providers.oauth2.constants import (
+ PROMPT_CONSNET,
+ PROMPT_NONE,
+ SCOPE_OPENID,
+)
+from authentik.providers.oauth2.errors import (
+ AuthorizeError,
+ ClientIdError,
+ OAuth2Error,
+ RedirectUriError,
+)
+from authentik.providers.oauth2.models import (
+ AuthorizationCode,
+ GrantTypes,
+ OAuth2Provider,
+ ResponseTypes,
+)
+from authentik.providers.oauth2.views.userinfo import UserInfoView
+from authentik.stages.consent.models import ConsentMode, ConsentStage
+from authentik.stages.consent.stage import (
+ PLAN_CONTEXT_CONSENT_TEMPLATE,
+ ConsentStageView,
+)
+
+LOGGER = get_logger()
+
+PLAN_CONTEXT_PARAMS = "params"
+PLAN_CONTEXT_SCOPE_DESCRIPTIONS = "scope_descriptions"
+
+ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET}
+
+
+@dataclass
+class OAuthAuthorizationParams:
+ """Parameteres required to authorize an OAuth Client"""
+
+ client_id: str
+ redirect_uri: str
+ response_type: str
+ scope: List[str]
+ state: str
+ nonce: str
+ prompt: Set[str]
+ grant_type: str
+
+ provider: OAuth2Provider = field(default_factory=OAuth2Provider)
+
+ code_challenge: Optional[str] = None
+ code_challenge_method: Optional[str] = None
+
+ @staticmethod
+ def from_request(request: HttpRequest) -> "OAuthAuthorizationParams":
+ """
+ Get all the params used by the Authorization Code Flow
+ (and also for the Implicit and Hybrid).
+
+ See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
+ """
+ # Because in this endpoint we handle both GET
+ # and POST request.
+ query_dict = request.POST if request.method == "POST" else request.GET
+
+ response_type = query_dict.get("response_type", "")
+ grant_type = None
+ # Determine which flow to use.
+ if response_type in [ResponseTypes.CODE, ResponseTypes.CODE_ADFS]:
+ grant_type = GrantTypes.AUTHORIZATION_CODE
+ elif response_type in [
+ ResponseTypes.ID_TOKEN,
+ ResponseTypes.ID_TOKEN_TOKEN,
+ ResponseTypes.CODE_TOKEN,
+ ]:
+ grant_type = GrantTypes.IMPLICIT
+ elif response_type in [
+ ResponseTypes.CODE_TOKEN,
+ ResponseTypes.CODE_ID_TOKEN,
+ ResponseTypes.CODE_ID_TOKEN_TOKEN,
+ ]:
+ grant_type = GrantTypes.HYBRID
+
+ # Grant type validation.
+ if not grant_type:
+ LOGGER.warning("Invalid response type", type=response_type)
+ raise AuthorizeError(
+ query_dict.get("redirect_uri", ""),
+ "unsupported_response_type",
+ grant_type,
+ )
+
+ return OAuthAuthorizationParams(
+ client_id=query_dict.get("client_id", ""),
+ redirect_uri=query_dict.get("redirect_uri", ""),
+ response_type=response_type,
+ grant_type=grant_type,
+ scope=query_dict.get("scope", "").split(),
+ state=query_dict.get("state", ""),
+ nonce=query_dict.get("nonce", ""),
+ prompt=ALLOWED_PROMPT_PARAMS.intersection(
+ set(query_dict.get("prompt", "").split())
+ ),
+ code_challenge=query_dict.get("code_challenge"),
+ code_challenge_method=query_dict.get("code_challenge_method"),
+ )
+
+ def __post_init__(self):
+ try:
+ self.provider: OAuth2Provider = OAuth2Provider.objects.get(
+ client_id=self.client_id
+ )
+ except OAuth2Provider.DoesNotExist:
+ LOGGER.warning("Invalid client identifier", client_id=self.client_id)
+ raise ClientIdError()
+ is_open_id = SCOPE_OPENID in self.scope
+
+ # Redirect URI validation.
+ if is_open_id and not self.redirect_uri:
+ LOGGER.warning("Missing redirect uri.")
+ raise RedirectUriError()
+ if self.redirect_uri.lower() not in [
+ x.lower() for x in self.provider.redirect_uris.split()
+ ]:
+ LOGGER.warning(
+ "Invalid redirect uri",
+ redirect_uri=self.redirect_uri,
+ excepted=self.provider.redirect_uris.split(),
+ )
+ raise RedirectUriError()
+
+ if not is_open_id and (
+ self.grant_type == GrantTypes.HYBRID
+ or self.response_type
+ in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
+ ):
+ LOGGER.warning("Missing 'openid' scope.")
+ raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type)
+
+ # Nonce parameter validation.
+ if is_open_id and self.grant_type == GrantTypes.IMPLICIT and not self.nonce:
+ raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
+
+ # Response type parameter validation.
+ if is_open_id:
+ actual_response_type = self.provider.response_type
+ if "#" in self.provider.response_type:
+ hash_index = actual_response_type.index("#")
+ actual_response_type = actual_response_type[:hash_index]
+ if self.response_type != actual_response_type:
+ raise AuthorizeError(
+ self.redirect_uri, "invalid_request", self.grant_type
+ )
+
+ # PKCE validation of the transformation method.
+ if self.code_challenge:
+ if not (self.code_challenge_method in ["plain", "S256"]):
+ raise AuthorizeError(
+ self.redirect_uri, "invalid_request", self.grant_type
+ )
+
+ def create_code(self, request: HttpRequest) -> AuthorizationCode:
+ """Create an AuthorizationCode object for the request"""
+ code = AuthorizationCode()
+ code.user = request.user
+ code.provider = self.provider
+
+ code.code = uuid4().hex
+
+ if self.code_challenge and self.code_challenge_method:
+ code.code_challenge = self.code_challenge
+ code.code_challenge_method = self.code_challenge_method
+
+ code.expires_at = timezone.now() + timedelta_from_string(
+ self.provider.token_validity
+ )
+ code.scope = self.scope
+ code.nonce = self.nonce
+ code.is_open_id = SCOPE_OPENID in self.scope
+
+ return code
+
+
+class OAuthFulfillmentStage(StageView):
+ """Final stage, restores params from Flow."""
+
+ params: OAuthAuthorizationParams
+ provider: OAuth2Provider
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ self.params: OAuthAuthorizationParams = self.executor.plan.context.pop(
+ PLAN_CONTEXT_PARAMS
+ )
+ application: Application = self.executor.plan.context.pop(
+ PLAN_CONTEXT_APPLICATION
+ )
+ self.provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
+ try:
+ # At this point we don't need to check permissions anymore
+ if {PROMPT_NONE, PROMPT_CONSNET}.issubset(self.params.prompt):
+ raise AuthorizeError(
+ self.params.redirect_uri,
+ "consent_required",
+ self.params.grant_type,
+ )
+ Event.new(
+ EventAction.AUTHORIZE_APPLICATION,
+ authorized_application=application,
+ flow=self.executor.plan.flow_pk,
+ ).from_http(self.request)
+ return redirect(self.create_response_uri())
+ except (ClientIdError, RedirectUriError) as error:
+ self.executor.stage_invalid()
+ # pylint: disable=no-member
+ return bad_request_message(request, error.description, title=error.error)
+ except AuthorizeError as error:
+ self.executor.stage_invalid()
+ uri = error.create_uri(self.params.redirect_uri, self.params.state)
+ return redirect(uri)
+
+ def create_response_uri(self) -> str:
+ """Create a final Response URI the user is redirected to."""
+ uri = urlsplit(self.params.redirect_uri)
+ query_params = parse_qs(uri.query)
+ query_fragment = {}
+
+ try:
+ code = None
+
+ if self.params.grant_type in [
+ GrantTypes.AUTHORIZATION_CODE,
+ GrantTypes.HYBRID,
+ ]:
+ code = self.params.create_code(self.request)
+ code.save()
+
+ if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
+ query_params["code"] = code.code
+ query_params["state"] = [
+ str(self.params.state) if self.params.state else ""
+ ]
+ elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
+ token = self.provider.create_refresh_token(
+ user=self.request.user,
+ scope=self.params.scope,
+ )
+
+ # Check if response_type must include access_token in the response.
+ if self.params.response_type in [
+ ResponseTypes.ID_TOKEN_TOKEN,
+ ResponseTypes.CODE_ID_TOKEN_TOKEN,
+ ResponseTypes.ID_TOKEN,
+ ResponseTypes.CODE_TOKEN,
+ ]:
+ query_fragment["access_token"] = token.access_token
+
+ # We don't need id_token if it's an OAuth2 request.
+ if SCOPE_OPENID in self.params.scope:
+ id_token = token.create_id_token(
+ user=self.request.user,
+ request=self.request,
+ )
+ id_token.nonce = self.params.nonce
+
+ # Include at_hash when access_token is being returned.
+ if "access_token" in query_fragment:
+ id_token.at_hash = token.at_hash
+
+ # Check if response_type must include id_token in the response.
+ if self.params.response_type in [
+ ResponseTypes.ID_TOKEN,
+ ResponseTypes.ID_TOKEN_TOKEN,
+ ResponseTypes.CODE_ID_TOKEN,
+ ResponseTypes.CODE_ID_TOKEN_TOKEN,
+ ]:
+ query_fragment["id_token"] = self.provider.encode(
+ id_token.to_dict()
+ )
+ token.id_token = id_token
+
+ # Store the token.
+ token.save()
+
+ # Code parameter must be present if it's Hybrid Flow.
+ if self.params.grant_type == GrantTypes.HYBRID:
+ query_fragment["code"] = code.code
+
+ query_fragment["token_type"] = "bearer"
+ query_fragment["expires_in"] = timedelta_from_string(
+ self.provider.token_validity
+ ).seconds
+ query_fragment["state"] = self.params.state if self.params.state else ""
+
+ except OAuth2Error as error:
+ LOGGER.exception("Error when trying to create response uri", error=error)
+ raise AuthorizeError(
+ self.params.redirect_uri, "server_error", self.params.grant_type
+ )
+
+ uri = uri._replace(
+ query=urlencode(query_params, doseq=True),
+ fragment=uri.fragment + urlencode(query_fragment, doseq=True),
+ )
+
+ return urlunsplit(uri)
+
+
+class AuthorizationFlowInitView(PolicyAccessView):
+ """OAuth2 Flow initializer, checks access to application and starts flow"""
+
+ def resolve_provider_application(self):
+ client_id = self.request.GET.get("client_id")
+ self.provider = get_object_or_404(OAuth2Provider, client_id=client_id)
+ self.application = self.provider.application
+
+ # pylint: disable=unused-argument
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Check access to application, start FlowPLanner, return to flow executor shell"""
+ # Extract params so we can save them in the plan context
+ try:
+ params = OAuthAuthorizationParams.from_request(request)
+ except (ClientIdError, RedirectUriError) as error:
+ # pylint: disable=no-member
+ return bad_request_message(request, error.description, title=error.error)
+ # Regardless, we start the planner and return to it
+ planner = FlowPlanner(self.provider.authorization_flow)
+ # planner.use_cache = False
+ planner.allow_empty_flows = True
+ plan: FlowPlan = planner.plan(
+ self.request,
+ {
+ PLAN_CONTEXT_SSO: True,
+ PLAN_CONTEXT_APPLICATION: self.application,
+ # OAuth2 related params
+ PLAN_CONTEXT_PARAMS: params,
+ PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions(
+ params.scope
+ ),
+ # Consent related params
+ PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth2/consent.html",
+ },
+ )
+ # OpenID clients can specify a `prompt` parameter, and if its set to consent we
+ # need to inject a consent stage
+ if PROMPT_CONSNET in params.prompt:
+ if not any([isinstance(x, ConsentStageView) for x in plan.stages]):
+ # Plan does not have any consent stage, so we add an in-memory one
+ stage = ConsentStage(
+ name="OAuth2 Provider In-memory consent stage",
+ mode=ConsentMode.ALWAYS_REQUIRE,
+ )
+ plan.append(stage)
+ plan.append(in_memory_stage(OAuthFulfillmentStage))
+ self.request.session[SESSION_KEY_PLAN] = plan
+ return redirect_with_qs(
+ "authentik_flows:flow-executor-shell",
+ self.request.GET,
+ flow_slug=self.provider.authorization_flow.slug,
+ )
diff --git a/authentik/providers/oauth2/views/github.py b/authentik/providers/oauth2/views/github.py
new file mode 100644
index 000000000..d9a001c26
--- /dev/null
+++ b/authentik/providers/oauth2/views/github.py
@@ -0,0 +1,69 @@
+"""authentik pretend GitHub Views"""
+from django.http import HttpRequest, HttpResponse, JsonResponse
+from django.views import View
+
+from authentik.providers.oauth2.models import RefreshToken
+
+
+class GitHubUserView(View):
+ """Emulate GitHub's /user API Endpoint"""
+
+ def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
+ """Emulate GitHub's /user API Endpoint"""
+ user = token.user
+ return JsonResponse(
+ {
+ "login": user.username,
+ "id": user.pk,
+ "node_id": "",
+ "avatar_url": "",
+ "gravatar_id": "",
+ "url": "",
+ "html_url": "",
+ "followers_url": "",
+ "following_url": "",
+ "gists_url": "",
+ "starred_url": "",
+ "subscriptions_url": "",
+ "organizations_url": "",
+ "repos_url": "",
+ "events_url": "",
+ "received_events_url": "",
+ "type": "User",
+ "site_admin": False,
+ "name": user.name,
+ "company": "",
+ "blog": "",
+ "location": "",
+ "email": user.email,
+ "hireable": False,
+ "bio": "",
+ "public_repos": 0,
+ "public_gists": 0,
+ "followers": 0,
+ "following": 0,
+ "created_at": user.date_joined,
+ "updated_at": user.date_joined,
+ "private_gists": 0,
+ "total_private_repos": 0,
+ "owned_private_repos": 0,
+ "disk_usage": 0,
+ "collaborators": 0,
+ "two_factor_authentication": True,
+ "plan": {
+ "name": "None",
+ "space": 0,
+ "private_repos": 0,
+ "collaborators": 0,
+ },
+ }
+ )
+
+
+class GitHubUserTeamsView(View):
+ """Emulate GitHub's /user/teams API Endpoint"""
+
+ # pylint: disable=unused-argument
+ def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
+ """Emulate GitHub's /user/teams API Endpoint"""
+ return JsonResponse([], safe=False)
diff --git a/authentik/providers/oauth2/views/introspection.py b/authentik/providers/oauth2/views/introspection.py
new file mode 100644
index 000000000..cb38e6dcc
--- /dev/null
+++ b/authentik/providers/oauth2/views/introspection.py
@@ -0,0 +1,124 @@
+"""authentik OAuth2 Token Introspection Views"""
+from dataclasses import dataclass, field
+
+from django.http import HttpRequest, HttpResponse
+from django.views import View
+from structlog import get_logger
+
+from authentik.providers.oauth2.errors import TokenIntrospectionError
+from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
+from authentik.providers.oauth2.utils import (
+ TokenResponse,
+ extract_access_token,
+ extract_client_auth,
+)
+
+LOGGER = get_logger()
+
+
+@dataclass
+class TokenIntrospectionParams:
+ """Parameters for Token Introspection"""
+
+ token: RefreshToken
+
+ provider: OAuth2Provider = field(init=False)
+ id_token: IDToken = field(init=False)
+
+ def __post_init__(self):
+ if self.token.is_expired:
+ LOGGER.debug("Token is not valid")
+ raise TokenIntrospectionError()
+
+ self.provider = self.token.provider
+ self.id_token = self.token.id_token
+
+ if not self.token.id_token:
+ LOGGER.debug(
+ "token not an authentication token",
+ token=self.token,
+ )
+ raise TokenIntrospectionError()
+
+ def authenticate_basic(self, request: HttpRequest) -> bool:
+ """Attempt to authenticate via Basic auth of client_id:client_secret"""
+ client_id, client_secret = extract_client_auth(request)
+ if client_id == client_secret == "":
+ return False
+ if (
+ client_id != self.provider.client_id
+ or client_secret != self.provider.client_secret
+ ):
+ LOGGER.debug("(basic) Provider for basic auth does not exist")
+ raise TokenIntrospectionError()
+ return True
+
+ def authenticate_bearer(self, request: HttpRequest) -> bool:
+ """Attempt to authenticate via token sent as bearer header"""
+ body_token = extract_access_token(request)
+ if not body_token:
+ return False
+ tokens = RefreshToken.objects.filter(access_token=body_token).select_related(
+ "provider"
+ )
+ if not tokens.exists():
+ LOGGER.debug("(bearer) Token does not exist")
+ raise TokenIntrospectionError()
+ if tokens.first().provider != self.provider:
+ LOGGER.debug("(bearer) Token providers don't match")
+ raise TokenIntrospectionError()
+ return True
+
+ @staticmethod
+ def from_request(request: HttpRequest) -> "TokenIntrospectionParams":
+ """Extract required Parameters from HTTP Request"""
+ raw_token = request.POST.get("token")
+ token_type_hint = request.POST.get("token_type_hint", "access_token")
+ token_filter = {token_type_hint: raw_token}
+
+ if token_type_hint not in ["access_token", "refresh_token"]:
+ LOGGER.debug("token_type_hint has invalid value", value=token_type_hint)
+ raise TokenIntrospectionError()
+
+ try:
+ token: RefreshToken = RefreshToken.objects.select_related("provider").get(
+ **token_filter
+ )
+ except RefreshToken.DoesNotExist:
+ LOGGER.debug("Token does not exist", token=raw_token)
+ raise TokenIntrospectionError()
+
+ params = TokenIntrospectionParams(token=token)
+ if not any(
+ [params.authenticate_basic(request), params.authenticate_bearer(request)]
+ ):
+ LOGGER.debug("Not authenticated")
+ raise TokenIntrospectionError()
+ return params
+
+
+class TokenIntrospectionView(View):
+ """Token Introspection
+ https://tools.ietf.org/html/rfc7662"""
+
+ token: RefreshToken
+ params: TokenIntrospectionParams
+ provider: OAuth2Provider
+ id_token: IDToken
+
+ def post(self, request: HttpRequest) -> HttpResponse:
+ """Introspection handler"""
+ try:
+ self.params = TokenIntrospectionParams.from_request(request)
+
+ response_dic = {}
+ if self.params.id_token:
+ token_dict = self.params.id_token.to_dict()
+ for k in ("aud", "sub", "exp", "iat", "iss"):
+ response_dic[k] = token_dict[k]
+ response_dic["active"] = True
+ response_dic["client_id"] = self.params.token.provider.client_id
+
+ return TokenResponse(response_dic)
+ except TokenIntrospectionError:
+ return TokenResponse({"active": False})
diff --git a/authentik/providers/oauth2/views/jwks.py b/authentik/providers/oauth2/views/jwks.py
new file mode 100644
index 000000000..b87a33227
--- /dev/null
+++ b/authentik/providers/oauth2/views/jwks.py
@@ -0,0 +1,40 @@
+"""authentik OAuth2 JWKS Views"""
+from django.http import HttpRequest, HttpResponse, JsonResponse
+from django.shortcuts import get_object_or_404
+from django.views import View
+from jwkest import long_to_base64
+from jwkest.jwk import import_rsa_key
+
+from authentik.core.models import Application
+from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
+
+
+class JWKSView(View):
+ """Show RSA Key data for Provider"""
+
+ def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
+ """Show RSA Key data for Provider"""
+ application = get_object_or_404(Application, slug=application_slug)
+ provider: OAuth2Provider = get_object_or_404(
+ OAuth2Provider, pk=application.provider_id
+ )
+
+ response_data = {}
+
+ if provider.jwt_alg == JWTAlgorithms.RS256:
+ public_key = import_rsa_key(provider.rsa_key.key_data).publickey()
+ response_data["keys"] = [
+ {
+ "kty": "RSA",
+ "alg": "RS256",
+ "use": "sig",
+ "kid": provider.rsa_key.kid,
+ "n": long_to_base64(public_key.n),
+ "e": long_to_base64(public_key.e),
+ }
+ ]
+
+ response = JsonResponse(response_data)
+ response["Access-Control-Allow-Origin"] = "*"
+
+ return response
diff --git a/authentik/providers/oauth2/views/provider.py b/authentik/providers/oauth2/views/provider.py
new file mode 100644
index 000000000..704f85ed5
--- /dev/null
+++ b/authentik/providers/oauth2/views/provider.py
@@ -0,0 +1,74 @@
+"""authentik OAuth2 OpenID well-known views"""
+from typing import Any, Dict
+
+from django.http import HttpRequest, HttpResponse, JsonResponse
+from django.shortcuts import get_object_or_404, reverse
+from django.views import View
+from structlog import get_logger
+
+from authentik.core.models import Application
+from authentik.providers.oauth2.models import OAuth2Provider
+
+LOGGER = get_logger()
+
+PLAN_CONTEXT_PARAMS = "params"
+PLAN_CONTEXT_SCOPES = "scopes"
+
+
+class ProviderInfoView(View):
+ """OpenID-compliant Provider Info"""
+
+ def get_info(self, provider: OAuth2Provider) -> Dict[str, Any]:
+ """Get dictionary for OpenID Connect information"""
+ return {
+ "issuer": provider.get_issuer(self.request),
+ "authorization_endpoint": self.request.build_absolute_uri(
+ reverse("authentik_providers_oauth2:authorize")
+ ),
+ "token_endpoint": self.request.build_absolute_uri(
+ reverse("authentik_providers_oauth2:token")
+ ),
+ "userinfo_endpoint": self.request.build_absolute_uri(
+ reverse("authentik_providers_oauth2:userinfo")
+ ),
+ "end_session_endpoint": self.request.build_absolute_uri(
+ reverse(
+ "authentik_providers_oauth2:end-session",
+ kwargs={"application_slug": provider.application.slug},
+ )
+ ),
+ "introspection_endpoint": self.request.build_absolute_uri(
+ reverse("authentik_providers_oauth2:token-introspection")
+ ),
+ "response_types_supported": [provider.response_type],
+ "jwks_uri": self.request.build_absolute_uri(
+ reverse(
+ "authentik_providers_oauth2:jwks",
+ kwargs={"application_slug": provider.application.slug},
+ )
+ ),
+ "id_token_signing_alg_values_supported": [provider.jwt_alg],
+ # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
+ "subject_types_supported": ["public"],
+ "token_endpoint_auth_methods_supported": [
+ "client_secret_post",
+ "client_secret_basic",
+ ],
+ }
+
+ # pylint: disable=unused-argument
+ def get(
+ self, request: HttpRequest, application_slug: str, *args, **kwargs
+ ) -> HttpResponse:
+ """OpenID-compliant Provider Info"""
+
+ application = get_object_or_404(Application, slug=application_slug)
+ provider: OAuth2Provider = get_object_or_404(
+ OAuth2Provider, pk=application.provider_id
+ )
+ response = JsonResponse(
+ self.get_info(provider), json_dumps_params={"indent": 2}
+ )
+ response["Access-Control-Allow-Origin"] = "*"
+
+ return response
diff --git a/authentik/providers/oauth2/views/session.py b/authentik/providers/oauth2/views/session.py
new file mode 100644
index 000000000..332a9b87e
--- /dev/null
+++ b/authentik/providers/oauth2/views/session.py
@@ -0,0 +1,22 @@
+"""authentik OAuth2 Session Views"""
+from typing import Any, Dict
+
+from django.shortcuts import get_object_or_404
+from django.views.generic.base import TemplateView
+
+from authentik.core.models import Application
+
+
+class EndSessionView(TemplateView):
+ """Allow the client to end the Session"""
+
+ template_name = "providers/oauth2/end_session.html"
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ context = super().get_context_data(**kwargs)
+
+ context["application"] = get_object_or_404(
+ Application, slug=self.kwargs["application_slug"]
+ )
+
+ return context
diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py
new file mode 100644
index 000000000..a1b9740ff
--- /dev/null
+++ b/authentik/providers/oauth2/views/token.py
@@ -0,0 +1,256 @@
+"""authentik OAuth2 Token views"""
+from base64 import urlsafe_b64encode
+from dataclasses import InitVar, dataclass
+from hashlib import sha256
+from typing import Any, Dict, List, Optional
+
+from django.http import HttpRequest, HttpResponse
+from django.views import View
+from structlog import get_logger
+
+from authentik.lib.utils.time import timedelta_from_string
+from authentik.providers.oauth2.constants import (
+ GRANT_TYPE_AUTHORIZATION_CODE,
+ GRANT_TYPE_REFRESH_TOKEN,
+)
+from authentik.providers.oauth2.errors import TokenError, UserAuthError
+from authentik.providers.oauth2.models import (
+ AuthorizationCode,
+ OAuth2Provider,
+ RefreshToken,
+ ResponseTypes,
+)
+from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth
+
+LOGGER = get_logger()
+
+
+@dataclass
+class TokenParams:
+ """Token params"""
+
+ client_id: str
+ client_secret: str
+ redirect_uri: str
+ grant_type: str
+ state: str
+ scope: List[str]
+
+ authorization_code: Optional[AuthorizationCode] = None
+ refresh_token: Optional[RefreshToken] = None
+
+ code_verifier: Optional[str] = None
+
+ raw_code: InitVar[str] = ""
+ raw_token: InitVar[str] = ""
+
+ @staticmethod
+ def from_request(request: HttpRequest) -> "TokenParams":
+ """Extract Token Parameters from http request"""
+ client_id, client_secret = extract_client_auth(request)
+
+ return TokenParams(
+ client_id=client_id,
+ client_secret=client_secret,
+ redirect_uri=request.POST.get("redirect_uri", ""),
+ grant_type=request.POST.get("grant_type", ""),
+ raw_code=request.POST.get("code", ""),
+ raw_token=request.POST.get("refresh_token", ""),
+ state=request.POST.get("state", ""),
+ scope=request.POST.get("scope", "").split(),
+ # PKCE parameter.
+ code_verifier=request.POST.get("code_verifier"),
+ )
+
+ def __post_init__(self, raw_code, raw_token):
+ try:
+ provider: OAuth2Provider = OAuth2Provider.objects.get(
+ client_id=self.client_id
+ )
+ self.provider = provider
+ except OAuth2Provider.DoesNotExist:
+ LOGGER.warning("OAuth2Provider does not exist", client_id=self.client_id)
+ raise TokenError("invalid_client")
+
+ if self.provider.client_type == "confidential":
+ if self.provider.client_secret != self.client_secret:
+ LOGGER.warning(
+ "Invalid client secret: client does not have secret",
+ client_id=self.provider.client_id,
+ secret=self.provider.client_secret,
+ )
+ raise TokenError("invalid_client")
+
+ if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
+ self.__post_init_code(raw_code)
+
+ elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
+ if not raw_token:
+ LOGGER.warning("Missing refresh token")
+ raise TokenError("invalid_grant")
+
+ try:
+ self.refresh_token = RefreshToken.objects.get(
+ refresh_token=raw_token, provider=self.provider
+ )
+
+ except RefreshToken.DoesNotExist:
+ LOGGER.warning(
+ "Refresh token does not exist",
+ token=raw_token,
+ )
+ raise TokenError("invalid_grant")
+
+ else:
+ LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
+ raise TokenError("unsupported_grant_type")
+
+ def __post_init_code(self, raw_code):
+ if not raw_code:
+ LOGGER.warning("Missing authorization code")
+ raise TokenError("invalid_grant")
+
+ if self.redirect_uri not in self.provider.redirect_uris.split():
+ LOGGER.warning(
+ "Invalid redirect uri",
+ uri=self.redirect_uri,
+ expected=self.provider.redirect_uris.split(),
+ )
+ raise TokenError("invalid_client")
+
+ try:
+ self.authorization_code = AuthorizationCode.objects.get(code=raw_code)
+ except AuthorizationCode.DoesNotExist:
+ LOGGER.warning("Code does not exist", code=raw_code)
+ raise TokenError("invalid_grant")
+
+ if (
+ self.authorization_code.provider != self.provider
+ or self.authorization_code.is_expired
+ ):
+ LOGGER.warning("Invalid code: invalid client or code has expired")
+ raise TokenError("invalid_grant")
+
+ # Validate PKCE parameters.
+ if self.code_verifier:
+ if self.authorization_code.code_challenge_method == "S256":
+ new_code_challenge = (
+ urlsafe_b64encode(
+ sha256(self.code_verifier.encode("ascii")).digest()
+ )
+ .decode("utf-8")
+ .replace("=", "")
+ )
+ else:
+ new_code_challenge = self.code_verifier
+
+ if new_code_challenge != self.authorization_code.code_challenge:
+ LOGGER.warning("Code challenge not matching")
+ raise TokenError("invalid_grant")
+
+
+class TokenView(View):
+ """Generate tokens for clients"""
+
+ params: TokenParams
+
+ def post(self, request: HttpRequest) -> HttpResponse:
+ """Generate tokens for clients"""
+ try:
+ self.params = TokenParams.from_request(request)
+
+ if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
+ return TokenResponse(self.create_code_response_dic())
+ if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
+ return TokenResponse(self.create_refresh_response_dic())
+ raise ValueError(f"Invalid grant_type: {self.params.grant_type}")
+ except TokenError as error:
+ return TokenResponse(error.create_dict(), status=400)
+ except UserAuthError as error:
+ return TokenResponse(error.create_dict(), status=403)
+
+ def create_code_response_dic(self) -> Dict[str, Any]:
+ """See https://tools.ietf.org/html/rfc6749#section-4.1"""
+
+ refresh_token = self.params.authorization_code.provider.create_refresh_token(
+ user=self.params.authorization_code.user,
+ scope=self.params.authorization_code.scope,
+ )
+
+ if self.params.authorization_code.is_open_id:
+ id_token = refresh_token.create_id_token(
+ user=self.params.authorization_code.user,
+ request=self.request,
+ )
+ id_token.nonce = self.params.authorization_code.nonce
+ id_token.at_hash = refresh_token.at_hash
+ refresh_token.id_token = id_token
+
+ # Store the token.
+ refresh_token.save()
+
+ # We don't need to store the code anymore.
+ self.params.authorization_code.delete()
+
+ response_dict = {
+ "access_token": refresh_token.access_token,
+ "refresh_token": refresh_token.refresh_token,
+ "token_type": "Bearer",
+ "expires_in": timedelta_from_string(
+ self.params.provider.token_validity
+ ).seconds,
+ "id_token": refresh_token.provider.encode(refresh_token.id_token.to_dict()),
+ }
+
+ if self.params.provider.response_type == ResponseTypes.CODE_ADFS:
+ # This seems to be expected by some OIDC Clients
+ # namely VMware vCenter. This is not documented in any OpenID or OAuth2 Standard.
+ # Maybe this should be a setting
+ # in the future?
+ response_dict["access_token"] = response_dict["id_token"]
+
+ return response_dict
+
+ def create_refresh_response_dic(self) -> Dict[str, Any]:
+ """See https://tools.ietf.org/html/rfc6749#section-6"""
+
+ unauthorized_scopes = set(self.params.scope) - set(
+ self.params.refresh_token.scope
+ )
+ if unauthorized_scopes:
+ raise TokenError("invalid_scope")
+
+ provider: OAuth2Provider = self.params.refresh_token.provider
+
+ refresh_token: RefreshToken = provider.create_refresh_token(
+ user=self.params.refresh_token.user,
+ scope=self.params.scope,
+ )
+
+ # If the Token has an id_token it's an Authentication request.
+ if self.params.refresh_token.id_token:
+ refresh_token.id_token = refresh_token.create_id_token(
+ user=self.params.refresh_token.user,
+ request=self.request,
+ )
+ refresh_token.id_token.at_hash = refresh_token.at_hash
+
+ # Store the refresh_token.
+ refresh_token.save()
+
+ # Forget the old token.
+ self.params.refresh_token.delete()
+
+ dic = {
+ "access_token": refresh_token.access_token,
+ "refresh_token": refresh_token.refresh_token,
+ "token_type": "bearer",
+ "expires_in": timedelta_from_string(
+ refresh_token.provider.token_validity
+ ).seconds,
+ "id_token": self.params.provider.encode(
+ self.params.refresh_token.id_token.to_dict()
+ ),
+ }
+
+ return dic
diff --git a/authentik/providers/oauth2/views/userinfo.py b/authentik/providers/oauth2/views/userinfo.py
new file mode 100644
index 000000000..d0f0f542f
--- /dev/null
+++ b/authentik/providers/oauth2/views/userinfo.py
@@ -0,0 +1,92 @@
+"""authentik OAuth2 OpenID Userinfo views"""
+from typing import Any, Dict, List
+
+from django.http import HttpRequest, HttpResponse
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from structlog import get_logger
+
+from authentik.providers.oauth2.constants import (
+ SCOPE_GITHUB_ORG_READ,
+ SCOPE_GITHUB_USER,
+ SCOPE_GITHUB_USER_EMAIL,
+ SCOPE_GITHUB_USER_READ,
+)
+from authentik.providers.oauth2.models import RefreshToken, ScopeMapping
+from authentik.providers.oauth2.utils import TokenResponse, cors_allow_any
+
+LOGGER = get_logger()
+
+
+class UserInfoView(View):
+ """Create a dictionary with all the requested claims about the End-User.
+ See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse"""
+
+ def get_scope_descriptions(self, scopes: List[str]) -> Dict[str, str]:
+ """Get a list of all Scopes's descriptions"""
+ scope_descriptions = {}
+ for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by(
+ "scope_name"
+ ):
+ if scope.description != "":
+ scope_descriptions[scope.scope_name] = scope.description
+ # GitHub Compatibility Scopes are handeled differently, since they required custom paths
+ # Hence they don't exist as Scope objects
+ github_scope_map = {
+ SCOPE_GITHUB_USER: _("GitHub Compatibility: Access your User Information"),
+ SCOPE_GITHUB_USER_READ: _(
+ "GitHub Compatibility: Access your User Information"
+ ),
+ SCOPE_GITHUB_USER_EMAIL: _(
+ "GitHub Compatibility: Access you Email addresses"
+ ),
+ SCOPE_GITHUB_ORG_READ: _("GitHub Compatibility: Access your Groups"),
+ }
+ for scope in scopes:
+ if scope in github_scope_map:
+ scope_descriptions[scope] = github_scope_map[scope]
+ return scope_descriptions
+
+ def get_claims(self, token: RefreshToken) -> Dict[str, Any]:
+ """Get a dictionary of claims from scopes that the token
+ requires and are assigned to the provider."""
+
+ scopes_from_client = token.scope
+ final_claims = {}
+ for scope in ScopeMapping.objects.filter(
+ provider=token.provider, scope_name__in=scopes_from_client
+ ).order_by("scope_name"):
+ value = scope.evaluate(
+ user=token.user,
+ request=self.request,
+ provider=token.provider,
+ token=token,
+ )
+ if value is None:
+ continue
+ if not isinstance(value, dict):
+ LOGGER.warning(
+ "Scope returned a non-dict value, ignoring",
+ scope=scope,
+ value=value,
+ )
+ continue
+ LOGGER.debug("updated scope", scope=scope)
+ final_claims.update(value)
+ return final_claims
+
+ def options(self, request: HttpRequest) -> HttpResponse:
+ return cors_allow_any(self.request, TokenResponse({}))
+
+ def get(self, request: HttpRequest, **kwargs) -> HttpResponse:
+ """Handle GET Requests for UserInfo"""
+ token: RefreshToken = kwargs["token"]
+ claims = self.get_claims(token)
+ claims["sub"] = token.id_token.sub
+ response = TokenResponse(claims)
+ cors_allow_any(self.request, response)
+ return response
+
+ def post(self, request: HttpRequest, **kwargs) -> HttpResponse:
+ """POST Requests behave the same as GET Requests, so the get handler is called here"""
+ return self.get(request, **kwargs)
diff --git a/passbook/providers/proxy/__init__.py b/authentik/providers/proxy/__init__.py
similarity index 100%
rename from passbook/providers/proxy/__init__.py
rename to authentik/providers/proxy/__init__.py
diff --git a/authentik/providers/proxy/api.py b/authentik/providers/proxy/api.py
new file mode 100644
index 000000000..51377c0a4
--- /dev/null
+++ b/authentik/providers/proxy/api.py
@@ -0,0 +1,118 @@
+"""ProxyProvider API Views"""
+from drf_yasg2.utils import swagger_serializer_method
+from rest_framework.fields import CharField, ListField, SerializerMethodField
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import ModelSerializer, Serializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.providers.oauth2.views.provider import ProviderInfoView
+from authentik.providers.proxy.models import ProxyProvider
+
+
+class OpenIDConnectConfigurationSerializer(Serializer):
+ """rest_framework Serializer for OIDC Configuration"""
+
+ issuer = CharField()
+ authorization_endpoint = CharField()
+ token_endpoint = CharField()
+ userinfo_endpoint = CharField()
+ end_session_endpoint = CharField()
+ introspection_endpoint = CharField()
+ jwks_uri = CharField()
+
+ response_types_supported = ListField(child=CharField())
+ id_token_signing_alg_values_supported = ListField(child=CharField())
+ subject_types_supported = ListField(child=CharField())
+ token_endpoint_auth_methods_supported = ListField(child=CharField())
+
+ def create(self, request: Request) -> Response:
+ raise NotImplementedError
+
+ def update(self, request: Request) -> Response:
+ raise NotImplementedError
+
+
+class ProxyProviderSerializer(ModelSerializer):
+ """ProxyProvider Serializer"""
+
+ def create(self, validated_data):
+ instance: ProxyProvider = super().create(validated_data)
+ instance.set_oauth_defaults()
+ instance.save()
+ return instance
+
+ def update(self, instance: ProxyProvider, validated_data):
+ instance.set_oauth_defaults()
+ return super().update(instance, validated_data)
+
+ class Meta:
+
+ model = ProxyProvider
+ fields = [
+ "pk",
+ "name",
+ "internal_host",
+ "external_host",
+ "internal_host_ssl_validation",
+ "certificate",
+ "skip_path_regex",
+ "basic_auth_enabled",
+ "basic_auth_password_attribute",
+ "basic_auth_user_attribute",
+ ]
+
+
+class ProxyProviderViewSet(ModelViewSet):
+ """ProxyProvider Viewset"""
+
+ queryset = ProxyProvider.objects.all()
+ serializer_class = ProxyProviderSerializer
+
+
+class ProxyOutpostConfigSerializer(ModelSerializer):
+ """ProxyProvider Serializer"""
+
+ oidc_configuration = SerializerMethodField()
+
+ def create(self, validated_data):
+ instance: ProxyProvider = super().create(validated_data)
+ instance.set_oauth_defaults()
+ instance.save()
+ return instance
+
+ def update(self, instance: ProxyProvider, validated_data):
+ instance.set_oauth_defaults()
+ return super().update(instance, validated_data)
+
+ class Meta:
+
+ model = ProxyProvider
+ fields = [
+ "pk",
+ "name",
+ "internal_host",
+ "external_host",
+ "internal_host_ssl_validation",
+ "client_id",
+ "client_secret",
+ "oidc_configuration",
+ "cookie_secret",
+ "certificate",
+ "skip_path_regex",
+ "basic_auth_enabled",
+ "basic_auth_password_attribute",
+ "basic_auth_user_attribute",
+ ]
+
+ @swagger_serializer_method(serializer_or_field=OpenIDConnectConfigurationSerializer)
+ def get_oidc_configuration(self, obj: ProxyProvider):
+ """Embed OpenID Connect provider information"""
+ return ProviderInfoView(request=self.context["request"]._request).get_info(obj)
+
+
+class ProxyOutpostConfigViewSet(ModelViewSet):
+ """ProxyProvider Viewset"""
+
+ queryset = ProxyProvider.objects.filter(application__isnull=False)
+ serializer_class = ProxyOutpostConfigSerializer
diff --git a/authentik/providers/proxy/apps.py b/authentik/providers/proxy/apps.py
new file mode 100644
index 000000000..ef7d2dd6d
--- /dev/null
+++ b/authentik/providers/proxy/apps.py
@@ -0,0 +1,10 @@
+"""authentik Proxy app"""
+from django.apps import AppConfig
+
+
+class AuthentikProviderProxyConfig(AppConfig):
+ """authentik proxy app"""
+
+ name = "authentik.providers.proxy"
+ label = "authentik_providers_proxy"
+ verbose_name = "authentik Providers.Proxy"
diff --git a/passbook/providers/proxy/controllers/__init__.py b/authentik/providers/proxy/controllers/__init__.py
similarity index 100%
rename from passbook/providers/proxy/controllers/__init__.py
rename to authentik/providers/proxy/controllers/__init__.py
diff --git a/authentik/providers/proxy/controllers/docker.py b/authentik/providers/proxy/controllers/docker.py
new file mode 100644
index 000000000..920c76b2a
--- /dev/null
+++ b/authentik/providers/proxy/controllers/docker.py
@@ -0,0 +1,34 @@
+"""Proxy Provider Docker Contoller"""
+from typing import Dict
+from urllib.parse import urlparse
+
+from authentik.outposts.controllers.docker import DockerController
+from authentik.outposts.models import DockerServiceConnection, Outpost
+from authentik.providers.proxy.models import ProxyProvider
+
+
+class ProxyDockerController(DockerController):
+ """Proxy Provider Docker Contoller"""
+
+ def __init__(self, outpost: Outpost, connection: DockerServiceConnection):
+ super().__init__(outpost, connection)
+ self.deployment_ports = {
+ "http": 4180,
+ "https": 4443,
+ }
+
+ def _get_labels(self) -> Dict[str, str]:
+ hosts = []
+ for proxy_provider in ProxyProvider.objects.filter(outpost__in=[self.outpost]):
+ proxy_provider: ProxyProvider
+ external_host_name = urlparse(proxy_provider.external_host)
+ hosts.append(f"`{external_host_name}`")
+ traefik_name = f"ak-outpost-{self.outpost.pk.hex}"
+ return {
+ "traefik.enable": "true",
+ f"traefik.http.routers.{traefik_name}-router.rule": f"Host({','.join(hosts)})",
+ f"traefik.http.routers.{traefik_name}-router.tls": "true",
+ f"traefik.http.routers.{traefik_name}-router.service": f"{traefik_name}-service",
+ f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path": "/",
+ f"traefik.http.services.{traefik_name}-service.loadbalancer.server.port": "4180",
+ }
diff --git a/passbook/providers/proxy/controllers/k8s/__init__.py b/authentik/providers/proxy/controllers/k8s/__init__.py
similarity index 100%
rename from passbook/providers/proxy/controllers/k8s/__init__.py
rename to authentik/providers/proxy/controllers/k8s/__init__.py
diff --git a/authentik/providers/proxy/controllers/k8s/ingress.py b/authentik/providers/proxy/controllers/k8s/ingress.py
new file mode 100644
index 000000000..ea4de5acc
--- /dev/null
+++ b/authentik/providers/proxy/controllers/k8s/ingress.py
@@ -0,0 +1,140 @@
+"""Kubernetes Ingress Reconciler"""
+from typing import TYPE_CHECKING, Dict
+from urllib.parse import urlparse
+
+from kubernetes.client import (
+ NetworkingV1beta1Api,
+ NetworkingV1beta1HTTPIngressPath,
+ NetworkingV1beta1HTTPIngressRuleValue,
+ NetworkingV1beta1Ingress,
+ NetworkingV1beta1IngressBackend,
+ NetworkingV1beta1IngressSpec,
+ NetworkingV1beta1IngressTLS,
+)
+from kubernetes.client.models.networking_v1beta1_ingress_rule import (
+ NetworkingV1beta1IngressRule,
+)
+
+from authentik.outposts.controllers.k8s.base import (
+ KubernetesObjectReconciler,
+ NeedsUpdate,
+)
+from authentik.providers.proxy.models import ProxyProvider
+
+if TYPE_CHECKING:
+ from authentik.outposts.controllers.kubernetes import KubernetesController
+
+
+class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]):
+ """Kubernetes Ingress Reconciler"""
+
+ def __init__(self, controller: "KubernetesController") -> None:
+ super().__init__(controller)
+ self.api = NetworkingV1beta1Api(controller.client)
+
+ @property
+ def name(self) -> str:
+ return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
+
+ def reconcile(
+ self, current: NetworkingV1beta1Ingress, reference: NetworkingV1beta1Ingress
+ ):
+ # Create a list of all expected host and tls hosts
+ expected_hosts = []
+ expected_hosts_tls = []
+ for proxy_provider in ProxyProvider.objects.filter(
+ outpost__in=[self.controller.outpost]
+ ):
+ proxy_provider: ProxyProvider
+ external_host_name = urlparse(proxy_provider.external_host)
+ expected_hosts.append(external_host_name.hostname)
+ if external_host_name.scheme == "https":
+ expected_hosts_tls.append(external_host_name.hostname)
+ expected_hosts.sort()
+ expected_hosts_tls.sort()
+
+ have_hosts = [rule.host for rule in reference.spec.rules]
+ have_hosts.sort()
+
+ have_hosts_tls = []
+ for tls_config in reference.spec.tls:
+ if tls_config:
+ have_hosts_tls += tls_config.hosts
+ have_hosts_tls.sort()
+
+ if have_hosts != expected_hosts:
+ raise NeedsUpdate()
+ if have_hosts_tls != expected_hosts_tls:
+ raise NeedsUpdate()
+
+ def get_ingress_annotations(self) -> Dict[str, str]:
+ """Get ingress annotations"""
+ annotations = {
+ # Ensure that with multiple proxy replicas deployed, the same CSRF request
+ # goes to the same pod
+ "nginx.ingress.kubernetes.io/affinity": "cookie",
+ "traefik.ingress.kubernetes.io/affinity": "true",
+ }
+ annotations.update(
+ self.controller.outpost.config.kubernetes_ingress_annotations
+ )
+ return dict()
+
+ def get_reference_object(self) -> NetworkingV1beta1Ingress:
+ """Get deployment object for outpost"""
+ meta = self.get_object_meta(
+ name=self.name,
+ annotations=self.get_ingress_annotations(),
+ )
+ rules = []
+ tls_hosts = []
+ for proxy_provider in ProxyProvider.objects.filter(
+ outpost__in=[self.controller.outpost]
+ ):
+ proxy_provider: ProxyProvider
+ external_host_name = urlparse(proxy_provider.external_host)
+ if external_host_name.scheme == "https":
+ tls_hosts.append(external_host_name.hostname)
+ rule = NetworkingV1beta1IngressRule(
+ host=external_host_name.hostname,
+ http=NetworkingV1beta1HTTPIngressRuleValue(
+ paths=[
+ NetworkingV1beta1HTTPIngressPath(
+ backend=NetworkingV1beta1IngressBackend(
+ service_name=self.name,
+ service_port=self.controller.deployment_ports["http"],
+ ),
+ path="/",
+ )
+ ]
+ ),
+ )
+ rules.append(rule)
+ tls_config = None
+ if tls_hosts:
+ tls_config = NetworkingV1beta1IngressTLS(
+ hosts=tls_hosts,
+ secret_name=self.controller.outpost.config.kubernetes_ingress_secret_name,
+ )
+ return NetworkingV1beta1Ingress(
+ metadata=meta,
+ spec=NetworkingV1beta1IngressSpec(rules=rules, tls=[tls_config]),
+ )
+
+ def create(self, reference: NetworkingV1beta1Ingress):
+ return self.api.create_namespaced_ingress(self.namespace, reference)
+
+ def delete(self, reference: NetworkingV1beta1Ingress):
+ return self.api.delete_namespaced_ingress(
+ reference.metadata.name, self.namespace
+ )
+
+ def retrieve(self) -> NetworkingV1beta1Ingress:
+ return self.api.read_namespaced_ingress(self.name, self.namespace)
+
+ def update(
+ self, current: NetworkingV1beta1Ingress, reference: NetworkingV1beta1Ingress
+ ):
+ return self.api.patch_namespaced_ingress(
+ current.metadata.name, self.namespace, reference
+ )
diff --git a/authentik/providers/proxy/controllers/kubernetes.py b/authentik/providers/proxy/controllers/kubernetes.py
new file mode 100644
index 000000000..9cee34ae8
--- /dev/null
+++ b/authentik/providers/proxy/controllers/kubernetes.py
@@ -0,0 +1,17 @@
+"""Proxy Provider Kubernetes Contoller"""
+from authentik.outposts.controllers.kubernetes import KubernetesController
+from authentik.outposts.models import KubernetesServiceConnection, Outpost
+from authentik.providers.proxy.controllers.k8s.ingress import IngressReconciler
+
+
+class ProxyKubernetesController(KubernetesController):
+ """Proxy Provider Kubernetes Contoller"""
+
+ def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection):
+ super().__init__(outpost, connection)
+ self.deployment_ports = {
+ "http": 4180,
+ "https": 4443,
+ }
+ self.reconcilers["ingress"] = IngressReconciler
+ self.reconcile_order.append("ingress")
diff --git a/authentik/providers/proxy/forms.py b/authentik/providers/proxy/forms.py
new file mode 100644
index 000000000..a83715105
--- /dev/null
+++ b/authentik/providers/proxy/forms.py
@@ -0,0 +1,50 @@
+"""authentik Proxy Provider Forms"""
+from django import forms
+
+from authentik.crypto.models import CertificateKeyPair
+from authentik.flows.models import Flow, FlowDesignation
+from authentik.providers.proxy.models import ProxyProvider
+
+
+class ProxyProviderForm(forms.ModelForm):
+ """Security Gateway Provider form"""
+
+ instance: ProxyProvider
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["authorization_flow"].queryset = Flow.objects.filter(
+ designation=FlowDesignation.AUTHORIZATION
+ )
+ self.fields["certificate"].queryset = CertificateKeyPair.objects.filter(
+ key_data__isnull=False
+ )
+
+ def save(self, *args, **kwargs):
+ actual_save = super().save(*args, **kwargs)
+ self.instance.set_oauth_defaults()
+ self.instance.save()
+ return actual_save
+
+ class Meta:
+
+ model = ProxyProvider
+ fields = [
+ "name",
+ "authorization_flow",
+ "internal_host",
+ "internal_host_ssl_validation",
+ "external_host",
+ "certificate",
+ "skip_path_regex",
+ "basic_auth_enabled",
+ "basic_auth_user_attribute",
+ "basic_auth_password_attribute",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "internal_host": forms.TextInput(),
+ "external_host": forms.TextInput(),
+ "basic_auth_user_attribute": forms.TextInput(),
+ "basic_auth_password_attribute": forms.TextInput(),
+ }
diff --git a/authentik/providers/proxy/migrations/0001_initial.py b/authentik/providers/proxy/migrations/0001_initial.py
new file mode 100644
index 000000000..873690d26
--- /dev/null
+++ b/authentik/providers/proxy/migrations/0001_initial.py
@@ -0,0 +1,58 @@
+# Generated by Django 3.1 on 2020-08-18 18:16
+
+import django.core.validators
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_providers_oauth2", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ProxyProvider",
+ fields=[
+ (
+ "oauth2provider_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_providers_oauth2.oauth2provider",
+ ),
+ ),
+ (
+ "internal_host",
+ models.TextField(
+ validators=[
+ django.core.validators.URLValidator(
+ schemes=("http", "https")
+ )
+ ]
+ ),
+ ),
+ (
+ "external_host",
+ models.TextField(
+ validators=[
+ django.core.validators.URLValidator(
+ schemes=("http", "https")
+ )
+ ]
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Proxy Provider",
+ "verbose_name_plural": "Proxy Providers",
+ },
+ bases=("authentik_providers_oauth2.oauth2provider",),
+ ),
+ ]
diff --git a/authentik/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py b/authentik/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py
new file mode 100644
index 000000000..bcf25c252
--- /dev/null
+++ b/authentik/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.1 on 2020-08-19 14:50
+
+from django.db import migrations, models
+
+import authentik.providers.proxy.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_proxy", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="proxyprovider",
+ name="cookie_secret",
+ field=models.TextField(
+ default=authentik.providers.proxy.models.get_cookie_secret
+ ),
+ ),
+ ]
diff --git a/authentik/providers/proxy/migrations/0003_proxyprovider_certificate.py b/authentik/providers/proxy/migrations/0003_proxyprovider_certificate.py
new file mode 100644
index 000000000..cbdbb8620
--- /dev/null
+++ b/authentik/providers/proxy/migrations/0003_proxyprovider_certificate.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1 on 2020-08-23 22:46
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_crypto", "0002_create_self_signed_kp"),
+ ("authentik_providers_proxy", "0002_proxyprovider_cookie_secret"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="proxyprovider",
+ name="certificate",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_crypto.certificatekeypair",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/proxy/migrations/0004_auto_20200913_1947.py b/authentik/providers/proxy/migrations/0004_auto_20200913_1947.py
new file mode 100644
index 000000000..34426eaf8
--- /dev/null
+++ b/authentik/providers/proxy/migrations/0004_auto_20200913_1947.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.1.1 on 2020-09-13 19:47
+
+from django.db import migrations, models
+
+import authentik.lib.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_proxy", "0003_proxyprovider_certificate"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="proxyprovider",
+ name="external_host",
+ field=models.TextField(
+ validators=[
+ authentik.lib.models.DomainlessURLValidator(
+ schemes=("http", "https")
+ )
+ ]
+ ),
+ ),
+ migrations.AlterField(
+ model_name="proxyprovider",
+ name="internal_host",
+ field=models.TextField(
+ validators=[
+ authentik.lib.models.DomainlessURLValidator(
+ schemes=("http", "https")
+ )
+ ]
+ ),
+ ),
+ ]
diff --git a/authentik/providers/proxy/migrations/0005_auto_20200914_1536.py b/authentik/providers/proxy/migrations/0005_auto_20200914_1536.py
new file mode 100644
index 000000000..0e8ec244d
--- /dev/null
+++ b/authentik/providers/proxy/migrations/0005_auto_20200914_1536.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.1.1 on 2020-09-14 15:36
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_crypto", "0002_create_self_signed_kp"),
+ ("authentik_providers_proxy", "0004_auto_20200913_1947"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="proxyprovider",
+ name="certificate",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_crypto.certificatekeypair",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py b/authentik/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py
new file mode 100644
index 000000000..de7bd7d5b
--- /dev/null
+++ b/authentik/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.1.1 on 2020-09-19 09:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_proxy", "0005_auto_20200914_1536"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="proxyprovider",
+ name="skip_path_regex",
+ field=models.TextField(
+ blank=True,
+ default="",
+ help_text="Regular expression for which authentication is not required. Each new line is interpreted as a new Regular Expression.",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/proxy/migrations/0007_auto_20200923_1017.py b/authentik/providers/proxy/migrations/0007_auto_20200923_1017.py
new file mode 100644
index 000000000..722bb87ed
--- /dev/null
+++ b/authentik/providers/proxy/migrations/0007_auto_20200923_1017.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.1.1 on 2020-09-23 10:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_proxy", "0006_proxyprovider_skip_path_regex"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="proxyprovider",
+ name="internal_host_ssl_validation",
+ field=models.BooleanField(
+ default=True, help_text="Validate SSL Certificates of upstream servers"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="proxyprovider",
+ name="skip_path_regex",
+ field=models.TextField(
+ blank=True,
+ default="",
+ help_text="Regular expressions for which authentication is not required. Each new line is interpreted as a new Regular Expression.",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/proxy/migrations/0008_auto_20200930_0810.py b/authentik/providers/proxy/migrations/0008_auto_20200930_0810.py
new file mode 100644
index 000000000..444ba2c79
--- /dev/null
+++ b/authentik/providers/proxy/migrations/0008_auto_20200930_0810.py
@@ -0,0 +1,78 @@
+# Generated by Django 3.1.1 on 2020-09-30 08:10
+
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+SCOPE_AK_PROXY_EXPRESSION = """return {
+ "ak_proxy": {
+ "user_attributes": user.group_attributes()
+ }
+}"""
+
+
+def create_proxy_scope(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ from authentik.providers.proxy.models import SCOPE_AK_PROXY, ProxyProvider
+
+ ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
+
+ ScopeMapping.objects.update_or_create(
+ scope_name=SCOPE_AK_PROXY,
+ defaults={
+ "name": "Autogenerated OAuth2 Mapping: authentik Proxy",
+ "scope_name": SCOPE_AK_PROXY,
+ "description": "",
+ "expression": SCOPE_AK_PROXY_EXPRESSION,
+ },
+ )
+
+ for provider in ProxyProvider.objects.all():
+ provider.set_oauth_defaults()
+ provider.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_proxy", "0007_auto_20200923_1017"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="proxyprovider",
+ name="internal_host_ssl_validation",
+ field=models.BooleanField(
+ default=True,
+ help_text="Validate SSL Certificates of upstream servers",
+ verbose_name="Internal host SSL Validation",
+ ),
+ ),
+ migrations.AddField(
+ model_name="proxyprovider",
+ name="basic_auth_enabled",
+ field=models.BooleanField(
+ default=False,
+ help_text="Set a custom HTTP-Basic Authentication header based on values from authentik.",
+ verbose_name="Set HTTP-Basic Authentication",
+ ),
+ ),
+ migrations.AddField(
+ model_name="proxyprovider",
+ name="basic_auth_password_attribute",
+ field=models.TextField(
+ blank=True,
+ help_text="User Attribute used for the password part of the HTTP-Basic Header.",
+ verbose_name="HTTP-Basic Password",
+ ),
+ ),
+ migrations.AddField(
+ model_name="proxyprovider",
+ name="basic_auth_user_attribute",
+ field=models.TextField(
+ blank=True,
+ help_text="User Attribute used for the user part of the HTTP-Basic Header. If not set, the user's Email address is used.",
+ verbose_name="HTTP-Basic Username",
+ ),
+ ),
+ migrations.RunPython(create_proxy_scope),
+ ]
diff --git a/authentik/providers/proxy/migrations/0009_auto_20201007_1721.py b/authentik/providers/proxy/migrations/0009_auto_20201007_1721.py
new file mode 100644
index 000000000..94c6e2a3b
--- /dev/null
+++ b/authentik/providers/proxy/migrations/0009_auto_20201007_1721.py
@@ -0,0 +1,31 @@
+# Generated by Django 3.1.2 on 2020-10-07 17:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_proxy", "0008_auto_20200930_0810"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="proxyprovider",
+ name="basic_auth_password_attribute",
+ field=models.TextField(
+ blank=True,
+ help_text="User/Group Attribute used for the password part of the HTTP-Basic Header.",
+ verbose_name="HTTP-Basic Password Key",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="proxyprovider",
+ name="basic_auth_user_attribute",
+ field=models.TextField(
+ blank=True,
+ help_text="User/Group Attribute used for the user part of the HTTP-Basic Header. If not set, the user's Email address is used.",
+ verbose_name="HTTP-Basic Username Key",
+ ),
+ ),
+ ]
diff --git a/passbook/providers/proxy/migrations/__init__.py b/authentik/providers/proxy/migrations/__init__.py
similarity index 100%
rename from passbook/providers/proxy/migrations/__init__.py
rename to authentik/providers/proxy/migrations/__init__.py
diff --git a/authentik/providers/proxy/models.py b/authentik/providers/proxy/models.py
new file mode 100644
index 000000000..badd62261
--- /dev/null
+++ b/authentik/providers/proxy/models.py
@@ -0,0 +1,154 @@
+"""authentik proxy models"""
+import string
+from random import SystemRandom
+from typing import Iterable, Optional, Type
+from urllib.parse import urljoin
+
+from django.db import models
+from django.forms import ModelForm
+from django.http import HttpRequest
+from django.utils.translation import gettext as _
+
+from authentik.crypto.models import CertificateKeyPair
+from authentik.lib.models import DomainlessURLValidator
+from authentik.outposts.models import OutpostModel
+from authentik.providers.oauth2.constants import (
+ SCOPE_OPENID,
+ SCOPE_OPENID_EMAIL,
+ SCOPE_OPENID_PROFILE,
+)
+from authentik.providers.oauth2.models import (
+ ClientTypes,
+ JWTAlgorithms,
+ OAuth2Provider,
+ ResponseTypes,
+ ScopeMapping,
+)
+
+SCOPE_AK_PROXY = "ak_proxy"
+
+
+def get_cookie_secret():
+ """Generate random 32-character string for cookie-secret"""
+ return "".join(
+ SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32)
+ )
+
+
+def _get_callback_url(uri: str) -> str:
+ return urljoin(uri, "/akprox/callback")
+
+
+class ProxyProvider(OutpostModel, OAuth2Provider):
+ """Protect applications that don't support any of the other
+ Protocols by using a Reverse-Proxy."""
+
+ internal_host = models.TextField(
+ validators=[DomainlessURLValidator(schemes=("http", "https"))]
+ )
+ external_host = models.TextField(
+ validators=[DomainlessURLValidator(schemes=("http", "https"))]
+ )
+ internal_host_ssl_validation = models.BooleanField(
+ default=True,
+ help_text=_("Validate SSL Certificates of upstream servers"),
+ verbose_name=_("Internal host SSL Validation"),
+ )
+
+ skip_path_regex = models.TextField(
+ default="",
+ blank=True,
+ help_text=_(
+ (
+ "Regular expressions for which authentication is not required. "
+ "Each new line is interpreted as a new Regular Expression."
+ )
+ ),
+ )
+
+ basic_auth_enabled = models.BooleanField(
+ default=False,
+ verbose_name=_("Set HTTP-Basic Authentication"),
+ help_text=_(
+ "Set a custom HTTP-Basic Authentication header based on values from authentik."
+ ),
+ )
+ basic_auth_user_attribute = models.TextField(
+ blank=True,
+ verbose_name=_("HTTP-Basic Username Key"),
+ help_text=_(
+ (
+ "User/Group Attribute used for the user part of the HTTP-Basic Header. "
+ "If not set, the user's Email address is used."
+ )
+ ),
+ )
+ basic_auth_password_attribute = models.TextField(
+ blank=True,
+ verbose_name=_("HTTP-Basic Password Key"),
+ help_text=_(
+ (
+ "User/Group Attribute used for the password part of the HTTP-Basic Header."
+ )
+ ),
+ )
+
+ certificate = models.ForeignKey(
+ CertificateKeyPair,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ )
+
+ cookie_secret = models.TextField(default=get_cookie_secret)
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.providers.proxy.forms import ProxyProviderForm
+
+ return ProxyProviderForm
+
+ @property
+ def launch_url(self) -> Optional[str]:
+ """Use external_host as launch URL"""
+ return self.external_host
+
+ def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
+ """Overwrite Setup URLs as they are not needed for proxy"""
+ return None
+
+ def set_oauth_defaults(self):
+ """Ensure all OAuth2-related settings are correct"""
+ self.client_type = ClientTypes.CONFIDENTIAL
+ self.response_type = ResponseTypes.CODE
+ self.jwt_alg = JWTAlgorithms.RS256
+ self.rsa_key = CertificateKeyPair.objects.first()
+ scopes = ScopeMapping.objects.filter(
+ scope_name__in=[
+ SCOPE_OPENID,
+ SCOPE_OPENID_PROFILE,
+ SCOPE_OPENID_EMAIL,
+ SCOPE_AK_PROXY,
+ ]
+ )
+ self.property_mappings.set(scopes)
+ self.redirect_uris = "\n".join(
+ [
+ _get_callback_url(self.external_host),
+ _get_callback_url(self.internal_host),
+ ]
+ )
+
+ def __str__(self):
+ return f"Proxy Provider {self.name}"
+
+ def get_required_objects(self) -> Iterable[models.Model]:
+ required_models = [self]
+ if self.certificate is not None:
+ required_models.append(self.certificate)
+ return required_models
+
+ class Meta:
+
+ verbose_name = _("Proxy Provider")
+ verbose_name_plural = _("Proxy Providers")
diff --git a/passbook/providers/proxy/provider/__init__.py b/authentik/providers/proxy/provider/__init__.py
similarity index 100%
rename from passbook/providers/proxy/provider/__init__.py
rename to authentik/providers/proxy/provider/__init__.py
diff --git a/passbook/providers/proxy/provider/kubernetes/__init__.py b/authentik/providers/proxy/provider/kubernetes/__init__.py
similarity index 100%
rename from passbook/providers/proxy/provider/kubernetes/__init__.py
rename to authentik/providers/proxy/provider/kubernetes/__init__.py
diff --git a/passbook/providers/saml/__init__.py b/authentik/providers/saml/__init__.py
similarity index 100%
rename from passbook/providers/saml/__init__.py
rename to authentik/providers/saml/__init__.py
diff --git a/authentik/providers/saml/api.py b/authentik/providers/saml/api.py
new file mode 100644
index 000000000..bf0f5501d
--- /dev/null
+++ b/authentik/providers/saml/api.py
@@ -0,0 +1,51 @@
+"""SAMLProvider API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
+
+
+class SAMLProviderSerializer(ModelSerializer):
+ """SAMLProvider Serializer"""
+
+ class Meta:
+
+ model = SAMLProvider
+ fields = [
+ "pk",
+ "name",
+ "acs_url",
+ "audience",
+ "issuer",
+ "assertion_valid_not_before",
+ "assertion_valid_not_on_or_after",
+ "session_valid_not_on_or_after",
+ "property_mappings",
+ "digest_algorithm",
+ "signature_algorithm",
+ "signing_kp",
+ "verification_kp",
+ ]
+
+
+class SAMLProviderViewSet(ModelViewSet):
+ """SAMLProvider Viewset"""
+
+ queryset = SAMLProvider.objects.all()
+ serializer_class = SAMLProviderSerializer
+
+
+class SAMLPropertyMappingSerializer(ModelSerializer):
+ """SAMLPropertyMapping Serializer"""
+
+ class Meta:
+
+ model = SAMLPropertyMapping
+ fields = ["pk", "name", "saml_name", "friendly_name", "expression"]
+
+
+class SAMLPropertyMappingViewSet(ModelViewSet):
+ """SAMLPropertyMapping Viewset"""
+
+ queryset = SAMLPropertyMapping.objects.all()
+ serializer_class = SAMLPropertyMappingSerializer
diff --git a/authentik/providers/saml/apps.py b/authentik/providers/saml/apps.py
new file mode 100644
index 000000000..1d6d9c5ed
--- /dev/null
+++ b/authentik/providers/saml/apps.py
@@ -0,0 +1,12 @@
+"""authentik SAML IdP app config"""
+
+from django.apps import AppConfig
+
+
+class AuthentikProviderSAMLConfig(AppConfig):
+ """authentik SAML IdP app config"""
+
+ name = "authentik.providers.saml"
+ label = "authentik_providers_saml"
+ verbose_name = "authentik Providers.SAML"
+ mountpoint = "application/saml/"
diff --git a/authentik/providers/saml/exceptions.py b/authentik/providers/saml/exceptions.py
new file mode 100644
index 000000000..b32453c6c
--- /dev/null
+++ b/authentik/providers/saml/exceptions.py
@@ -0,0 +1,6 @@
+"""authentik SAML IDP Exceptions"""
+from authentik.lib.sentry import SentryIgnoredException
+
+
+class CannotHandleAssertion(SentryIgnoredException):
+ """This processor does not handle this assertion."""
diff --git a/authentik/providers/saml/forms.py b/authentik/providers/saml/forms.py
new file mode 100644
index 000000000..c676355d0
--- /dev/null
+++ b/authentik/providers/saml/forms.py
@@ -0,0 +1,85 @@
+"""authentik SAML IDP Forms"""
+
+from django import forms
+from django.utils.html import mark_safe
+from django.utils.translation import gettext as _
+
+from authentik.admin.fields import CodeMirrorWidget
+from authentik.core.expression import PropertyMappingEvaluator
+from authentik.crypto.models import CertificateKeyPair
+from authentik.flows.models import Flow, FlowDesignation
+from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
+
+
+class SAMLProviderForm(forms.ModelForm):
+ """SAML Provider form"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["authorization_flow"].queryset = Flow.objects.filter(
+ designation=FlowDesignation.AUTHORIZATION
+ )
+ self.fields["property_mappings"].queryset = SAMLPropertyMapping.objects.all()
+ self.fields["signing_kp"].queryset = CertificateKeyPair.objects.exclude(
+ key_data__iexact=""
+ )
+
+ class Meta:
+
+ model = SAMLProvider
+ fields = [
+ "name",
+ "authorization_flow",
+ "acs_url",
+ "audience",
+ "issuer",
+ "sp_binding",
+ "assertion_valid_not_before",
+ "assertion_valid_not_on_or_after",
+ "session_valid_not_on_or_after",
+ "digest_algorithm",
+ "signature_algorithm",
+ "signing_kp",
+ "verification_kp",
+ "property_mappings",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "audience": forms.TextInput(),
+ "issuer": forms.TextInput(),
+ "assertion_valid_not_before": forms.TextInput(),
+ "assertion_valid_not_on_or_after": forms.TextInput(),
+ "session_valid_not_on_or_after": forms.TextInput(),
+ }
+
+
+class SAMLPropertyMappingForm(forms.ModelForm):
+ """SAML Property Mapping form"""
+
+ template_name = "providers/saml/property_mapping_form.html"
+
+ def clean_expression(self):
+ """Test Syntax"""
+ expression = self.cleaned_data.get("expression")
+ evaluator = PropertyMappingEvaluator()
+ evaluator.validate(expression)
+ return expression
+
+ class Meta:
+
+ model = SAMLPropertyMapping
+ fields = ["name", "saml_name", "friendly_name", "expression"]
+ widgets = {
+ "name": forms.TextInput(),
+ "saml_name": forms.TextInput(),
+ "friendly_name": forms.TextInput(),
+ "expression": CodeMirrorWidget(mode="python"),
+ }
+ help_texts = {
+ "saml_name": mark_safe(
+ _(
+ "URN OID used by SAML. This is optional. "
+ 'Reference '
+ )
+ ),
+ }
diff --git a/authentik/providers/saml/migrations/0001_initial.py b/authentik/providers/saml/migrations/0001_initial.py
new file mode 100644
index 000000000..c877030fe
--- /dev/null
+++ b/authentik/providers/saml/migrations/0001_initial.py
@@ -0,0 +1,140 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import authentik.lib.utils.time
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_crypto", "0001_initial"),
+ ("authentik_core", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="SAMLPropertyMapping",
+ fields=[
+ (
+ "propertymapping_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_core.PropertyMapping",
+ ),
+ ),
+ ("saml_name", models.TextField(verbose_name="SAML Name")),
+ (
+ "friendly_name",
+ models.TextField(blank=True, default=None, null=True),
+ ),
+ ],
+ options={
+ "verbose_name": "SAML Property Mapping",
+ "verbose_name_plural": "SAML Property Mappings",
+ },
+ bases=("authentik_core.propertymapping",),
+ ),
+ migrations.CreateModel(
+ name="SAMLProvider",
+ fields=[
+ (
+ "provider_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_core.Provider",
+ ),
+ ),
+ ("name", models.TextField()),
+ ("processor_path", models.CharField(choices=[], max_length=255)),
+ ("acs_url", models.URLField(verbose_name="ACS URL")),
+ ("audience", models.TextField(default="")),
+ ("issuer", models.TextField(help_text="Also known as EntityID")),
+ (
+ "assertion_valid_not_before",
+ models.TextField(
+ default="minutes=-5",
+ help_text="Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).",
+ validators=[
+ authentik.lib.utils.time.timedelta_string_validator
+ ],
+ ),
+ ),
+ (
+ "assertion_valid_not_on_or_after",
+ models.TextField(
+ default="minutes=5",
+ help_text="Assertion not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
+ validators=[
+ authentik.lib.utils.time.timedelta_string_validator
+ ],
+ ),
+ ),
+ (
+ "session_valid_not_on_or_after",
+ models.TextField(
+ default="minutes=86400",
+ help_text="Session not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
+ validators=[
+ authentik.lib.utils.time.timedelta_string_validator
+ ],
+ ),
+ ),
+ (
+ "digest_algorithm",
+ models.CharField(
+ choices=[("sha1", "SHA1"), ("sha256", "SHA256")],
+ default="sha256",
+ max_length=50,
+ ),
+ ),
+ (
+ "signature_algorithm",
+ models.CharField(
+ choices=[
+ ("rsa-sha1", "RSA-SHA1"),
+ ("rsa-sha256", "RSA-SHA256"),
+ ("ecdsa-sha256", "ECDSA-SHA256"),
+ ("dsa-sha1", "DSA-SHA1"),
+ ],
+ default="rsa-sha256",
+ max_length=50,
+ ),
+ ),
+ (
+ "require_signing",
+ models.BooleanField(
+ default=False,
+ help_text="Require Requests to be signed by an X509 Certificate. Must match the Certificate selected in `Singing Keypair`.",
+ ),
+ ),
+ (
+ "signing_kp",
+ models.ForeignKey(
+ default=None,
+ help_text="Singing is enabled upon selection of a Key Pair.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_crypto.CertificateKeyPair",
+ verbose_name="Signing Keypair",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "SAML Provider",
+ "verbose_name_plural": "SAML Providers",
+ },
+ bases=("authentik_core.provider",),
+ ),
+ ]
diff --git a/authentik/providers/saml/migrations/0002_default_saml_property_mappings.py b/authentik/providers/saml/migrations/0002_default_saml_property_mappings.py
new file mode 100644
index 000000000..0caf8d1c7
--- /dev/null
+++ b/authentik/providers/saml/migrations/0002_default_saml_property_mappings.py
@@ -0,0 +1,63 @@
+# Generated by Django 3.0.6 on 2020-05-23 19:32
+
+from django.db import migrations
+
+
+def create_default_property_mappings(apps, schema_editor):
+ """Create default SAML Property Mappings"""
+ SAMLPropertyMapping = apps.get_model(
+ "authentik_providers_saml", "SAMLPropertyMapping"
+ )
+ db_alias = schema_editor.connection.alias
+ defaults = [
+ {
+ "FriendlyName": "eduPersonPrincipalName",
+ "Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
+ "Expression": "return user.email",
+ },
+ {
+ "FriendlyName": "cn",
+ "Name": "urn:oid:2.5.4.3",
+ "Expression": "return user.name",
+ },
+ {
+ "FriendlyName": "mail",
+ "Name": "urn:oid:0.9.2342.19200300.100.1.3",
+ "Expression": "return user.email",
+ },
+ {
+ "FriendlyName": "displayName",
+ "Name": "urn:oid:2.16.840.1.113730.3.1.241",
+ "Expression": "return user.username",
+ },
+ {
+ "FriendlyName": "uid",
+ "Name": "urn:oid:0.9.2342.19200300.100.1.1",
+ "Expression": "return user.pk",
+ },
+ {
+ "FriendlyName": "member-of",
+ "Name": "member-of",
+ "Expression": "for group in user.groups.all():\n yield group.name",
+ },
+ ]
+ for default in defaults:
+ SAMLPropertyMapping.objects.using(db_alias).get_or_create(
+ saml_name=default["Name"],
+ friendly_name=default["FriendlyName"],
+ expression=default["Expression"],
+ defaults={
+ "name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}"
+ },
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_saml", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_default_property_mappings),
+ ]
diff --git a/authentik/providers/saml/migrations/0003_samlprovider_sp_binding.py b/authentik/providers/saml/migrations/0003_samlprovider_sp_binding.py
new file mode 100644
index 000000000..9119f1cc8
--- /dev/null
+++ b/authentik/providers/saml/migrations/0003_samlprovider_sp_binding.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.0.6 on 2020-06-06 13:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_saml", "0002_default_saml_property_mappings"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="samlprovider",
+ name="sp_binding",
+ field=models.TextField(
+ choices=[("redirect", "Redirect"), ("post", "Post")], default="redirect"
+ ),
+ ),
+ ]
diff --git a/authentik/providers/saml/migrations/0004_auto_20200620_1950.py b/authentik/providers/saml/migrations/0004_auto_20200620_1950.py
new file mode 100644
index 000000000..723d16cb7
--- /dev/null
+++ b/authentik/providers/saml/migrations/0004_auto_20200620_1950.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.0.7 on 2020-06-20 19:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_saml", "0003_samlprovider_sp_binding"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="samlprovider",
+ name="sp_binding",
+ field=models.TextField(
+ choices=[("redirect", "Redirect"), ("post", "Post")],
+ default="redirect",
+ verbose_name="Service Prodier Binding",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/saml/migrations/0005_remove_samlprovider_processor_path.py b/authentik/providers/saml/migrations/0005_remove_samlprovider_processor_path.py
new file mode 100644
index 000000000..db5a834ab
--- /dev/null
+++ b/authentik/providers/saml/migrations/0005_remove_samlprovider_processor_path.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.0.8 on 2020-07-11 00:02
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_saml", "0004_auto_20200620_1950"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="samlprovider",
+ name="processor_path",
+ ),
+ ]
diff --git a/authentik/providers/saml/migrations/0006_remove_samlprovider_name.py b/authentik/providers/saml/migrations/0006_remove_samlprovider_name.py
new file mode 100644
index 000000000..d68e1b059
--- /dev/null
+++ b/authentik/providers/saml/migrations/0006_remove_samlprovider_name.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.1.2 on 2020-10-03 17:37
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def update_name_temp(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ SAMLProvider = apps.get_model("authentik_providers_saml", "SAMLProvider")
+ db_alias = schema_editor.connection.alias
+
+ for provider in SAMLProvider.objects.using(db_alias).all():
+ provider.name_temp = provider.name
+ provider.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0011_provider_name_temp"),
+ ("authentik_providers_saml", "0005_remove_samlprovider_processor_path"),
+ ]
+
+ operations = [
+ migrations.RunPython(update_name_temp),
+ migrations.RemoveField(
+ model_name="samlprovider",
+ name="name",
+ ),
+ ]
diff --git a/authentik/providers/saml/migrations/0007_samlprovider_verification_kp.py b/authentik/providers/saml/migrations/0007_samlprovider_verification_kp.py
new file mode 100644
index 000000000..016b4bc07
--- /dev/null
+++ b/authentik/providers/saml/migrations/0007_samlprovider_verification_kp.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.1.3 on 2020-11-08 21:22
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_crypto", "0002_create_self_signed_kp"),
+ ("authentik_providers_saml", "0006_remove_samlprovider_name"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="samlprovider",
+ name="verification_kp",
+ field=models.ForeignKey(
+ default=None,
+ help_text="If selected, incoming assertion's Signatures will be validated.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="+",
+ to="authentik_crypto.certificatekeypair",
+ verbose_name="Verification Keypair",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/saml/migrations/0008_auto_20201112_1036.py b/authentik/providers/saml/migrations/0008_auto_20201112_1036.py
new file mode 100644
index 000000000..18dc55c89
--- /dev/null
+++ b/authentik/providers/saml/migrations/0008_auto_20201112_1036.py
@@ -0,0 +1,71 @@
+# Generated by Django 3.1.3 on 2020-11-12 10:36
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_crypto", "0002_create_self_signed_kp"),
+ ("authentik_providers_saml", "0007_samlprovider_verification_kp"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="samlprovider",
+ name="require_signing",
+ ),
+ migrations.AlterField(
+ model_name="samlprovider",
+ name="audience",
+ field=models.TextField(
+ default="",
+ help_text="Value of the audience restriction field of the asseration.",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlprovider",
+ name="issuer",
+ field=models.TextField(
+ default="authentik", help_text="Also known as EntityID"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlprovider",
+ name="signing_kp",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="Keypair used to sign outgoing Responses going to the Service Provider.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_crypto.certificatekeypair",
+ verbose_name="Signing Keypair",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlprovider",
+ name="sp_binding",
+ field=models.TextField(
+ choices=[("redirect", "Redirect"), ("post", "Post")],
+ default="redirect",
+ help_text="This determines how authentik sends the response back to the Service Provider.",
+ verbose_name="Service Provider Binding",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlprovider",
+ name="verification_kp",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="+",
+ to="authentik_crypto.certificatekeypair",
+ verbose_name="Verification Certificate",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/saml/migrations/0009_auto_20201112_2016.py b/authentik/providers/saml/migrations/0009_auto_20201112_2016.py
new file mode 100644
index 000000000..5ac5b73a1
--- /dev/null
+++ b/authentik/providers/saml/migrations/0009_auto_20201112_2016.py
@@ -0,0 +1,69 @@
+# Generated by Django 3.1.3 on 2020-11-12 20:16
+
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.sources.saml.processors import constants
+
+
+def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ SAMLProvider = apps.get_model("authentik_providers_saml", "SAMLProvider")
+ signature_translation_map = {
+ "rsa-sha1": constants.RSA_SHA1,
+ "rsa-sha256": constants.RSA_SHA256,
+ "ecdsa-sha256": constants.RSA_SHA256,
+ "dsa-sha1": constants.DSA_SHA1,
+ }
+ digest_translation_map = {
+ "sha1": constants.SHA1,
+ "sha256": constants.SHA256,
+ }
+
+ for source in SAMLProvider.objects.all():
+ source.signature_algorithm = signature_translation_map.get(
+ source.signature_algorithm, constants.RSA_SHA256
+ )
+ source.digest_algorithm = digest_translation_map.get(
+ source.digest_algorithm, constants.SHA256
+ )
+ source.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_saml", "0008_auto_20201112_1036"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="samlprovider",
+ name="digest_algorithm",
+ field=models.CharField(
+ choices=[
+ (constants.SHA1, "SHA1"),
+ (constants.SHA256, "SHA256"),
+ (constants.SHA384, "SHA384"),
+ (constants.SHA512, "SHA512"),
+ ],
+ default=constants.SHA256,
+ max_length=50,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlprovider",
+ name="signature_algorithm",
+ field=models.CharField(
+ choices=[
+ (constants.RSA_SHA1, "RSA-SHA1"),
+ (constants.RSA_SHA256, "RSA-SHA256"),
+ (constants.RSA_SHA384, "RSA-SHA384"),
+ (constants.RSA_SHA512, "RSA-SHA512"),
+ (constants.DSA_SHA1, "DSA-SHA1"),
+ ],
+ default=constants.RSA_SHA256,
+ max_length=50,
+ ),
+ ),
+ ]
diff --git a/passbook/providers/saml/migrations/__init__.py b/authentik/providers/saml/migrations/__init__.py
similarity index 100%
rename from passbook/providers/saml/migrations/__init__.py
rename to authentik/providers/saml/migrations/__init__.py
diff --git a/authentik/providers/saml/models.py b/authentik/providers/saml/models.py
new file mode 100644
index 000000000..c4dcbba78
--- /dev/null
+++ b/authentik/providers/saml/models.py
@@ -0,0 +1,207 @@
+"""authentik saml_idp Models"""
+from typing import Optional, Type
+from urllib.parse import urlparse
+
+from django.db import models
+from django.forms import ModelForm
+from django.http import HttpRequest
+from django.shortcuts import reverse
+from django.utils.translation import gettext_lazy as _
+from structlog import get_logger
+
+from authentik.core.models import PropertyMapping, Provider
+from authentik.crypto.models import CertificateKeyPair
+from authentik.lib.utils.template import render_to_string
+from authentik.lib.utils.time import timedelta_string_validator
+from authentik.sources.saml.processors.constants import (
+ DSA_SHA1,
+ RSA_SHA1,
+ RSA_SHA256,
+ RSA_SHA384,
+ RSA_SHA512,
+ SHA1,
+ SHA256,
+ SHA384,
+ SHA512,
+)
+
+LOGGER = get_logger()
+
+
+class SAMLBindings(models.TextChoices):
+ """SAML Bindings supported by authentik"""
+
+ REDIRECT = "redirect"
+ POST = "post"
+
+
+class SAMLProvider(Provider):
+ """SAML 2.0 Endpoint for applications which support SAML."""
+
+ acs_url = models.URLField(verbose_name=_("ACS URL"))
+ audience = models.TextField(
+ default="",
+ help_text=_("Value of the audience restriction field of the asseration."),
+ )
+ issuer = models.TextField(
+ help_text=_("Also known as EntityID"), default="authentik"
+ )
+ sp_binding = models.TextField(
+ choices=SAMLBindings.choices,
+ default=SAMLBindings.REDIRECT,
+ verbose_name=_("Service Provider Binding"),
+ help_text=_(
+ (
+ "This determines how authentik sends the "
+ "response back to the Service Provider."
+ )
+ ),
+ )
+
+ assertion_valid_not_before = models.TextField(
+ default="minutes=-5",
+ validators=[timedelta_string_validator],
+ help_text=_(
+ (
+ "Assertion valid not before current time + this value "
+ "(Format: hours=-1;minutes=-2;seconds=-3)."
+ )
+ ),
+ )
+ assertion_valid_not_on_or_after = models.TextField(
+ default="minutes=5",
+ validators=[timedelta_string_validator],
+ help_text=_(
+ (
+ "Assertion not valid on or after current time + this value "
+ "(Format: hours=1;minutes=2;seconds=3)."
+ )
+ ),
+ )
+
+ session_valid_not_on_or_after = models.TextField(
+ default="minutes=86400",
+ validators=[timedelta_string_validator],
+ help_text=_(
+ (
+ "Session not valid on or after current time + this value "
+ "(Format: hours=1;minutes=2;seconds=3)."
+ )
+ ),
+ )
+
+ digest_algorithm = models.CharField(
+ max_length=50,
+ choices=(
+ (SHA1, _("SHA1")),
+ (SHA256, _("SHA256")),
+ (SHA384, _("SHA384")),
+ (SHA512, _("SHA512")),
+ ),
+ default=SHA256,
+ )
+ signature_algorithm = models.CharField(
+ max_length=50,
+ choices=(
+ (RSA_SHA1, _("RSA-SHA1")),
+ (RSA_SHA256, _("RSA-SHA256")),
+ (RSA_SHA384, _("RSA-SHA384")),
+ (RSA_SHA512, _("RSA-SHA512")),
+ (DSA_SHA1, _("DSA-SHA1")),
+ ),
+ default=RSA_SHA256,
+ )
+
+ verification_kp = models.ForeignKey(
+ CertificateKeyPair,
+ default=None,
+ null=True,
+ blank=True,
+ help_text=_(
+ (
+ "When selected, incoming assertion's Signatures will be validated against this "
+ "certificate. To allow unsigned Requests, leave on default."
+ )
+ ),
+ on_delete=models.SET_NULL,
+ verbose_name=_("Verification Certificate"),
+ related_name="+",
+ )
+ signing_kp = models.ForeignKey(
+ CertificateKeyPair,
+ default=None,
+ null=True,
+ blank=True,
+ help_text=_(
+ "Keypair used to sign outgoing Responses going to the Service Provider."
+ ),
+ on_delete=models.SET_NULL,
+ verbose_name=_("Signing Keypair"),
+ )
+
+ @property
+ def launch_url(self) -> Optional[str]:
+ """Guess launch_url based on acs URL"""
+ launch_url = urlparse(self.acs_url)
+ return self.acs_url.replace(launch_url.path, "")
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.providers.saml.forms import SAMLProviderForm
+
+ return SAMLProviderForm
+
+ def __str__(self):
+ return f"SAML Provider {self.name}"
+
+ def link_download_metadata(self):
+ """Get link to download XML metadata for admin interface"""
+ try:
+ # pylint: disable=no-member
+ return reverse(
+ "authentik_providers_saml:metadata",
+ kwargs={"application_slug": self.application.slug},
+ )
+ except Provider.application.RelatedObjectDoesNotExist:
+ return None
+
+ def html_metadata_view(self, request: HttpRequest) -> Optional[str]:
+ """return template and context modal to view Metadata without downloading it"""
+ from authentik.providers.saml.views import DescriptorDownloadView
+
+ try:
+ # pylint: disable=no-member
+ metadata = DescriptorDownloadView.get_metadata(request, self)
+ return render_to_string(
+ "providers/saml/admin_metadata_modal.html",
+ {"provider": self, "metadata": metadata},
+ )
+ except Provider.application.RelatedObjectDoesNotExist:
+ return None
+
+ class Meta:
+
+ verbose_name = _("SAML Provider")
+ verbose_name_plural = _("SAML Providers")
+
+
+class SAMLPropertyMapping(PropertyMapping):
+ """Map User/Group attribute to SAML Attribute, which can be used by the Service Provider."""
+
+ saml_name = models.TextField(verbose_name="SAML Name")
+ friendly_name = models.TextField(default=None, blank=True, null=True)
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.providers.saml.forms import SAMLPropertyMappingForm
+
+ return SAMLPropertyMappingForm
+
+ def __str__(self):
+ name = self.friendly_name if self.friendly_name != "" else self.saml_name
+ return f"{self.name} ({name})"
+
+ class Meta:
+
+ verbose_name = _("SAML Property Mapping")
+ verbose_name_plural = _("SAML Property Mappings")
diff --git a/passbook/providers/saml/processors/__init__.py b/authentik/providers/saml/processors/__init__.py
similarity index 100%
rename from passbook/providers/saml/processors/__init__.py
rename to authentik/providers/saml/processors/__init__.py
diff --git a/authentik/providers/saml/processors/assertion.py b/authentik/providers/saml/processors/assertion.py
new file mode 100644
index 000000000..6cba0b283
--- /dev/null
+++ b/authentik/providers/saml/processors/assertion.py
@@ -0,0 +1,263 @@
+"""SAML Assertion generator"""
+from hashlib import sha256
+from types import GeneratorType
+
+import xmlsec
+from django.http import HttpRequest
+from lxml import etree # nosec
+from lxml.etree import Element, SubElement # nosec
+from structlog import get_logger
+
+from authentik.core.exceptions import PropertyMappingExpressionException
+from authentik.lib.utils.time import timedelta_from_string
+from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
+from authentik.providers.saml.processors.request_parser import AuthNRequest
+from authentik.providers.saml.utils import get_random_id
+from authentik.providers.saml.utils.time import get_time_string
+from authentik.sources.saml.exceptions import UnsupportedNameIDFormat
+from authentik.sources.saml.processors.constants import (
+ DIGEST_ALGORITHM_TRANSLATION_MAP,
+ NS_MAP,
+ NS_SAML_ASSERTION,
+ NS_SAML_PROTOCOL,
+ SAML_NAME_ID_FORMAT_EMAIL,
+ SAML_NAME_ID_FORMAT_PERSISTENT,
+ SAML_NAME_ID_FORMAT_TRANSIENT,
+ SAML_NAME_ID_FORMAT_X509,
+ SIGN_ALGORITHM_TRANSFORM_MAP,
+)
+
+LOGGER = get_logger()
+
+
+class AssertionProcessor:
+ """Generate a SAML Response from an AuthNRequest"""
+
+ provider: SAMLProvider
+ http_request: HttpRequest
+ auth_n_request: AuthNRequest
+
+ _issue_instant: str
+ _assertion_id: str
+
+ _valid_not_before: str
+ _valid_not_on_or_after: str
+
+ def __init__(
+ self, provider: SAMLProvider, request: HttpRequest, auth_n_request: AuthNRequest
+ ):
+ self.provider = provider
+ self.http_request = request
+ self.auth_n_request = auth_n_request
+
+ self._issue_instant = get_time_string()
+ self._assertion_id = get_random_id()
+
+ self._valid_not_before = get_time_string(
+ timedelta_from_string(self.provider.assertion_valid_not_before)
+ )
+ self._valid_not_on_or_after = get_time_string(
+ timedelta_from_string(self.provider.assertion_valid_not_on_or_after)
+ )
+
+ def get_attributes(self) -> Element:
+ """Get AttributeStatement Element with Attributes from Property Mappings."""
+ # https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
+ attribute_statement = Element(f"{{{NS_SAML_ASSERTION}}}AttributeStatement")
+ for mapping in self.provider.property_mappings.all().select_subclasses():
+ if not isinstance(mapping, SAMLPropertyMapping):
+ continue
+ try:
+ mapping: SAMLPropertyMapping
+ value = mapping.evaluate(
+ user=self.http_request.user,
+ request=self.http_request,
+ provider=self.provider,
+ )
+ if value is None:
+ continue
+
+ attribute = Element(f"{{{NS_SAML_ASSERTION}}}Attribute")
+ attribute.attrib["FriendlyName"] = mapping.friendly_name
+ attribute.attrib["Name"] = mapping.saml_name
+
+ if not isinstance(value, (list, GeneratorType)):
+ value = [value]
+
+ for value_item in value:
+ attribute_value = SubElement(
+ attribute, f"{{{NS_SAML_ASSERTION}}}AttributeValue"
+ )
+ if not isinstance(value_item, str):
+ value_item = str(value_item)
+ attribute_value.text = value_item
+
+ attribute_statement.append(attribute)
+
+ except PropertyMappingExpressionException as exc:
+ LOGGER.warning(exc)
+ continue
+ return attribute_statement
+
+ def get_issuer(self) -> Element:
+ """Get Issuer Element"""
+ issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer", nsmap=NS_MAP)
+ issuer.text = self.provider.issuer
+ return issuer
+
+ def get_assertion_auth_n_statement(self) -> Element:
+ """Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
+ auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
+ auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
+ auth_n_statement.attrib["SessionIndex"] = self._assertion_id
+
+ auth_n_context = SubElement(
+ auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext"
+ )
+ auth_n_context_class_ref = SubElement(
+ auth_n_context, f"{{{NS_SAML_ASSERTION}}}AuthnContextClassRef"
+ )
+ auth_n_context_class_ref.text = (
+ "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
+ )
+ return auth_n_statement
+
+ def get_assertion_conditions(self) -> Element:
+ """Generate Conditions with AudienceRestriction and Audience Elements."""
+ conditions = Element(f"{{{NS_SAML_ASSERTION}}}Conditions")
+ conditions.attrib["NotBefore"] = self._valid_not_before
+ conditions.attrib["NotOnOrAfter"] = self._valid_not_on_or_after
+ audience_restriction = SubElement(
+ conditions, f"{{{NS_SAML_ASSERTION}}}AudienceRestriction"
+ )
+ audience = SubElement(audience_restriction, f"{{{NS_SAML_ASSERTION}}}Audience")
+ audience.text = self.provider.audience
+ return conditions
+
+ def get_name_id(self) -> Element:
+ """Get NameID Element"""
+ name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID")
+ name_id.attrib["Format"] = self.auth_n_request.name_id_policy
+ if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_EMAIL:
+ name_id.text = self.http_request.user.email
+ return name_id
+ if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_PERSISTENT:
+ name_id.text = self.http_request.user.username
+ return name_id
+ if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_X509:
+ # This attribute is statically set by the LDAP source
+ name_id.text = self.http_request.user.attributes.get(
+ "distinguishedName", ""
+ )
+ return name_id
+ if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
+ # This attribute is statically set by the LDAP source
+ session_key: str = self.http_request.user.session.session_key
+ name_id.text = sha256(session_key.encode()).hexdigest()
+ return name_id
+ raise UnsupportedNameIDFormat(
+ f"Assertion contains NameID with unsupported format {name_id.attrib['Format']}."
+ )
+
+ def get_assertion_subject(self) -> Element:
+ """Generate Subject Element with NameID and SubjectConfirmation Objects"""
+ subject = Element(f"{{{NS_SAML_ASSERTION}}}Subject")
+ subject.append(self.get_name_id())
+
+ subject_confirmation = SubElement(
+ subject, f"{{{NS_SAML_ASSERTION}}}SubjectConfirmation"
+ )
+ subject_confirmation.attrib["Method"] = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
+
+ subject_confirmation_data = SubElement(
+ subject_confirmation, f"{{{NS_SAML_ASSERTION}}}SubjectConfirmationData"
+ )
+ if self.auth_n_request.id:
+ subject_confirmation_data.attrib["InResponseTo"] = self.auth_n_request.id
+ subject_confirmation_data.attrib["NotOnOrAfter"] = self._valid_not_on_or_after
+ subject_confirmation_data.attrib["Recipient"] = self.provider.acs_url
+ return subject
+
+ def get_assertion(self) -> Element:
+ """Generate Main Assertion Element"""
+ assertion = Element(f"{{{NS_SAML_ASSERTION}}}Assertion", nsmap=NS_MAP)
+ assertion.attrib["Version"] = "2.0"
+ assertion.attrib["ID"] = self._assertion_id
+ assertion.attrib["IssueInstant"] = self._issue_instant
+ assertion.append(self.get_issuer())
+
+ if self.provider.signing_kp:
+ sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
+ self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1
+ )
+ signature = xmlsec.template.create(
+ assertion,
+ xmlsec.constants.TransformExclC14N,
+ sign_algorithm_transform,
+ ns="ds", # type: ignore
+ )
+ assertion.append(signature)
+
+ assertion.append(self.get_assertion_subject())
+ assertion.append(self.get_assertion_conditions())
+ assertion.append(self.get_assertion_auth_n_statement())
+
+ assertion.append(self.get_attributes())
+ return assertion
+
+ def get_response(self) -> Element:
+ """Generate Root response element"""
+ response = Element(f"{{{NS_SAML_PROTOCOL}}}Response", nsmap=NS_MAP)
+ response.attrib["Version"] = "2.0"
+ response.attrib["IssueInstant"] = self._issue_instant
+ response.attrib["Destination"] = self.provider.acs_url
+ response.attrib["ID"] = get_random_id()
+ if self.auth_n_request.id:
+ response.attrib["InResponseTo"] = self.auth_n_request.id
+
+ response.append(self.get_issuer())
+
+ status = SubElement(response, f"{{{NS_SAML_PROTOCOL}}}Status")
+ status_code = SubElement(status, f"{{{NS_SAML_PROTOCOL}}}StatusCode")
+ status_code.attrib["Value"] = "urn:oasis:names:tc:SAML:2.0:status:Success"
+
+ response.append(self.get_assertion())
+ return response
+
+ def build_response(self) -> str:
+ """Build string XML Response and sign if signing is enabled."""
+ root_response = self.get_response()
+ if self.provider.signing_kp:
+ digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
+ self.provider.digest_algorithm, xmlsec.constants.TransformSha1
+ )
+ assertion = root_response.xpath("//saml:Assertion", namespaces=NS_MAP)[0]
+ xmlsec.tree.add_ids(assertion, ["ID"])
+ signature_node = xmlsec.tree.find_node(
+ assertion, xmlsec.constants.NodeSignature
+ )
+ ref = xmlsec.template.add_reference(
+ signature_node,
+ digest_algorithm_transform,
+ uri="#" + self._assertion_id,
+ )
+ xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
+ xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
+ key_info = xmlsec.template.ensure_key_info(signature_node)
+ xmlsec.template.add_x509_data(key_info)
+
+ ctx = xmlsec.SignatureContext()
+
+ key = xmlsec.Key.from_memory(
+ self.provider.signing_kp.key_data,
+ xmlsec.constants.KeyDataFormatPem,
+ None,
+ )
+ key.load_cert_from_memory(
+ self.provider.signing_kp.certificate_data,
+ xmlsec.constants.KeyDataFormatCertPem,
+ )
+ ctx.key = key
+ ctx.sign(signature_node)
+
+ return etree.tostring(root_response).decode("utf-8") # nosec
diff --git a/authentik/providers/saml/processors/metadata.py b/authentik/providers/saml/processors/metadata.py
new file mode 100644
index 000000000..0e8a1d376
--- /dev/null
+++ b/authentik/providers/saml/processors/metadata.py
@@ -0,0 +1,108 @@
+"""SAML Identity Provider Metadata Processor"""
+from typing import Iterator, Optional
+
+from django.http import HttpRequest
+from django.shortcuts import reverse
+from lxml.etree import Element, SubElement, tostring # nosec
+
+from authentik.providers.saml.models import SAMLProvider
+from authentik.providers.saml.utils.encoding import strip_pem_header
+from authentik.sources.saml.processors.constants import (
+ NS_MAP,
+ NS_SAML_METADATA,
+ NS_SIGNATURE,
+ SAML_BINDING_POST,
+ SAML_BINDING_REDIRECT,
+ SAML_NAME_ID_FORMAT_EMAIL,
+ SAML_NAME_ID_FORMAT_PERSISTENT,
+ SAML_NAME_ID_FORMAT_TRANSIENT,
+ SAML_NAME_ID_FORMAT_X509,
+)
+
+
+class MetadataProcessor:
+ """SAML Identity Provider Metadata Processor"""
+
+ provider: SAMLProvider
+ http_request: HttpRequest
+
+ def __init__(self, provider: SAMLProvider, request: HttpRequest):
+ self.provider = provider
+ self.http_request = request
+
+ def get_signing_key_descriptor(self) -> Optional[Element]:
+ """Get Singing KeyDescriptor, if enabled for the provider"""
+ if self.provider.signing_kp:
+ key_descriptor = Element(f"{{{NS_SAML_METADATA}}}KeyDescriptor")
+ key_descriptor.attrib["use"] = "signing"
+ key_info = SubElement(key_descriptor, f"{{{NS_SIGNATURE}}}KeyInfo")
+ x509_data = SubElement(key_info, f"{{{NS_SIGNATURE}}}X509Data")
+ x509_certificate = SubElement(
+ x509_data, f"{{{NS_SIGNATURE}}}X509Certificate"
+ )
+ x509_certificate.text = strip_pem_header(
+ self.provider.signing_kp.certificate_data.replace("\r", "")
+ )
+ return key_descriptor
+ return None
+
+ def get_name_id_formats(self) -> Iterator[Element]:
+ """Get compatible NameID Formats"""
+ formats = [
+ SAML_NAME_ID_FORMAT_EMAIL,
+ SAML_NAME_ID_FORMAT_PERSISTENT,
+ SAML_NAME_ID_FORMAT_X509,
+ SAML_NAME_ID_FORMAT_TRANSIENT,
+ ]
+ for name_id_format in formats:
+ element = Element(f"{{{NS_SAML_METADATA}}}NameIDFormat")
+ element.text = name_id_format
+ yield element
+
+ def get_bindings(self) -> Iterator[Element]:
+ """Get all Bindings supported"""
+ binding_url_map = {
+ SAML_BINDING_POST: self.http_request.build_absolute_uri(
+ reverse(
+ "authentik_providers_saml:sso-post",
+ kwargs={"application_slug": self.provider.application.slug},
+ )
+ ),
+ SAML_BINDING_REDIRECT: self.http_request.build_absolute_uri(
+ reverse(
+ "authentik_providers_saml:sso-redirect",
+ kwargs={"application_slug": self.provider.application.slug},
+ )
+ ),
+ }
+ for binding, url in binding_url_map.items():
+ element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService")
+ element.attrib["Binding"] = binding
+ element.attrib["Location"] = url
+ yield element
+
+ def build_entity_descriptor(self) -> str:
+ """Build full EntityDescriptor"""
+ entity_descriptor = Element(
+ f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP
+ )
+ entity_descriptor.attrib["entityID"] = self.provider.issuer
+
+ idp_sso_descriptor = SubElement(
+ entity_descriptor, f"{{{NS_SAML_METADATA}}}IDPSSODescriptor"
+ )
+ idp_sso_descriptor.attrib[
+ "protocolSupportEnumeration"
+ ] = "urn:oasis:names:tc:SAML:2.0:protocol"
+
+ signing_descriptor = self.get_signing_key_descriptor()
+ if signing_descriptor is not None:
+ idp_sso_descriptor.append(signing_descriptor)
+
+ for name_id_format in self.get_name_id_formats():
+ idp_sso_descriptor.append(name_id_format)
+
+ for binding in self.get_bindings():
+ idp_sso_descriptor.append(binding)
+
+ return tostring(entity_descriptor, pretty_print=True).decode()
diff --git a/authentik/providers/saml/processors/request_parser.py b/authentik/providers/saml/processors/request_parser.py
new file mode 100644
index 000000000..9f38bae6a
--- /dev/null
+++ b/authentik/providers/saml/processors/request_parser.py
@@ -0,0 +1,169 @@
+"""SAML AuthNRequest Parser and dataclass"""
+from base64 import b64decode
+from dataclasses import dataclass
+from typing import Optional
+from urllib.parse import quote_plus
+
+import xmlsec
+from defusedxml import ElementTree
+from lxml import etree # nosec
+from structlog import get_logger
+
+from authentik.providers.saml.exceptions import CannotHandleAssertion
+from authentik.providers.saml.models import SAMLProvider
+from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
+from authentik.sources.saml.processors.constants import (
+ DSA_SHA1,
+ NS_MAP,
+ NS_SAML_PROTOCOL,
+ RSA_SHA1,
+ RSA_SHA256,
+ RSA_SHA384,
+ RSA_SHA512,
+ SAML_NAME_ID_FORMAT_EMAIL,
+)
+
+LOGGER = get_logger()
+ERROR_SIGNATURE_REQUIRED_BUT_ABSENT = (
+ "Verification Certificate configured, but request is not signed."
+)
+ERROR_SIGNATURE_EXISTS_BUT_NO_VERIFIER = (
+ "Provider does not have a Validation Certificate configured."
+)
+ERROR_FAILED_TO_VERIFY = "Failed to verify signature"
+
+
+@dataclass
+class AuthNRequest:
+ """AuthNRequest Dataclass"""
+
+ # pylint: disable=invalid-name
+ id: Optional[str] = None
+
+ relay_state: Optional[str] = None
+
+ name_id_policy: str = SAML_NAME_ID_FORMAT_EMAIL
+
+
+class AuthNRequestParser:
+ """AuthNRequest Parser"""
+
+ provider: SAMLProvider
+
+ def __init__(self, provider: SAMLProvider):
+ self.provider = provider
+
+ def _parse_xml(self, decoded_xml: str, relay_state: Optional[str]) -> AuthNRequest:
+ root = ElementTree.fromstring(decoded_xml)
+
+ request_acs_url = root.attrib["AssertionConsumerServiceURL"]
+
+ if self.provider.acs_url.lower() != request_acs_url.lower():
+ msg = (
+ f"ACS URL of {request_acs_url} doesn't match Provider "
+ f"ACS URL of {self.provider.acs_url}."
+ )
+ LOGGER.info(msg)
+ raise CannotHandleAssertion(msg)
+
+ auth_n_request = AuthNRequest(id=root.attrib["ID"], relay_state=relay_state)
+
+ # Check if AuthnRequest has a NameID Policy object
+ name_id_policies = root.findall(f"{{{NS_SAML_PROTOCOL}}}:NameIDPolicy")
+ if len(name_id_policies) > 0:
+ name_id_policy = name_id_policies[0]
+ auth_n_request.name_id_policy = name_id_policy.attrib["Format"]
+
+ return auth_n_request
+
+ def parse(self, saml_request: str, relay_state: Optional[str]) -> AuthNRequest:
+ """Validate and parse raw request with enveloped signautre."""
+ decoded_xml = b64decode(saml_request.encode()).decode()
+
+ verifier = self.provider.verification_kp
+
+ root = etree.fromstring(decoded_xml) # nosec
+ xmlsec.tree.add_ids(root, ["ID"])
+ signature_nodes = root.xpath(
+ "/samlp:AuthnRequest/ds:Signature", namespaces=NS_MAP
+ )
+ if len(signature_nodes) != 1:
+ raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT)
+
+ signature_node = signature_nodes[0]
+
+ if verifier and signature_node is None:
+ raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT)
+
+ if signature_node is not None:
+ if not verifier:
+ raise CannotHandleAssertion(ERROR_SIGNATURE_EXISTS_BUT_NO_VERIFIER)
+
+ try:
+ ctx = xmlsec.SignatureContext()
+ key = xmlsec.Key.from_memory(
+ verifier.certificate_data,
+ xmlsec.constants.KeyDataFormatCertPem,
+ None,
+ )
+ ctx.key = key
+ ctx.verify(signature_node)
+ except xmlsec.VerificationError as exc:
+ raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
+
+ return self._parse_xml(decoded_xml, relay_state)
+
+ def parse_detached(
+ self,
+ saml_request: str,
+ relay_state: Optional[str],
+ signature: Optional[str] = None,
+ sig_alg: Optional[str] = None,
+ ) -> AuthNRequest:
+ """Validate and parse raw request with detached signature"""
+ decoded_xml = decode_base64_and_inflate(saml_request)
+
+ verifier = self.provider.verification_kp
+
+ if verifier and not (signature and sig_alg):
+ raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT)
+
+ if signature and sig_alg:
+ if not verifier:
+ raise CannotHandleAssertion(ERROR_SIGNATURE_EXISTS_BUT_NO_VERIFIER)
+
+ querystring = f"SAMLRequest={quote_plus(saml_request)}&"
+ if relay_state is not None:
+ querystring += f"RelayState={quote_plus(relay_state)}&"
+ querystring += f"SigAlg={quote_plus(sig_alg)}"
+
+ dsig_ctx = xmlsec.SignatureContext()
+ key = xmlsec.Key.from_memory(
+ verifier.certificate_data, xmlsec.constants.KeyDataFormatCertPem, None
+ )
+ dsig_ctx.key = key
+
+ sign_algorithm_transform_map = {
+ DSA_SHA1: xmlsec.constants.TransformDsaSha1,
+ RSA_SHA1: xmlsec.constants.TransformRsaSha1,
+ RSA_SHA256: xmlsec.constants.TransformRsaSha256,
+ RSA_SHA384: xmlsec.constants.TransformRsaSha384,
+ RSA_SHA512: xmlsec.constants.TransformRsaSha512,
+ }
+ sign_algorithm_transform = sign_algorithm_transform_map.get(
+ sig_alg, xmlsec.constants.TransformRsaSha1
+ )
+
+ try:
+ dsig_ctx.verify_binary(
+ querystring.encode("utf-8"),
+ sign_algorithm_transform,
+ b64decode(signature),
+ )
+ except xmlsec.VerificationError as exc:
+ raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
+ return self._parse_xml(decoded_xml, relay_state)
+
+ def idp_initiated(self) -> AuthNRequest:
+ """Create IdP Initiated AuthNRequest"""
+ return AuthNRequest()
diff --git a/authentik/providers/saml/settings.py b/authentik/providers/saml/settings.py
new file mode 100644
index 000000000..5477bb568
--- /dev/null
+++ b/authentik/providers/saml/settings.py
@@ -0,0 +1,6 @@
+"""saml provider settings"""
+
+AUTHENTIK_PROVIDERS_SAML_PROCESSORS = [
+ "authentik.providers.saml.processors.generic",
+ "authentik.providers.saml.processors.salesforce",
+]
diff --git a/authentik/providers/saml/templates/providers/saml/admin_metadata_modal.html b/authentik/providers/saml/templates/providers/saml/admin_metadata_modal.html
new file mode 100644
index 000000000..b21ff7160
--- /dev/null
+++ b/authentik/providers/saml/templates/providers/saml/admin_metadata_modal.html
@@ -0,0 +1,22 @@
+{% load i18n %}
+
+
+
+ {% trans 'View Metadata' %}
+
+
+
+
diff --git a/passbook/providers/saml/templates/providers/saml/consent.html b/authentik/providers/saml/templates/providers/saml/consent.html
similarity index 100%
rename from passbook/providers/saml/templates/providers/saml/consent.html
rename to authentik/providers/saml/templates/providers/saml/consent.html
diff --git a/passbook/providers/saml/templates/providers/saml/logged_out.html b/authentik/providers/saml/templates/providers/saml/logged_out.html
similarity index 100%
rename from passbook/providers/saml/templates/providers/saml/logged_out.html
rename to authentik/providers/saml/templates/providers/saml/logged_out.html
diff --git a/authentik/providers/saml/templates/providers/saml/property_mapping_form.html b/authentik/providers/saml/templates/providers/saml/property_mapping_form.html
new file mode 100644
index 000000000..c202b5b29
--- /dev/null
+++ b/authentik/providers/saml/templates/providers/saml/property_mapping_form.html
@@ -0,0 +1,14 @@
+{% extends "generic/form.html" %}
+
+{% load i18n %}
+
+{% block beneath_form %}
+
+{% endblock %}
diff --git a/passbook/providers/saml/tests/__init__.py b/authentik/providers/saml/tests/__init__.py
similarity index 100%
rename from passbook/providers/saml/tests/__init__.py
rename to authentik/providers/saml/tests/__init__.py
diff --git a/authentik/providers/saml/tests/test_auth_n_request.py b/authentik/providers/saml/tests/test_auth_n_request.py
new file mode 100644
index 000000000..e01c45c56
--- /dev/null
+++ b/authentik/providers/saml/tests/test_auth_n_request.py
@@ -0,0 +1,211 @@
+"""Test AuthN Request generator and parser"""
+from base64 import b64encode
+
+from django.contrib.sessions.middleware import SessionMiddleware
+from django.http.request import HttpRequest, QueryDict
+from django.test import RequestFactory, TestCase
+from guardian.utils import get_anonymous_user
+
+from authentik.crypto.models import CertificateKeyPair
+from authentik.flows.models import Flow
+from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
+from authentik.providers.saml.processors.assertion import AssertionProcessor
+from authentik.providers.saml.processors.request_parser import AuthNRequestParser
+from authentik.sources.saml.exceptions import MismatchedRequestID
+from authentik.sources.saml.models import SAMLSource
+from authentik.sources.saml.processors.constants import SAML_NAME_ID_FORMAT_EMAIL
+from authentik.sources.saml.processors.request import (
+ SESSION_REQUEST_ID,
+ RequestProcessor,
+)
+from authentik.sources.saml.processors.response import ResponseProcessor
+
+REDIRECT_REQUEST = (
+ "fZLNbsIwEIRfJfIdbKeFgEUipXAoEm0jSHvopTLJplhK7NTr9Oft6yRUKhekPdk73+yOdoWyqVuRdu6k9/DRAbrgu6k1iu"
+ "EjJp3VwkhUKLRsAIUrxCF92IlwykRrjTOFqUmQIoJ1yui10dg1YA9gP1UBz/tdTE7OtSgo5WzKQzYditGeP8GW9rSQZk+H"
+ "nAQbb6+07EGj7EI1j8SCeaVs21oVQ9dAoRqcf6OIhh6VLpV+pxZKZaFwlATbTUzeyqKazaqiDCO5WEQwZzKCagkwr8obWc"
+ "qjb0PsYKvRSe1iErKQTTj3lYdc3HLBl68kyM4L340u19M5j4LiPs+zybjgC1gclvMNJFn104vB2P5L/TpW/kVNkqvBrug/"
+ "+mjVikeP224y4/P7CdK6Nl9rC9JBTDihySi5vIbkFw=="
+)
+REDIRECT_SIGNATURE = (
+ "UlOe1BItHVHM+io6rUZAenIqfibm7hM6wr9I1rcP5kPJ4N8cbkyqmAMh5LD2lUq3PDERJfjdO/oOKnvJmbD2y9MOObyR2d"
+ "7Udv62KERrA0qM917Q+w8wrLX7w2nHY96EDvkXD4iAomR5EE9dHRuubDy7uRv2syEevc0gfoLi7W/5vp96vJgsaSqxnTp+"
+ "QiYq49KyWyMtxRULF2yd+vYDnHCDME73mNSULEHfwCU71dvbKpnFaej78q7wS20gUk6ysOOXXtvDHbiVcpUb/9oyDgNAxU"
+ "jVvPdh96AhBFj2HCuGZhP0CGotafTciu6YlsiwUpuBkIYgZmNWYa3FR9LS4Q=="
+)
+REDIRECT_SIG_ALG = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
+REDIRECT_RELAY_STATE = (
+ "ss:mem:7a054b4af44f34f89dd2d973f383c250b6b076e7f06cfa8276008a6504eaf3c7"
+)
+REDIRECT_CERT = """-----BEGIN CERTIFICATE-----
+MIIDCDCCAfCgAwIBAgIRAM5s+bhOHk4ChSpPkGSh0NswDQYJKoZIhvcNAQELBQAw
+KzEpMCcGA1UEAwwgcGFzc2Jvb2sgU2VsZi1zaWduZWQgQ2VydGlmaWNhdGUwHhcN
+MjAxMTA3MjAzNDIxWhcNMjExMTA4MjAzNDIxWjBUMSkwJwYDVQQDDCBwYXNzYm9v
+ayBTZWxmLXNpZ25lZCBDZXJ0aWZpY2F0ZTERMA8GA1UECgwIcGFzc2Jvb2sxFDAS
+BgNVBAsMC1NlbGYtc2lnbmVkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
+AQEAuh+Bv6a/ogpic72X/sq86YiLzVjixnGqjc4wpsPPP00GX8jUAZJL4Tjo+sYK
+IU2DF2/azlVqjkbLho4rGuuc8YkbFXBEXPYc5h3bseO2vk6sbbbWKV0mro1VFhBh
+T59hBORuMMefmQdhFzsRNOGklIptQdg0quD8ET3+/uNfIT98S2ruZdYteFls46Sa
+MokZFYVD6pWEYV4P2MKVAFqJX9bqBW0LfCCfFqHAOJjUZj9dtleg86d2WfedUOG2
+LK0iLrydjhThbI0GUDhv0jWYkRlv04fdJ1WSRANYA3gBOnyw+Iigh2xNnYbVZMXT
+I0BupIJ4UoODMc4QpD2GYJ6oGwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCCEF3e
+Y99KxEBSR4H4/TvKbnh4QtHswOf7MaGdjtrld7l4u4Hc4NEklNdDn1XLKhZwnq3Z
+LRsRlJutDzZ18SRmAJPXPbka7z7D+LA1mbNQElOgiKyQHD9rIJSBr6X5SM9As3CR
+7QUsb8dg7kc+Jn7WuLZIEVxxMtekt0buWEdMJiklF0tCS3LNsP083FaQk/H1K0z6
+3PWP26EFdwir3RyTKLY5CBLjKrUAo9O1l/WBVFYbdetnipbGGu5f6nk6nnxbwLLI
+Dm52Vkq+xFDDUq9IqIoYvLaE86MDvtpMQEx65tIGU19vUf3fL/+sSfdRZ1HDzP4d
+qNAZMq1DqpibfCBg
+-----END CERTIFICATE-----"""
+
+
+def dummy_get_response(request: HttpRequest): # pragma: no cover
+ """Dummy get_response for SessionMiddleware"""
+ return None
+
+
+class TestAuthNRequest(TestCase):
+ """Test AuthN Request generator and parser"""
+
+ def setUp(self):
+ cert = CertificateKeyPair.objects.first()
+ self.provider: SAMLProvider = SAMLProvider.objects.create(
+ authorization_flow=Flow.objects.get(
+ slug="default-provider-authorization-implicit-consent"
+ ),
+ acs_url="http://testserver/source/saml/provider/acs/",
+ signing_kp=cert,
+ verification_kp=cert,
+ )
+ self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
+ self.provider.save()
+ self.source = SAMLSource.objects.create(
+ slug="provider",
+ issuer="authentik",
+ signing_kp=cert,
+ )
+ self.factory = RequestFactory()
+
+ def test_signed_valid(self):
+ """Test generated AuthNRequest with valid signature"""
+ http_request = self.factory.get("/")
+
+ middleware = SessionMiddleware(dummy_get_response)
+ middleware.process_request(http_request)
+ http_request.session.save()
+
+ # First create an AuthNRequest
+ request_proc = RequestProcessor(self.source, http_request, "test_state")
+ request = request_proc.build_auth_n()
+ # Now we check the ID and signature
+ parsed_request = AuthNRequestParser(self.provider).parse(
+ b64encode(request.encode()).decode(), "test_state"
+ )
+ self.assertEqual(parsed_request.id, request_proc.request_id)
+ self.assertEqual(parsed_request.relay_state, "test_state")
+
+ def test_request_full_signed(self):
+ """Test full SAML Request/Response flow, fully signed"""
+ http_request = self.factory.get("/")
+ http_request.user = get_anonymous_user()
+
+ middleware = SessionMiddleware(dummy_get_response)
+ middleware.process_request(http_request)
+ http_request.session.save()
+
+ # First create an AuthNRequest
+ request_proc = RequestProcessor(self.source, http_request, "test_state")
+ request = request_proc.build_auth_n()
+
+ # To get an assertion we need a parsed request (parsed by provider)
+ parsed_request = AuthNRequestParser(self.provider).parse(
+ b64encode(request.encode()).decode(), "test_state"
+ )
+ # Now create a response and convert it to string (provider)
+ response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
+ response = response_proc.build_response()
+
+ # Now parse the response (source)
+ http_request.POST = QueryDict(mutable=True)
+ http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
+
+ response_parser = ResponseProcessor(self.source)
+ response_parser.parse(http_request)
+
+ def test_request_id_invalid(self):
+ """Test generated AuthNRequest with invalid request ID"""
+ http_request = self.factory.get("/")
+ http_request.user = get_anonymous_user()
+
+ middleware = SessionMiddleware(dummy_get_response)
+ middleware.process_request(http_request)
+ http_request.session.save()
+
+ # First create an AuthNRequest
+ request_proc = RequestProcessor(self.source, http_request, "test_state")
+ request = request_proc.build_auth_n()
+
+ # change the request ID
+ http_request.session[SESSION_REQUEST_ID] = "test"
+ http_request.session.save()
+
+ # To get an assertion we need a parsed request (parsed by provider)
+ parsed_request = AuthNRequestParser(self.provider).parse(
+ b64encode(request.encode()).decode(), "test_state"
+ )
+ # Now create a response and convert it to string (provider)
+ response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
+ response = response_proc.build_response()
+
+ # Now parse the response (source)
+ http_request.POST = QueryDict(mutable=True)
+ http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
+
+ response_parser = ResponseProcessor(self.source)
+
+ with self.assertRaises(MismatchedRequestID):
+ response_parser.parse(http_request)
+
+ def test_signed_valid_detached(self):
+ """Test generated AuthNRequest with valid signature (detached)"""
+ http_request = self.factory.get("/")
+
+ middleware = SessionMiddleware(dummy_get_response)
+ middleware.process_request(http_request)
+ http_request.session.save()
+
+ # First create an AuthNRequest
+ request_proc = RequestProcessor(self.source, http_request, "test_state")
+ params = request_proc.build_auth_n_detached()
+ # Now we check the ID and signature
+ parsed_request = AuthNRequestParser(self.provider).parse_detached(
+ params["SAMLRequest"],
+ params["RelayState"],
+ params["Signature"],
+ params["SigAlg"],
+ )
+ self.assertEqual(parsed_request.id, request_proc.request_id)
+ self.assertEqual(parsed_request.relay_state, "test_state")
+
+ def test_signed_detached_static(self):
+ """Test request with detached signature,
+ taken from https://www.samltool.com/generic_sso_req.php"""
+ static_keypair = CertificateKeyPair.objects.create(
+ name="samltool", certificate_data=REDIRECT_CERT
+ )
+ provider = SAMLProvider(
+ name="samltool",
+ authorization_flow=Flow.objects.get(
+ slug="default-provider-authorization-implicit-consent"
+ ),
+ acs_url="https://10.120.20.200/saml-sp/SAML2/POST",
+ audience="https://10.120.20.200/saml-sp/SAML2/POST",
+ issuer="https://10.120.20.200/saml-sp/SAML2/POST",
+ signing_kp=static_keypair,
+ verification_kp=static_keypair,
+ )
+ parsed_request = AuthNRequestParser(provider).parse_detached(
+ REDIRECT_REQUEST, REDIRECT_RELAY_STATE, REDIRECT_SIGNATURE, REDIRECT_SIG_ALG
+ )
+ self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab")
+ self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL)
+ self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE)
diff --git a/authentik/providers/saml/tests/test_utils_time.py b/authentik/providers/saml/tests/test_utils_time.py
new file mode 100644
index 000000000..419d205e9
--- /dev/null
+++ b/authentik/providers/saml/tests/test_utils_time.py
@@ -0,0 +1,27 @@
+"""Test time utils"""
+from datetime import timedelta
+
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+
+from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
+
+
+class TestTimeUtils(TestCase):
+ """Test time-utils"""
+
+ def test_valid(self):
+ """Test valid expression"""
+ expr = "hours=3;minutes=1"
+ expected = timedelta(hours=3, minutes=1)
+ self.assertEqual(timedelta_from_string(expr), expected)
+
+ def test_invalid(self):
+ """Test invalid expression"""
+ with self.assertRaises(ValueError):
+ timedelta_from_string("foo")
+
+ def test_validation(self):
+ """Test Django model field validator"""
+ with self.assertRaises(ValidationError):
+ timedelta_string_validator("foo")
diff --git a/authentik/providers/saml/urls.py b/authentik/providers/saml/urls.py
new file mode 100644
index 000000000..2f3ce3ec9
--- /dev/null
+++ b/authentik/providers/saml/urls.py
@@ -0,0 +1,29 @@
+"""authentik SAML IDP URLs"""
+from django.urls import path
+
+from authentik.providers.saml import views
+
+urlpatterns = [
+ # SSO Bindings
+ path(
+ "/sso/binding/redirect/",
+ views.SAMLSSOBindingRedirectView.as_view(),
+ name="sso-redirect",
+ ),
+ path(
+ "/sso/binding/post/",
+ views.SAMLSSOBindingPOSTView.as_view(),
+ name="sso-post",
+ ),
+ # SSO IdP Initiated
+ path(
+ "/sso/binding/init/",
+ views.SAMLSSOBindingInitView.as_view(),
+ name="sso-init",
+ ),
+ path(
+ "/metadata/",
+ views.DescriptorDownloadView.as_view(),
+ name="metadata",
+ ),
+]
diff --git a/passbook/providers/saml/utils/__init__.py b/authentik/providers/saml/utils/__init__.py
similarity index 100%
rename from passbook/providers/saml/utils/__init__.py
rename to authentik/providers/saml/utils/__init__.py
diff --git a/passbook/providers/saml/utils/encoding.py b/authentik/providers/saml/utils/encoding.py
similarity index 100%
rename from passbook/providers/saml/utils/encoding.py
rename to authentik/providers/saml/utils/encoding.py
diff --git a/passbook/providers/saml/utils/time.py b/authentik/providers/saml/utils/time.py
similarity index 100%
rename from passbook/providers/saml/utils/time.py
rename to authentik/providers/saml/utils/time.py
diff --git a/authentik/providers/saml/views.py b/authentik/providers/saml/views.py
new file mode 100644
index 000000000..dd347d655
--- /dev/null
+++ b/authentik/providers/saml/views.py
@@ -0,0 +1,239 @@
+"""authentik SAML IDP Views"""
+from typing import Optional
+
+from django.core.validators import URLValidator
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.decorators import method_decorator
+from django.utils.http import urlencode
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from django.views.decorators.csrf import csrf_exempt
+from structlog import get_logger
+
+from authentik.audit.models import Event, EventAction
+from authentik.core.models import Application, Provider
+from authentik.flows.models import in_memory_stage
+from authentik.flows.planner import (
+ PLAN_CONTEXT_APPLICATION,
+ PLAN_CONTEXT_SSO,
+ FlowPlanner,
+)
+from authentik.flows.stage import StageView
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.lib.utils.urls import redirect_with_qs
+from authentik.lib.views import bad_request_message
+from authentik.policies.views import PolicyAccessView
+from authentik.providers.saml.exceptions import CannotHandleAssertion
+from authentik.providers.saml.models import SAMLBindings, SAMLProvider
+from authentik.providers.saml.processors.assertion import AssertionProcessor
+from authentik.providers.saml.processors.metadata import MetadataProcessor
+from authentik.providers.saml.processors.request_parser import (
+ AuthNRequest,
+ AuthNRequestParser,
+)
+from authentik.providers.saml.utils.encoding import deflate_and_base64_encode, nice64
+from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE
+
+LOGGER = get_logger()
+URL_VALIDATOR = URLValidator(schemes=("http", "https"))
+REQUEST_KEY_SAML_REQUEST = "SAMLRequest"
+REQUEST_KEY_SAML_SIGNATURE = "Signature"
+REQUEST_KEY_SAML_SIG_ALG = "SigAlg"
+REQUEST_KEY_SAML_RESPONSE = "SAMLResponse"
+REQUEST_KEY_RELAY_STATE = "RelayState"
+
+SESSION_KEY_AUTH_N_REQUEST = "authn_request"
+
+
+class SAMLSSOView(PolicyAccessView):
+ """ "SAML SSO Base View, which plans a flow and injects our final stage.
+ Calls get/post handler."""
+
+ def resolve_provider_application(self):
+ self.application = get_object_or_404(
+ Application, slug=self.kwargs["application_slug"]
+ )
+ self.provider: SAMLProvider = get_object_or_404(
+ SAMLProvider, pk=self.application.provider_id
+ )
+
+ def check_saml_request(self) -> Optional[HttpRequest]:
+ """Handler to verify the SAML Request. Must be implemented by a subclass"""
+ raise NotImplementedError
+
+ # pylint: disable=unused-argument
+ def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
+ """Verify the SAML Request, and if valid initiate the FlowPlanner for the application"""
+ # Call the method handler, which checks the SAML
+ # Request and returns a HTTP Response on error
+ method_response = self.check_saml_request()
+ if method_response:
+ return method_response
+ # Regardless, we start the planner and return to it
+ planner = FlowPlanner(self.provider.authorization_flow)
+ planner.allow_empty_flows = True
+ plan = planner.plan(
+ request,
+ {
+ PLAN_CONTEXT_SSO: True,
+ PLAN_CONTEXT_APPLICATION: self.application,
+ PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/saml/consent.html",
+ },
+ )
+ plan.append(in_memory_stage(SAMLFlowFinalView))
+ request.session[SESSION_KEY_PLAN] = plan
+ return redirect_with_qs(
+ "authentik_flows:flow-executor-shell",
+ request.GET,
+ flow_slug=self.provider.authorization_flow.slug,
+ )
+
+ def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
+ """GET and POST use the same handler, but we can't
+ override .dispatch easily because PolicyAccessView's dispatch"""
+ return self.get(request, application_slug)
+
+
+class SAMLSSOBindingRedirectView(SAMLSSOView):
+ """SAML Handler for SSO/Redirect bindings, which are sent via GET"""
+
+ def check_saml_request(self) -> Optional[HttpRequest]:
+ """Handle REDIRECT bindings"""
+ if REQUEST_KEY_SAML_REQUEST not in self.request.GET:
+ LOGGER.info("handle_saml_request: SAML payload missing")
+ return bad_request_message(
+ self.request, "The SAML request payload is missing."
+ )
+
+ try:
+ auth_n_request = AuthNRequestParser(self.provider).parse_detached(
+ self.request.GET[REQUEST_KEY_SAML_REQUEST],
+ self.request.GET.get(REQUEST_KEY_RELAY_STATE),
+ self.request.GET.get(REQUEST_KEY_SAML_SIGNATURE),
+ self.request.GET.get(REQUEST_KEY_SAML_SIG_ALG),
+ )
+ self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
+ except CannotHandleAssertion as exc:
+ LOGGER.info(exc)
+ return bad_request_message(self.request, str(exc))
+ return None
+
+
+@method_decorator(csrf_exempt, name="dispatch")
+class SAMLSSOBindingPOSTView(SAMLSSOView):
+ """SAML Handler for SSO/POST bindings"""
+
+ def check_saml_request(self) -> Optional[HttpRequest]:
+ """Handle POST bindings"""
+ if REQUEST_KEY_SAML_REQUEST not in self.request.POST:
+ LOGGER.info("check_saml_request: SAML payload missing")
+ return bad_request_message(
+ self.request, "The SAML request payload is missing."
+ )
+
+ try:
+ auth_n_request = AuthNRequestParser(self.provider).parse(
+ self.request.POST[REQUEST_KEY_SAML_REQUEST],
+ self.request.POST.get(REQUEST_KEY_RELAY_STATE),
+ )
+ self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
+ except CannotHandleAssertion as exc:
+ LOGGER.info(exc)
+ return bad_request_message(self.request, str(exc))
+ return None
+
+
+class SAMLSSOBindingInitView(SAMLSSOView):
+ """SAML Handler for for IdP Initiated login flows"""
+
+ def check_saml_request(self) -> Optional[HttpRequest]:
+ """Create SAML Response from scratch"""
+ LOGGER.debug(
+ "handle_saml_no_request: No SAML Request, using IdP-initiated flow."
+ )
+ auth_n_request = AuthNRequestParser(self.provider).idp_initiated()
+ self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
+
+
+# This View doesn't have a URL on purpose, as its called by the FlowExecutor
+class SAMLFlowFinalView(StageView):
+ """View used by FlowExecutor after all stages have passed. Logs the authorization,
+ and redirects to the SP (if REDIRECT is configured) or shows and auto-submit for
+ (if POST is configured)."""
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
+ provider: SAMLProvider = get_object_or_404(
+ SAMLProvider, pk=application.provider_id
+ )
+ # Log Application Authorization
+ Event.new(
+ EventAction.AUTHORIZE_APPLICATION,
+ authorized_application=application,
+ flow=self.executor.plan.flow_pk,
+ ).from_http(self.request)
+
+ if SESSION_KEY_AUTH_N_REQUEST not in self.request.session:
+ return self.executor.stage_invalid()
+
+ auth_n_request: AuthNRequest = self.request.session.pop(
+ SESSION_KEY_AUTH_N_REQUEST
+ )
+ response = AssertionProcessor(
+ provider, request, auth_n_request
+ ).build_response()
+
+ if provider.sp_binding == SAMLBindings.POST:
+ form_attrs = {
+ "ACSUrl": provider.acs_url,
+ REQUEST_KEY_SAML_RESPONSE: nice64(response),
+ }
+ if auth_n_request.relay_state:
+ form_attrs[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state
+ return render(
+ self.request,
+ "generic/autosubmit_form.html",
+ {
+ "url": provider.acs_url,
+ "title": _("Redirecting to %(app)s..." % {"app": application.name}),
+ "attrs": form_attrs,
+ },
+ )
+ if provider.sp_binding == SAMLBindings.REDIRECT:
+ url_args = {
+ REQUEST_KEY_SAML_RESPONSE: deflate_and_base64_encode(response),
+ }
+ if auth_n_request.relay_state:
+ url_args[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state
+ querystring = urlencode(url_args)
+ return redirect(f"{provider.acs_url}?{querystring}")
+ return bad_request_message(request, "Invalid sp_binding specified")
+
+
+class DescriptorDownloadView(View):
+ """Replies with the XML Metadata IDSSODescriptor."""
+
+ @staticmethod
+ def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
+ """Return rendered XML Metadata"""
+ return MetadataProcessor(provider, request).build_entity_descriptor()
+
+ def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
+ """Replies with the XML Metadata IDSSODescriptor."""
+ application = get_object_or_404(Application, slug=application_slug)
+ provider: SAMLProvider = get_object_or_404(
+ SAMLProvider, pk=application.provider_id
+ )
+ try:
+ metadata = DescriptorDownloadView.get_metadata(request, provider)
+ except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
+ return bad_request_message(
+ request, "Provider is not assigned to an application."
+ )
+ else:
+ response = HttpResponse(metadata, content_type="application/xml")
+ response[
+ "Content-Disposition"
+ ] = f'attachment; filename="{provider.name}_authentik_meta.xml"'
+ return response
diff --git a/passbook/recovery/__init__.py b/authentik/recovery/__init__.py
similarity index 100%
rename from passbook/recovery/__init__.py
rename to authentik/recovery/__init__.py
diff --git a/authentik/recovery/apps.py b/authentik/recovery/apps.py
new file mode 100644
index 000000000..e9f33fd6b
--- /dev/null
+++ b/authentik/recovery/apps.py
@@ -0,0 +1,11 @@
+"""authentik Recovery app config"""
+from django.apps import AppConfig
+
+
+class AuthentikRecoveryConfig(AppConfig):
+ """authentik Recovery app config"""
+
+ name = "authentik.recovery"
+ label = "authentik_recovery"
+ verbose_name = "authentik Recovery"
+ mountpoint = "recovery/"
diff --git a/passbook/recovery/management/__init__.py b/authentik/recovery/management/__init__.py
similarity index 100%
rename from passbook/recovery/management/__init__.py
rename to authentik/recovery/management/__init__.py
diff --git a/passbook/recovery/management/commands/__init__.py b/authentik/recovery/management/commands/__init__.py
similarity index 100%
rename from passbook/recovery/management/commands/__init__.py
rename to authentik/recovery/management/commands/__init__.py
diff --git a/authentik/recovery/management/commands/create_recovery_key.py b/authentik/recovery/management/commands/create_recovery_key.py
new file mode 100644
index 000000000..19ce7c950
--- /dev/null
+++ b/authentik/recovery/management/commands/create_recovery_key.py
@@ -0,0 +1,54 @@
+"""authentik recovery createkey command"""
+from datetime import timedelta
+from getpass import getuser
+
+from django.core.management.base import BaseCommand
+from django.urls import reverse
+from django.utils.timezone import now
+from django.utils.translation import gettext as _
+from structlog import get_logger
+
+from authentik.core.models import Token, TokenIntents, User
+
+LOGGER = get_logger()
+
+
+class Command(BaseCommand):
+ """Create Token used to recover access"""
+
+ help = _("Create a Key which can be used to restore access to authentik.")
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "duration",
+ default=1,
+ action="store",
+ help="How long the token is valid for (in years).",
+ )
+ parser.add_argument(
+ "user", action="store", help="Which user the Token gives access to."
+ )
+
+ def get_url(self, token: Token) -> str:
+ """Get full recovery link"""
+ return reverse("authentik_recovery:use-token", kwargs={"key": str(token.key)})
+
+ def handle(self, *args, **options):
+ """Create Token used to recover access"""
+ duration = int(options.get("duration", 1))
+ _now = now()
+ expiry = _now + timedelta(days=duration * 365.2425)
+ user = User.objects.get(username=options.get("user"))
+ token = Token.objects.create(
+ expires=expiry,
+ user=user,
+ intent=TokenIntents.INTENT_RECOVERY,
+ description=f"Recovery Token generated by {getuser()} on {_now}",
+ )
+ self.stdout.write(
+ (
+ f"Store this link safely, as it will allow"
+ f" anyone to access authentik as {user}."
+ )
+ )
+ self.stdout.write(self.get_url(token))
diff --git a/authentik/recovery/tests.py b/authentik/recovery/tests.py
new file mode 100644
index 000000000..f696c2c98
--- /dev/null
+++ b/authentik/recovery/tests.py
@@ -0,0 +1,34 @@
+"""recovery tests"""
+from io import StringIO
+
+from django.core.management import call_command
+from django.shortcuts import reverse
+from django.test import TestCase
+
+from authentik.core.models import Token, TokenIntents, User
+
+
+class TestRecovery(TestCase):
+ """recovery tests"""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username="recovery-test-user")
+
+ def test_create_key(self):
+ """Test creation of a new key"""
+ out = StringIO()
+ self.assertEqual(len(Token.objects.all()), 0)
+ call_command("create_recovery_key", "1", self.user.username, stdout=out)
+ token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user)
+ self.assertIn(token.key, out.getvalue())
+ self.assertEqual(len(Token.objects.all()), 1)
+
+ def test_recovery_view(self):
+ """Test recovery view"""
+ out = StringIO()
+ call_command("create_recovery_key", "1", self.user.username, stdout=out)
+ token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user)
+ self.client.get(
+ reverse("authentik_recovery:use-token", kwargs={"key": token.key})
+ )
+ self.assertEqual(int(self.client.session["_auth_user_id"]), token.user.pk)
diff --git a/authentik/recovery/urls.py b/authentik/recovery/urls.py
new file mode 100644
index 000000000..6e761442f
--- /dev/null
+++ b/authentik/recovery/urls.py
@@ -0,0 +1,9 @@
+"""recovery views"""
+
+from django.urls import path
+
+from authentik.recovery.views import UseTokenView
+
+urlpatterns = [
+ path("use-token//", UseTokenView.as_view(), name="use-token"),
+]
diff --git a/authentik/recovery/views.py b/authentik/recovery/views.py
new file mode 100644
index 000000000..b0c1978ce
--- /dev/null
+++ b/authentik/recovery/views.py
@@ -0,0 +1,24 @@
+"""recovery views"""
+from django.contrib import messages
+from django.contrib.auth import login
+from django.http import Http404, HttpRequest, HttpResponse
+from django.shortcuts import redirect
+from django.utils.translation import gettext as _
+from django.views import View
+
+from authentik.core.models import Token, TokenIntents
+
+
+class UseTokenView(View):
+ """Use token to login"""
+
+ def get(self, request: HttpRequest, key: str) -> HttpResponse:
+ """Check if token exists, log user in and delete token."""
+ tokens = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_RECOVERY)
+ if not tokens.exists():
+ raise Http404
+ token = tokens.first()
+ login(request, token.user, backend="django.contrib.auth.backends.ModelBackend")
+ token.delete()
+ messages.warning(request, _("Used recovery-link to authenticate."))
+ return redirect("authentik_core:shell")
diff --git a/passbook/root/__init__.py b/authentik/root/__init__.py
similarity index 100%
rename from passbook/root/__init__.py
rename to authentik/root/__init__.py
diff --git a/authentik/root/asgi.py b/authentik/root/asgi.py
new file mode 100644
index 000000000..454daffba
--- /dev/null
+++ b/authentik/root/asgi.py
@@ -0,0 +1,148 @@
+"""
+ASGI config for authentik project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
+"""
+import typing
+from time import time
+from typing import Any, ByteString, Dict
+
+import django
+from asgiref.compatibility import guarantee_single_callable
+from channels.routing import ProtocolTypeRouter, URLRouter
+from defusedxml import defuse_stdlib
+from django.core.asgi import get_asgi_application
+from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
+from structlog import get_logger
+
+# DJANGO_SETTINGS_MODULE is set in gunicorn.conf.py
+
+defuse_stdlib()
+django.setup()
+
+# pylint: disable=wrong-import-position
+from authentik.root import websocket # noqa # isort:skip
+
+
+# See https://github.com/encode/starlette/blob/master/starlette/types.py
+Scope = typing.MutableMapping[str, typing.Any]
+Message = typing.MutableMapping[str, typing.Any]
+
+Receive = typing.Callable[[], typing.Awaitable[Message]]
+Send = typing.Callable[[Message], typing.Awaitable[None]]
+
+ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]]
+
+ASGI_IP_HEADERS = (
+ b"x-forwarded-for",
+ b"x-real-ip",
+)
+
+LOGGER = get_logger("authentik.asgi")
+
+
+class ASGILoggerMiddleware:
+ """Main ASGI Logger middleware, starts an ASGILogger for each request"""
+
+ def __init__(self, app: ASGIApp) -> None:
+ self.app = app
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
+ responder = ASGILogger(self.app)
+ await responder(scope, receive, send)
+ return
+
+
+class ASGILogger:
+ """ASGI Logger, instantiated for each request"""
+
+ app: ASGIApp
+
+ scope: Scope
+ headers: Dict[ByteString, Any]
+
+ status_code: int
+ start: float
+ content_length: int
+
+ def __init__(self, app: ASGIApp):
+ self.app = app
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ self.scope = scope
+ self.content_length = 0
+ self.headers = dict(scope.get("headers", []))
+
+ async def send_hooked(message: Message) -> None:
+ """Hooked send method, which records status code and content-length, and for the final
+ requests logs it"""
+ headers = dict(message.get("headers", []))
+
+ if "status" in message:
+ self.status_code = message["status"]
+
+ if b"Content-Length" in headers:
+ self.content_length += int(headers.get(b"Content-Length", b"0"))
+
+ if message["type"] == "http.response.body" and not message.get(
+ "more_body", None
+ ):
+ runtime = int((time() - self.start) * 10 ** 6)
+ self.log(runtime)
+ await send(message)
+
+ if self.headers.get(b"host", b"") == b"authentik-healthcheck-host":
+ # Don't log healthcheck/readiness requests
+ await send({"type": "http.response.start", "status": 204, "headers": []})
+ await send({"type": "http.response.body", "body": ""})
+ return
+
+ self.start = time()
+ if scope["type"] == "lifespan":
+ # https://code.djangoproject.com/ticket/31508
+ # https://github.com/encode/uvicorn/issues/266
+ return
+ await self.app(scope, receive, send_hooked)
+
+ def _get_ip(self) -> str:
+ client_ip = None
+ for header in ASGI_IP_HEADERS:
+ if header in self.headers:
+ client_ip = self.headers[header].decode()
+ if not client_ip:
+ client_ip, _ = self.scope.get("client", ("", 0))
+ # Check if header has multiple values, and use the first one
+ return client_ip.split(", ")[0]
+
+ def log(self, runtime: float):
+ """Outpot access logs in a structured format"""
+ host = self._get_ip()
+ query_string = ""
+ if self.scope.get("query_string", b"") != b"":
+ query_string = f"?{self.scope.get('query_string').decode()}"
+ LOGGER.info(
+ f"{self.scope.get('path', '')}{query_string}",
+ host=host,
+ method=self.scope.get("method", ""),
+ scheme=self.scope.get("scheme", ""),
+ status=self.status_code,
+ size=self.content_length / 1000 if self.content_length > 0 else "-",
+ runtime=runtime,
+ )
+
+
+application = ASGILogger(
+ guarantee_single_callable(
+ SentryAsgiMiddleware(
+ ProtocolTypeRouter(
+ {
+ "http": get_asgi_application(),
+ "websocket": URLRouter(websocket.websocket_urlpatterns),
+ }
+ )
+ )
+ )
+)
diff --git a/authentik/root/celery.py b/authentik/root/celery.py
new file mode 100644
index 000000000..28443ebab
--- /dev/null
+++ b/authentik/root/celery.py
@@ -0,0 +1,55 @@
+"""authentik core celery"""
+import os
+from logging.config import dictConfig
+
+from celery import Celery
+from celery.signals import after_task_publish, setup_logging, task_postrun, task_prerun
+from django.conf import settings
+from structlog import get_logger
+
+# set the default Django settings module for the 'celery' program.
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
+
+LOGGER = get_logger()
+CELERY_APP = Celery("authentik")
+
+
+# pylint: disable=unused-argument
+@setup_logging.connect
+def config_loggers(*args, **kwags):
+ """Apply logging settings from settings.py to celery"""
+ dictConfig(settings.LOGGING)
+
+
+# pylint: disable=unused-argument
+@after_task_publish.connect
+def after_task_publish(sender=None, headers=None, body=None, **kwargs):
+ """Log task_id after it was published"""
+ info = headers if "task" in headers else body
+ LOGGER.debug(
+ "Task published", task_id=info.get("id", ""), task_name=info.get("task", "")
+ )
+
+
+# pylint: disable=unused-argument
+@task_prerun.connect
+def task_prerun(task_id, task, *args, **kwargs):
+ """Log task_id on worker"""
+ LOGGER.debug("Task started", task_id=task_id, task_name=task.__name__)
+
+
+# pylint: disable=unused-argument
+@task_postrun.connect
+def task_postrun(task_id, task, *args, retval=None, state=None, **kwargs):
+ """Log task_id on worker"""
+ LOGGER.debug("Task finished", task_id=task_id, task_name=task.__name__, state=state)
+
+
+# Using a string here means the worker doesn't have to serialize
+# the configuration object to child processes.
+# - namespace='CELERY' means all celery-related configuration keys
+# should have a `CELERY_` prefix.
+CELERY_APP.config_from_object(settings, namespace="CELERY")
+
+# Load task modules from all registered Django app configs.
+CELERY_APP.autodiscover_tasks()
diff --git a/passbook/root/messages/__init__.py b/authentik/root/messages/__init__.py
similarity index 100%
rename from passbook/root/messages/__init__.py
rename to authentik/root/messages/__init__.py
diff --git a/passbook/root/messages/consumer.py b/authentik/root/messages/consumer.py
similarity index 100%
rename from passbook/root/messages/consumer.py
rename to authentik/root/messages/consumer.py
diff --git a/passbook/root/messages/storage.py b/authentik/root/messages/storage.py
similarity index 100%
rename from passbook/root/messages/storage.py
rename to authentik/root/messages/storage.py
diff --git a/authentik/root/monitoring.py b/authentik/root/monitoring.py
new file mode 100644
index 000000000..1ffa0d87f
--- /dev/null
+++ b/authentik/root/monitoring.py
@@ -0,0 +1,25 @@
+"""Metrics view"""
+from base64 import b64encode
+
+from django.conf import settings
+from django.http import HttpRequest, HttpResponse
+from django.views import View
+from django_prometheus.exports import ExportToDjangoView
+
+
+class MetricsView(View):
+ """Wrapper around ExportToDjangoView, using http-basic auth"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ """Check for HTTP-Basic auth"""
+ auth_header = request.META.get("HTTP_AUTHORIZATION", "")
+ auth_type, _, given_credentials = auth_header.partition(" ")
+ credentials = f"monitor:{settings.SECRET_KEY}"
+ expected = b64encode(str.encode(credentials)).decode()
+
+ if auth_type != "Basic" or given_credentials != expected:
+ response = HttpResponse(status=401)
+ response["WWW-Authenticate"] = 'Basic realm="authentik-monitoring"'
+ return response
+
+ return ExportToDjangoView(request)
diff --git a/authentik/root/settings.py b/authentik/root/settings.py
new file mode 100644
index 000000000..9f90076f1
--- /dev/null
+++ b/authentik/root/settings.py
@@ -0,0 +1,460 @@
+"""
+Django settings for authentik project.
+
+Generated by 'django-admin startproject' using Django 2.1.3.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/2.1/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/2.1/ref/settings/
+"""
+
+import importlib
+import os
+import sys
+from json import dumps
+from time import time
+
+import structlog
+from celery.schedules import crontab
+from sentry_sdk import init as sentry_init
+from sentry_sdk.integrations.celery import CeleryIntegration
+from sentry_sdk.integrations.django import DjangoIntegration
+from sentry_sdk.integrations.redis import RedisIntegration
+
+from authentik import __version__
+from authentik.core.middleware import structlog_add_request_id
+from authentik.lib.config import CONFIG
+from authentik.lib.logging import add_common_fields, add_process_id
+from authentik.lib.sentry import before_send
+
+
+def j_print(event: str, log_level: str = "info", **kwargs):
+ """Print event in the same format as structlog with JSON.
+ Used before structlog is configured."""
+ data = {
+ "event": event,
+ "level": log_level,
+ "logger": __name__,
+ "timestamp": time(),
+ }
+ data.update(**kwargs)
+ print(dumps(data), file=sys.stderr)
+
+
+LOGGER = structlog.get_logger()
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+STATIC_ROOT = BASE_DIR + "/static"
+STATICFILES_DIRS = [BASE_DIR + "/web"]
+MEDIA_ROOT = BASE_DIR + "/media"
+
+SECRET_KEY = CONFIG.y(
+ "secret_key", "9$@r!d^1^jrn#fk#1#@ks#9&i$^s#1)_13%$rwjrhd=e8jfi_s"
+) # noqa Debug
+
+DEBUG = CONFIG.y_bool("debug")
+INTERNAL_IPS = ["127.0.0.1"]
+ALLOWED_HOSTS = ["*"]
+SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
+
+LOGIN_URL = "authentik_flows:default-authentication"
+
+# Custom user model
+AUTH_USER_MODEL = "authentik_core.User"
+
+_cookie_suffix = "_debug" if DEBUG else ""
+CSRF_COOKIE_NAME = "authentik_csrf"
+LANGUAGE_COOKIE_NAME = f"authentik_language{_cookie_suffix}"
+SESSION_COOKIE_NAME = f"authentik_session{_cookie_suffix}"
+
+AUTHENTICATION_BACKENDS = [
+ "django.contrib.auth.backends.ModelBackend",
+ "guardian.backends.ObjectPermissionBackend",
+]
+
+# Application definition
+INSTALLED_APPS = [
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "django.contrib.humanize",
+ "authentik.admin.apps.AuthentikAdminConfig",
+ "authentik.api.apps.AuthentikAPIConfig",
+ "authentik.audit.apps.AuthentikAuditConfig",
+ "authentik.crypto.apps.AuthentikCryptoConfig",
+ "authentik.flows.apps.AuthentikFlowsConfig",
+ "authentik.outposts.apps.AuthentikOutpostConfig",
+ "authentik.lib.apps.AuthentikLibConfig",
+ "authentik.policies.apps.AuthentikPoliciesConfig",
+ "authentik.policies.dummy.apps.AuthentikPolicyDummyConfig",
+ "authentik.policies.expiry.apps.AuthentikPolicyExpiryConfig",
+ "authentik.policies.expression.apps.AuthentikPolicyExpressionConfig",
+ "authentik.policies.hibp.apps.AuthentikPolicyHIBPConfig",
+ "authentik.policies.password.apps.AuthentikPoliciesPasswordConfig",
+ "authentik.policies.group_membership.apps.AuthentikPoliciesGroupMembershipConfig",
+ "authentik.policies.reputation.apps.AuthentikPolicyReputationConfig",
+ "authentik.providers.proxy.apps.AuthentikProviderProxyConfig",
+ "authentik.providers.oauth2.apps.AuthentikProviderOAuth2Config",
+ "authentik.providers.saml.apps.AuthentikProviderSAMLConfig",
+ "authentik.recovery.apps.AuthentikRecoveryConfig",
+ "authentik.sources.ldap.apps.AuthentikSourceLDAPConfig",
+ "authentik.sources.oauth.apps.AuthentikSourceOAuthConfig",
+ "authentik.sources.saml.apps.AuthentikSourceSAMLConfig",
+ "authentik.stages.captcha.apps.AuthentikStageCaptchaConfig",
+ "authentik.stages.consent.apps.AuthentikStageConsentConfig",
+ "authentik.stages.dummy.apps.AuthentikStageDummyConfig",
+ "authentik.stages.email.apps.AuthentikStageEmailConfig",
+ "authentik.stages.prompt.apps.AuthentikStagPromptConfig",
+ "authentik.stages.identification.apps.AuthentikStageIdentificationConfig",
+ "authentik.stages.invitation.apps.AuthentikStageUserInvitationConfig",
+ "authentik.stages.user_delete.apps.AuthentikStageUserDeleteConfig",
+ "authentik.stages.user_login.apps.AuthentikStageUserLoginConfig",
+ "authentik.stages.user_logout.apps.AuthentikStageUserLogoutConfig",
+ "authentik.stages.user_write.apps.AuthentikStageUserWriteConfig",
+ "authentik.stages.otp_static.apps.AuthentikStageOTPStaticConfig",
+ "authentik.stages.otp_time.apps.AuthentikStageOTPTimeConfig",
+ "authentik.stages.otp_validate.apps.AuthentikStageOTPValidateConfig",
+ "authentik.stages.password.apps.AuthentikStagePasswordConfig",
+ "rest_framework",
+ "django_filters",
+ "drf_yasg2",
+ "guardian",
+ "django_prometheus",
+ "channels",
+ "dbbackup",
+]
+
+GUARDIAN_MONKEY_PATCH = False
+
+SWAGGER_SETTINGS = {
+ "DEFAULT_INFO": "authentik.api.v2.urls.info",
+ "SECURITY_DEFINITIONS": {
+ "token": {"type": "apiKey", "name": "Authorization", "in": "header"}
+ },
+}
+
+REST_FRAMEWORK = {
+ "DEFAULT_PAGINATION_CLASS": "authentik.api.pagination.Pagination",
+ "PAGE_SIZE": 100,
+ "DEFAULT_FILTER_BACKENDS": [
+ "rest_framework_guardian.filters.ObjectPermissionsFilter",
+ "django_filters.rest_framework.DjangoFilterBackend",
+ "rest_framework.filters.OrderingFilter",
+ "rest_framework.filters.SearchFilter",
+ ],
+ "DEFAULT_PERMISSION_CLASSES": (
+ "rest_framework.permissions.DjangoObjectPermissions",
+ ),
+ "DEFAULT_AUTHENTICATION_CLASSES": (
+ "authentik.api.auth.AuthentikTokenAuthentication",
+ "rest_framework.authentication.SessionAuthentication",
+ ),
+}
+
+CACHES = {
+ "default": {
+ "BACKEND": "django_redis.cache.RedisCache",
+ "LOCATION": (
+ f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379"
+ f"/{CONFIG.y('redis.cache_db')}"
+ ),
+ "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
+ }
+}
+DJANGO_REDIS_IGNORE_EXCEPTIONS = True
+DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
+SESSION_ENGINE = "django.contrib.sessions.backends.cache"
+SESSION_CACHE_ALIAS = "default"
+SESSION_COOKIE_SAMESITE = "lax"
+
+MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage"
+
+MIDDLEWARE = [
+ "django_prometheus.middleware.PrometheusBeforeMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "authentik.core.middleware.RequestIDMiddleware",
+ "authentik.audit.middleware.AuditMiddleware",
+ "django.middleware.security.SecurityMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+ "authentik.core.middleware.ImpersonateMiddleware",
+ "django_prometheus.middleware.PrometheusAfterMiddleware",
+]
+
+ROOT_URLCONF = "authentik.root.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ "authentik.lib.config.context_processor",
+ ],
+ },
+ },
+]
+
+ASGI_APPLICATION = "authentik.root.asgi.application"
+
+CHANNEL_LAYERS = {
+ "default": {
+ "BACKEND": "channels_redis.core.RedisChannelLayer",
+ "CONFIG": {
+ "hosts": [
+ f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379"
+ f"/{CONFIG.y('redis.ws_db')}"
+ ],
+ },
+ },
+}
+
+
+# Database
+# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql",
+ "HOST": CONFIG.y("postgresql.host"),
+ "NAME": CONFIG.y("postgresql.name"),
+ "USER": CONFIG.y("postgresql.user"),
+ "PASSWORD": CONFIG.y("postgresql.password"),
+ }
+}
+
+# Password validation
+# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+ },
+ {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
+ {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
+ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/2.1/topics/i18n/
+
+LANGUAGE_CODE = "en-us"
+
+TIME_ZONE = "UTC"
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Celery settings
+# Add a 10 minute timeout to all Celery tasks.
+CELERY_TASK_SOFT_TIME_LIMIT = 600
+CELERY_BEAT_SCHEDULE = {
+ "clean_expired_models": {
+ "task": "authentik.core.tasks.clean_expired_models",
+ "schedule": crontab(minute="*/5"),
+ "options": {"queue": "authentik_scheduled"},
+ },
+ "db_backup": {
+ "task": "authentik.core.tasks.backup_database",
+ "schedule": crontab(minute=0, hour=0),
+ "options": {"queue": "authentik_scheduled"},
+ },
+}
+CELERY_TASK_CREATE_MISSING_QUEUES = True
+CELERY_TASK_DEFAULT_QUEUE = "authentik"
+CELERY_BROKER_URL = (
+ f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}"
+ f":6379/{CONFIG.y('redis.message_queue_db')}"
+)
+CELERY_RESULT_BACKEND = (
+ f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}"
+ f":6379/{CONFIG.y('redis.message_queue_db')}"
+)
+
+# Database backup
+DBBACKUP_STORAGE = "django.core.files.storage.FileSystemStorage"
+DBBACKUP_STORAGE_OPTIONS = {"location": "./backups" if DEBUG else "/backups"}
+DBBACKUP_CONNECTOR_MAPPING = {
+ "django_prometheus.db.backends.postgresql": "dbbackup.db.postgresql.PgDumpConnector"
+}
+if CONFIG.y("postgresql.s3_backup"):
+ DBBACKUP_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
+ DBBACKUP_STORAGE_OPTIONS = {
+ "access_key": CONFIG.y("postgresql.s3_backup.access_key"),
+ "secret_key": CONFIG.y("postgresql.s3_backup.secret_key"),
+ "bucket_name": CONFIG.y("postgresql.s3_backup.bucket"),
+ "region_name": CONFIG.y("postgresql.s3_backup.region", "eu-central-1"),
+ "default_acl": "private",
+ "endpoint_url": CONFIG.y("postgresql.s3_backup.host"),
+ }
+ j_print(
+ "Database backup to S3 is configured.",
+ host=CONFIG.y("postgresql.s3_backup.host"),
+ )
+
+# Sentry integration
+_ERROR_REPORTING = CONFIG.y_bool("error_reporting.enabled", False)
+if not DEBUG and _ERROR_REPORTING:
+ sentry_init(
+ dsn="https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8",
+ integrations=[
+ DjangoIntegration(transaction_style="function_name"),
+ CeleryIntegration(),
+ RedisIntegration(),
+ ],
+ before_send=before_send,
+ release="authentik@%s" % __version__,
+ traces_sample_rate=0.6,
+ environment=CONFIG.y("error_reporting.environment", "customer"),
+ send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False),
+ )
+ j_print(
+ "Error reporting is enabled.",
+ env=CONFIG.y("error_reporting.environment", "customer"),
+ )
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/2.1/howto/static-files/
+
+STATIC_URL = "/static/"
+MEDIA_URL = "/media/"
+
+
+structlog.configure_once(
+ processors=[
+ structlog.stdlib.add_log_level,
+ structlog.stdlib.add_logger_name,
+ add_process_id,
+ add_common_fields(CONFIG.y("error_reporting.environment", "customer")),
+ structlog_add_request_id,
+ structlog.stdlib.PositionalArgumentsFormatter(),
+ structlog.processors.TimeStamper(),
+ structlog.processors.StackInfoRenderer(),
+ structlog.processors.format_exc_info,
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
+ ],
+ context_class=structlog.threadlocal.wrap_dict(dict),
+ logger_factory=structlog.stdlib.LoggerFactory(),
+ wrapper_class=structlog.stdlib.BoundLogger,
+ cache_logger_on_first_use=True,
+)
+
+LOG_PRE_CHAIN = [
+ # Add the log level and a timestamp to the event_dict if the log entry
+ # is not from structlog.
+ structlog.stdlib.add_log_level,
+ structlog.stdlib.add_logger_name,
+ structlog.processors.TimeStamper(),
+ structlog.processors.StackInfoRenderer(),
+ structlog.processors.format_exc_info,
+]
+
+LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "formatters": {
+ "plain": {
+ "()": structlog.stdlib.ProcessorFormatter,
+ "processor": structlog.processors.JSONRenderer(sort_keys=True),
+ "foreign_pre_chain": LOG_PRE_CHAIN,
+ },
+ "colored": {
+ "()": structlog.stdlib.ProcessorFormatter,
+ "processor": structlog.dev.ConsoleRenderer(colors=DEBUG),
+ "foreign_pre_chain": LOG_PRE_CHAIN,
+ },
+ },
+ "handlers": {
+ "console": {
+ "level": "DEBUG",
+ "class": "logging.StreamHandler",
+ "formatter": "colored" if DEBUG else "plain",
+ },
+ },
+ "loggers": {},
+}
+
+TEST = False
+TEST_RUNNER = "authentik.root.test_runner.PytestTestRunner"
+LOG_LEVEL = CONFIG.y("log_level").upper()
+
+
+_LOGGING_HANDLER_MAP = {
+ "": LOG_LEVEL,
+ "authentik": LOG_LEVEL,
+ "django": "WARNING",
+ "celery": "WARNING",
+ "selenium": "WARNING",
+ "grpc": LOG_LEVEL,
+ "docker": "WARNING",
+ "urllib3": "WARNING",
+ "websockets": "WARNING",
+ "daphne": "WARNING",
+ "dbbackup": "ERROR",
+ "kubernetes": "INFO",
+ "asyncio": "WARNING",
+}
+for handler_name, level in _LOGGING_HANDLER_MAP.items():
+ # pyright: reportGeneralTypeIssues=false
+ LOGGING["loggers"][handler_name] = {
+ "handlers": ["console"],
+ "level": level,
+ "propagate": False,
+ }
+
+
+_DISALLOWED_ITEMS = [
+ "INSTALLED_APPS",
+ "MIDDLEWARE",
+ "AUTHENTICATION_BACKENDS",
+ "CELERY_BEAT_SCHEDULE",
+]
+# Load subapps's INSTALLED_APPS
+for _app in INSTALLED_APPS:
+ if _app.startswith("authentik"):
+ if "apps" in _app:
+ _app = ".".join(_app.split(".")[:-2])
+ try:
+ app_settings = importlib.import_module("%s.settings" % _app)
+ INSTALLED_APPS.extend(getattr(app_settings, "INSTALLED_APPS", []))
+ MIDDLEWARE.extend(getattr(app_settings, "MIDDLEWARE", []))
+ AUTHENTICATION_BACKENDS.extend(
+ getattr(app_settings, "AUTHENTICATION_BACKENDS", [])
+ )
+ CELERY_BEAT_SCHEDULE.update(
+ getattr(app_settings, "CELERY_BEAT_SCHEDULE", {})
+ )
+ for _attr in dir(app_settings):
+ if not _attr.startswith("__") and _attr not in _DISALLOWED_ITEMS:
+ globals()[_attr] = getattr(app_settings, _attr)
+ except ImportError:
+ pass
+
+if DEBUG:
+ INSTALLED_APPS.append("debug_toolbar")
+ MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
+ CELERY_TASK_ALWAYS_EAGER = True
+
+INSTALLED_APPS.append("authentik.core.apps.AuthentikCoreConfig")
+
+j_print("Booting authentik", version=__version__)
diff --git a/authentik/root/test_runner.py b/authentik/root/test_runner.py
new file mode 100644
index 000000000..7a79577f0
--- /dev/null
+++ b/authentik/root/test_runner.py
@@ -0,0 +1,38 @@
+"""Integrate ./manage.py test with pytest"""
+from django.conf import settings
+
+from authentik.lib.config import CONFIG
+
+
+class PytestTestRunner:
+ """Runs pytest to discover and run tests."""
+
+ def __init__(self, verbosity=1, failfast=False, keepdb=False, **_):
+ self.verbosity = verbosity
+ self.failfast = failfast
+ self.keepdb = keepdb
+ settings.TEST = True
+ settings.CELERY_TASK_ALWAYS_EAGER = True
+ CONFIG.raw.get("authentik")["avatars"] = "none"
+
+ def run_tests(self, test_labels):
+ """Run pytest and return the exitcode.
+
+ It translates some of Django's test command option to pytest's.
+ """
+ import pytest
+
+ argv = []
+ if self.verbosity == 0:
+ argv.append("--quiet")
+ if self.verbosity == 2:
+ argv.append("--verbose")
+ if self.verbosity == 3:
+ argv.append("-vv")
+ if self.failfast:
+ argv.append("--exitfirst")
+ if self.keepdb:
+ argv.append("--reuse-db")
+
+ argv.extend(test_labels)
+ return pytest.main(argv)
diff --git a/passbook/root/tests.py b/authentik/root/tests.py
similarity index 100%
rename from passbook/root/tests.py
rename to authentik/root/tests.py
diff --git a/authentik/root/urls.py b/authentik/root/urls.py
new file mode 100644
index 000000000..c5a427984
--- /dev/null
+++ b/authentik/root/urls.py
@@ -0,0 +1,73 @@
+"""authentik URL Configuration"""
+from django.conf import settings
+from django.conf.urls.static import static
+from django.contrib import admin
+from django.urls import include, path
+from django.views.generic import RedirectView
+from django.views.i18n import JavaScriptCatalog
+from structlog import get_logger
+
+from authentik.core.views import error
+from authentik.lib.utils.reflection import get_apps
+from authentik.root.monitoring import MetricsView
+
+LOGGER = get_logger()
+admin.autodiscover()
+admin.site.login = RedirectView.as_view(
+ pattern_name="authentik_flows:default-authentication"
+)
+admin.site.logout = RedirectView.as_view(
+ pattern_name="authentik_flows:default-invalidation"
+)
+
+handler400 = error.BadRequestView.as_view()
+handler403 = error.ForbiddenView.as_view()
+handler404 = error.NotFoundView.as_view()
+handler500 = error.ServerErrorView.as_view()
+
+urlpatterns = []
+
+for _authentik_app in get_apps():
+ mountpoints = None
+ base_url_module = _authentik_app.name + ".urls"
+ if hasattr(_authentik_app, "mountpoint"):
+ mountpoint = getattr(_authentik_app, "mountpoint")
+ mountpoints = {base_url_module: mountpoint}
+ if hasattr(_authentik_app, "mountpoints"):
+ mountpoints = getattr(_authentik_app, "mountpoints")
+ if not mountpoints:
+ continue
+ for module, mountpoint in mountpoints.items():
+ namespace = _authentik_app.label + module.replace(base_url_module, "")
+ _path = path(
+ mountpoint,
+ include(
+ (module, _authentik_app.label),
+ namespace=namespace,
+ ),
+ )
+ urlpatterns.append(_path)
+ LOGGER.debug(
+ "Mounted URLs",
+ app_name=_authentik_app.name,
+ mountpoint=mountpoint,
+ namespace=namespace,
+ )
+
+urlpatterns += [
+ path("administration/django/", admin.site.urls),
+ path("metrics/", MetricsView.as_view(), name="metrics"),
+ path("-/jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
+]
+
+if settings.DEBUG:
+ import debug_toolbar
+
+ urlpatterns = (
+ [
+ path("-/debug/", include(debug_toolbar.urls)),
+ ]
+ + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+ + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+ + urlpatterns
+ )
diff --git a/authentik/root/websocket.py b/authentik/root/websocket.py
new file mode 100644
index 000000000..d53b52a12
--- /dev/null
+++ b/authentik/root/websocket.py
@@ -0,0 +1,11 @@
+"""root Websocket URLS"""
+from channels.auth import AuthMiddlewareStack
+from django.urls import path
+
+from authentik.outposts.channels import OutpostConsumer
+from authentik.root.messages.consumer import MessageConsumer
+
+websocket_urlpatterns = [
+ path("ws/outpost//", OutpostConsumer.as_asgi()),
+ path("ws/client/", AuthMiddlewareStack(MessageConsumer.as_asgi())),
+]
diff --git a/passbook/sources/__init__.py b/authentik/sources/__init__.py
similarity index 100%
rename from passbook/sources/__init__.py
rename to authentik/sources/__init__.py
diff --git a/passbook/sources/ldap/__init__.py b/authentik/sources/ldap/__init__.py
similarity index 100%
rename from passbook/sources/ldap/__init__.py
rename to authentik/sources/ldap/__init__.py
diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py
new file mode 100644
index 000000000..ef622ac35
--- /dev/null
+++ b/authentik/sources/ldap/api.py
@@ -0,0 +1,54 @@
+"""Source API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
+from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
+
+
+class LDAPSourceSerializer(ModelSerializer):
+ """LDAP Source Serializer"""
+
+ class Meta:
+ model = LDAPSource
+ fields = SOURCE_SERIALIZER_FIELDS + [
+ "server_uri",
+ "bind_cn",
+ "bind_password",
+ "start_tls",
+ "base_dn",
+ "additional_user_dn",
+ "additional_group_dn",
+ "user_object_filter",
+ "group_object_filter",
+ "user_group_membership_field",
+ "object_uniqueness_field",
+ "sync_users",
+ "sync_users_password",
+ "sync_groups",
+ "sync_parent_group",
+ "property_mappings",
+ ]
+ extra_kwargs = {"bind_password": {"write_only": True}}
+
+
+class LDAPPropertyMappingSerializer(ModelSerializer):
+ """LDAP PropertyMapping Serializer"""
+
+ class Meta:
+ model = LDAPPropertyMapping
+ fields = ["pk", "name", "expression", "object_field"]
+
+
+class LDAPSourceViewSet(ModelViewSet):
+ """LDAP Source Viewset"""
+
+ queryset = LDAPSource.objects.all()
+ serializer_class = LDAPSourceSerializer
+
+
+class LDAPPropertyMappingViewSet(ModelViewSet):
+ """LDAP PropertyMapping Viewset"""
+
+ queryset = LDAPPropertyMapping.objects.all()
+ serializer_class = LDAPPropertyMappingSerializer
diff --git a/authentik/sources/ldap/apps.py b/authentik/sources/ldap/apps.py
new file mode 100644
index 000000000..c6bde458a
--- /dev/null
+++ b/authentik/sources/ldap/apps.py
@@ -0,0 +1,15 @@
+"""authentik ldap source config"""
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class AuthentikSourceLDAPConfig(AppConfig):
+ """Authentik ldap app config"""
+
+ name = "authentik.sources.ldap"
+ label = "authentik_sources_ldap"
+ verbose_name = "authentik Sources.LDAP"
+
+ def ready(self):
+ import_module("authentik.sources.ldap.signals")
diff --git a/authentik/sources/ldap/auth.py b/authentik/sources/ldap/auth.py
new file mode 100644
index 000000000..aa5347176
--- /dev/null
+++ b/authentik/sources/ldap/auth.py
@@ -0,0 +1,76 @@
+"""authentik LDAP Authentication Backend"""
+from typing import Optional
+
+import ldap3
+from django.contrib.auth.backends import ModelBackend
+from django.http import HttpRequest
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.sources.ldap.models import LDAPSource
+
+LOGGER = get_logger()
+
+
+class LDAPBackend(ModelBackend):
+ """Authenticate users against LDAP Server"""
+
+ def authenticate(self, request: HttpRequest, **kwargs):
+ """Try to authenticate a user via ldap"""
+ if "password" not in kwargs:
+ return None
+ for source in LDAPSource.objects.filter(enabled=True):
+ LOGGER.debug("LDAP Auth attempt", source=source)
+ user = self.auth_user(source, **kwargs)
+ if user:
+ return user
+ return None
+
+ def auth_user(
+ self, source: LDAPSource, password: str, **filters: str
+ ) -> Optional[User]:
+ """Try to bind as either user_dn or mail with password.
+ Returns True on success, otherwise False"""
+ users = User.objects.filter(**filters)
+ if not users.exists():
+ return None
+ user: User = users.first()
+ if "distinguishedName" not in user.attributes:
+ LOGGER.debug(
+ "User doesn't have DN set, assuming not LDAP imported.", user=user
+ )
+ return None
+ # Either has unusable password,
+ # or has a password, but couldn't be authenticated by ModelBackend.
+ # This means we check with a bind to see if the LDAP password has changed
+ if self.auth_user_by_bind(source, user, password):
+ # Password given successfully binds to LDAP, so we save it in our Database
+ LOGGER.debug("Updating user's password in DB", user=user)
+ user.set_password(password, signal=False)
+ user.save()
+ return user
+ # Password doesn't match
+ LOGGER.debug("Failed to bind, password invalid")
+ return None
+
+ def auth_user_by_bind(
+ self, source: LDAPSource, user: User, password: str
+ ) -> Optional[User]:
+ """Attempt authentication by binding to the LDAP server as `user`. This
+ method should be avoided as its slow to do the bind."""
+ # Try to bind as new user
+ LOGGER.debug("Attempting Binding as user", user=user)
+ try:
+ temp_connection = ldap3.Connection(
+ source.connection.server,
+ user=user.attributes.get("distinguishedName"),
+ password=password,
+ raise_exceptions=True,
+ )
+ temp_connection.bind()
+ return user
+ except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
+ LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
+ except ldap3.core.exceptions.LDAPException as exception:
+ LOGGER.warning(exception)
+ return None
diff --git a/authentik/sources/ldap/forms.py b/authentik/sources/ldap/forms.py
new file mode 100644
index 000000000..d78bf6a65
--- /dev/null
+++ b/authentik/sources/ldap/forms.py
@@ -0,0 +1,83 @@
+"""authentik LDAP Forms"""
+
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from authentik.admin.fields import CodeMirrorWidget
+from authentik.core.expression import PropertyMappingEvaluator
+from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
+
+
+class LDAPSourceForm(forms.ModelForm):
+ """LDAPSource Form"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["property_mappings"].queryset = LDAPPropertyMapping.objects.all()
+
+ class Meta:
+
+ model = LDAPSource
+ fields = [
+ # we don't use all common fields, as we don't use flows for this
+ "name",
+ "slug",
+ "enabled",
+ # -- start of our custom fields
+ "server_uri",
+ "start_tls",
+ "bind_cn",
+ "bind_password",
+ "base_dn",
+ "sync_users",
+ "sync_users_password",
+ "sync_groups",
+ "property_mappings",
+ "additional_user_dn",
+ "additional_group_dn",
+ "user_object_filter",
+ "group_object_filter",
+ "user_group_membership_field",
+ "object_uniqueness_field",
+ "sync_parent_group",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "server_uri": forms.TextInput(),
+ "bind_cn": forms.TextInput(),
+ "bind_password": forms.TextInput(),
+ "base_dn": forms.TextInput(),
+ "additional_user_dn": forms.TextInput(),
+ "additional_group_dn": forms.TextInput(),
+ "user_object_filter": forms.TextInput(),
+ "group_object_filter": forms.TextInput(),
+ "user_group_membership_field": forms.TextInput(),
+ "object_uniqueness_field": forms.TextInput(),
+ }
+
+
+class LDAPPropertyMappingForm(forms.ModelForm):
+ """LDAP Property Mapping form"""
+
+ template_name = "ldap/property_mapping_form.html"
+
+ def clean_expression(self):
+ """Test Syntax"""
+ expression = self.cleaned_data.get("expression")
+ evaluator = PropertyMappingEvaluator()
+ evaluator.validate(expression)
+ return expression
+
+ class Meta:
+
+ model = LDAPPropertyMapping
+ fields = ["name", "object_field", "expression"]
+ widgets = {
+ "name": forms.TextInput(),
+ "ldap_property": forms.TextInput(),
+ "object_field": forms.TextInput(),
+ "expression": CodeMirrorWidget(mode="python"),
+ }
+ help_texts = {
+ "object_field": _("Field of the user object this value is written to.")
+ }
diff --git a/authentik/sources/ldap/migrations/0001_initial.py b/authentik/sources/ldap/migrations/0001_initial.py
new file mode 100644
index 000000000..dd42e98d7
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0001_initial.py
@@ -0,0 +1,131 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.core.validators
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_core", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="LDAPPropertyMapping",
+ fields=[
+ (
+ "propertymapping_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_core.PropertyMapping",
+ ),
+ ),
+ ("object_field", models.TextField()),
+ ],
+ options={
+ "verbose_name": "LDAP Property Mapping",
+ "verbose_name_plural": "LDAP Property Mappings",
+ },
+ bases=("authentik_core.propertymapping",),
+ ),
+ migrations.CreateModel(
+ name="LDAPSource",
+ fields=[
+ (
+ "source_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_core.Source",
+ ),
+ ),
+ (
+ "server_uri",
+ models.TextField(
+ validators=[
+ django.core.validators.URLValidator(
+ schemes=["ldap", "ldaps"]
+ )
+ ],
+ verbose_name="Server URI",
+ ),
+ ),
+ ("bind_cn", models.TextField(verbose_name="Bind CN")),
+ ("bind_password", models.TextField()),
+ (
+ "start_tls",
+ models.BooleanField(default=False, verbose_name="Enable Start TLS"),
+ ),
+ ("base_dn", models.TextField(verbose_name="Base DN")),
+ (
+ "additional_user_dn",
+ models.TextField(
+ help_text="Prepended to Base DN for User-queries.",
+ verbose_name="Addition User DN",
+ ),
+ ),
+ (
+ "additional_group_dn",
+ models.TextField(
+ help_text="Prepended to Base DN for Group-queries.",
+ verbose_name="Addition Group DN",
+ ),
+ ),
+ (
+ "user_object_filter",
+ models.TextField(
+ default="(objectCategory=Person)",
+ help_text="Consider Objects matching this filter to be Users.",
+ ),
+ ),
+ (
+ "user_group_membership_field",
+ models.TextField(
+ default="memberOf",
+ help_text="Field which contains Groups of user.",
+ ),
+ ),
+ (
+ "group_object_filter",
+ models.TextField(
+ default="(objectCategory=Group)",
+ help_text="Consider Objects matching this filter to be Groups.",
+ ),
+ ),
+ (
+ "object_uniqueness_field",
+ models.TextField(
+ default="objectSid",
+ help_text="Field which contains a unique Identifier.",
+ ),
+ ),
+ ("sync_groups", models.BooleanField(default=True)),
+ (
+ "sync_parent_group",
+ models.ForeignKey(
+ blank=True,
+ default=None,
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ to="authentik_core.Group",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "LDAP Source",
+ "verbose_name_plural": "LDAP Sources",
+ },
+ bases=("authentik_core.source",),
+ ),
+ ]
diff --git a/authentik/sources/ldap/migrations/0002_ldapsource_sync_users.py b/authentik/sources/ldap/migrations/0002_ldapsource_sync_users.py
new file mode 100644
index 000000000..93df6ff7e
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0002_ldapsource_sync_users.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.6 on 2020-05-23 19:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_ldap", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ldapsource",
+ name="sync_users",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py b/authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py
new file mode 100644
index 000000000..7ee555d1c
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.6 on 2020-05-23 19:30
+
+from django.apps.registry import Apps
+from django.db import migrations
+
+
+def create_default_ad_property_mappings(apps: Apps, schema_editor):
+ LDAPPropertyMapping = apps.get_model(
+ "authentik_sources_ldap", "LDAPPropertyMapping"
+ )
+ mapping = {
+ "name": "return ldap.get('name')",
+ "first_name": "return ldap.get('givenName')",
+ "last_name": "return ldap.get('sn')",
+ "username": "return ldap.get('sAMAccountName')",
+ "email": "return ldap.get('mail')",
+ }
+ db_alias = schema_editor.connection.alias
+ for object_field, expression in mapping.items():
+ LDAPPropertyMapping.objects.using(db_alias).get_or_create(
+ expression=expression,
+ object_field=object_field,
+ defaults={
+ "name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}"
+ },
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_ldap", "0002_ldapsource_sync_users"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_default_ad_property_mappings),
+ ]
diff --git a/authentik/sources/ldap/migrations/0004_auto_20200524_1146.py b/authentik/sources/ldap/migrations/0004_auto_20200524_1146.py
new file mode 100644
index 000000000..2bc5397e5
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0004_auto_20200524_1146.py
@@ -0,0 +1,31 @@
+# Generated by Django 3.0.6 on 2020-05-24 11:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_ldap", "0003_default_ldap_property_mappings"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="ldapsource",
+ name="additional_group_dn",
+ field=models.TextField(
+ blank=True,
+ help_text="Prepended to Base DN for Group-queries.",
+ verbose_name="Addition Group DN",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ldapsource",
+ name="additional_user_dn",
+ field=models.TextField(
+ blank=True,
+ help_text="Prepended to Base DN for User-queries.",
+ verbose_name="Addition User DN",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/ldap/migrations/0005_auto_20200913_1947.py b/authentik/sources/ldap/migrations/0005_auto_20200913_1947.py
new file mode 100644
index 000000000..c33be769c
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0005_auto_20200913_1947.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.1.1 on 2020-09-13 19:47
+
+from django.db import migrations, models
+
+import authentik.lib.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_ldap", "0004_auto_20200524_1146"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="ldapsource",
+ name="server_uri",
+ field=models.TextField(
+ validators=[
+ authentik.lib.models.DomainlessURLValidator(
+ schemes=["ldap", "ldaps"]
+ )
+ ],
+ verbose_name="Server URI",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/ldap/migrations/0006_auto_20200915_1919.py b/authentik/sources/ldap/migrations/0006_auto_20200915_1919.py
new file mode 100644
index 000000000..b742bb3e4
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0006_auto_20200915_1919.py
@@ -0,0 +1,50 @@
+# Generated by Django 3.1.1 on 2020-09-15 19:19
+
+from django.apps.registry import Apps
+from django.db import migrations
+
+
+def create_default_property_mappings(apps: Apps, schema_editor):
+ LDAPPropertyMapping = apps.get_model(
+ "authentik_sources_ldap", "LDAPPropertyMapping"
+ )
+ db_alias = schema_editor.connection.alias
+ mapping = {
+ "name": "name",
+ "first_name": "givenName",
+ "last_name": "sn",
+ "email": "mail",
+ }
+ for object_field, ldap_field in mapping.items():
+ expression = f"return ldap.get('{ldap_field}')"
+ LDAPPropertyMapping.objects.using(db_alias).get_or_create(
+ expression=expression,
+ object_field=object_field,
+ defaults={
+ "name": f"Autogenerated LDAP Mapping: {ldap_field} -> {object_field}"
+ },
+ )
+ ad_mapping = {
+ "username": "sAMAccountName",
+ "attributes.upn": "userPrincipalName",
+ }
+ for object_field, ldap_field in ad_mapping.items():
+ expression = f"return ldap.get('{ldap_field}')"
+ LDAPPropertyMapping.objects.using(db_alias).get_or_create(
+ expression=expression,
+ object_field=object_field,
+ defaults={
+ "name": f"Autogenerated Active Directory Mapping: {ldap_field} -> {object_field}"
+ },
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_ldap", "0005_auto_20200913_1947"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_default_property_mappings),
+ ]
diff --git a/authentik/sources/ldap/migrations/0007_ldapsource_sync_users_password.py b/authentik/sources/ldap/migrations/0007_ldapsource_sync_users_password.py
new file mode 100644
index 000000000..51bda8d27
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0007_ldapsource_sync_users_password.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.1.1 on 2020-09-21 09:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_ldap", "0006_auto_20200915_1919"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ldapsource",
+ name="sync_users_password",
+ field=models.BooleanField(
+ default=True,
+ help_text="When a user changes their password, sync it back to LDAP. This can only be enabled on a single LDAP source.",
+ unique=True,
+ ),
+ ),
+ ]
diff --git a/passbook/sources/ldap/migrations/__init__.py b/authentik/sources/ldap/migrations/__init__.py
similarity index 100%
rename from passbook/sources/ldap/migrations/__init__.py
rename to authentik/sources/ldap/migrations/__init__.py
diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py
new file mode 100644
index 000000000..3e746fb69
--- /dev/null
+++ b/authentik/sources/ldap/models.py
@@ -0,0 +1,132 @@
+"""authentik LDAP Models"""
+from datetime import datetime
+from typing import Optional, Type
+
+from django.core.cache import cache
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from ldap3 import ALL, Connection, Server
+
+from authentik.core.models import Group, PropertyMapping, Source
+from authentik.lib.models import DomainlessURLValidator
+from authentik.lib.utils.template import render_to_string
+
+
+class LDAPSource(Source):
+ """Federate LDAP Directory with authentik, or create new accounts in LDAP."""
+
+ server_uri = models.TextField(
+ validators=[DomainlessURLValidator(schemes=["ldap", "ldaps"])],
+ verbose_name=_("Server URI"),
+ )
+ bind_cn = models.TextField(verbose_name=_("Bind CN"))
+ bind_password = models.TextField()
+ start_tls = models.BooleanField(default=False, verbose_name=_("Enable Start TLS"))
+
+ base_dn = models.TextField(verbose_name=_("Base DN"))
+ additional_user_dn = models.TextField(
+ help_text=_("Prepended to Base DN for User-queries."),
+ verbose_name=_("Addition User DN"),
+ blank=True,
+ )
+ additional_group_dn = models.TextField(
+ help_text=_("Prepended to Base DN for Group-queries."),
+ verbose_name=_("Addition Group DN"),
+ blank=True,
+ )
+
+ user_object_filter = models.TextField(
+ default="(objectCategory=Person)",
+ help_text=_("Consider Objects matching this filter to be Users."),
+ )
+ user_group_membership_field = models.TextField(
+ default="memberOf", help_text=_("Field which contains Groups of user.")
+ )
+ group_object_filter = models.TextField(
+ default="(objectCategory=Group)",
+ help_text=_("Consider Objects matching this filter to be Groups."),
+ )
+ object_uniqueness_field = models.TextField(
+ default="objectSid", help_text=_("Field which contains a unique Identifier.")
+ )
+
+ sync_users = models.BooleanField(default=True)
+ sync_users_password = models.BooleanField(
+ default=True,
+ help_text=_(
+ (
+ "When a user changes their password, sync it back to LDAP. "
+ "This can only be enabled on a single LDAP source."
+ )
+ ),
+ unique=True,
+ )
+ sync_groups = models.BooleanField(default=True)
+ sync_parent_group = models.ForeignKey(
+ Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
+ )
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.ldap.forms import LDAPSourceForm
+
+ return LDAPSourceForm
+
+ def state_cache_prefix(self, suffix: str) -> str:
+ """Key by which the ldap source status is saved"""
+ return f"source_ldap_{self.pk}_state_{suffix}"
+
+ @property
+ def ui_additional_info(self) -> str:
+ last_sync = cache.get(self.state_cache_prefix("last_sync"), None)
+ if last_sync:
+ last_sync = datetime.fromtimestamp(last_sync)
+
+ return render_to_string(
+ "ldap/source_list_status.html", {"source": self, "last_sync": last_sync}
+ )
+
+ _connection: Optional[Connection] = None
+
+ @property
+ def connection(self) -> Connection:
+ """Get a fully connected and bound LDAP Connection"""
+ if not self._connection:
+ server = Server(self.server_uri, get_info=ALL)
+ self._connection = Connection(
+ server,
+ raise_exceptions=True,
+ user=self.bind_cn,
+ password=self.bind_password,
+ )
+
+ self._connection.bind()
+ if self.start_tls:
+ self._connection.start_tls()
+ return self._connection
+
+ class Meta:
+
+ verbose_name = _("LDAP Source")
+ verbose_name_plural = _("LDAP Sources")
+
+
+class LDAPPropertyMapping(PropertyMapping):
+ """Map LDAP Property to User or Group object attribute"""
+
+ object_field = models.TextField()
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.ldap.forms import LDAPPropertyMappingForm
+
+ return LDAPPropertyMappingForm
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+
+ verbose_name = _("LDAP Property Mapping")
+ verbose_name_plural = _("LDAP Property Mappings")
diff --git a/authentik/sources/ldap/password.py b/authentik/sources/ldap/password.py
new file mode 100644
index 000000000..bab51f258
--- /dev/null
+++ b/authentik/sources/ldap/password.py
@@ -0,0 +1,155 @@
+"""Help validate and update passwords in LDAP"""
+from enum import IntFlag
+from re import split
+from typing import Optional
+
+import ldap3
+import ldap3.core.exceptions
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.sources.ldap.models import LDAPSource
+
+LOGGER = get_logger()
+
+NON_ALPHA = r"~!@#$%^&*_-+=`|\(){}[]:;\"'<>,.?/"
+RE_DISPLAYNAME_SEPARATORS = r",\.–—_\s#\t"
+
+
+class PwdProperties(IntFlag):
+ """Possible values for the pwdProperties attribute"""
+
+ DOMAIN_PASSWORD_COMPLEX = 1
+ DOMAIN_PASSWORD_NO_ANON_CHANGE = 2
+ DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4
+ DOMAIN_LOCKOUT_ADMINS = 8
+ DOMAIN_PASSWORD_STORE_CLEARTEXT = 16
+ DOMAIN_REFUSE_PASSWORD_CHANGE = 32
+
+
+class PasswordCategories(IntFlag):
+ """Password categories as defined by Microsoft, a category can only be counted
+ once, hence intflag."""
+
+ NONE = 0
+ ALPHA_LOWER = 1
+ ALPHA_UPPER = 2
+ ALPHA_OTHER = 4
+ NUMERIC = 8
+ SYMBOL = 16
+
+
+class LDAPPasswordChanger:
+ """Help validate and update passwords in LDAP"""
+
+ _source: LDAPSource
+
+ def __init__(self, source: LDAPSource) -> None:
+ self._source = source
+
+ def get_domain_root_dn(self) -> str:
+ """Attempt to get root DN via MS specific fields or generic LDAP fields"""
+ info = self._source.connection.server.info
+ if "rootDomainNamingContext" in info.other:
+ return info.other["rootDomainNamingContext"][0]
+ naming_contexts = info.naming_contexts
+ naming_contexts.sort(key=len)
+ return naming_contexts[0]
+
+ def check_ad_password_complexity_enabled(self) -> bool:
+ """Check if DOMAIN_PASSWORD_COMPLEX is enabled"""
+ root_dn = self.get_domain_root_dn()
+ root_attrs = self._source.connection.extend.standard.paged_search(
+ search_base=root_dn,
+ search_filter="(objectClass=*)",
+ search_scope=ldap3.BASE,
+ attributes=["pwdProperties"],
+ )
+ root_attrs = list(root_attrs)[0]
+ pwd_properties = PwdProperties(root_attrs["attributes"]["pwdProperties"])
+ if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties:
+ return True
+
+ return False
+
+ def change_password(self, user: User, password: str):
+ """Change user's password"""
+ user_dn = user.attributes.get("distinguishedName", None)
+ if not user_dn:
+ raise AttributeError("User has no distinguishedName set.")
+ self._source.connection.extend.microsoft.modify_password(user_dn, password)
+
+ def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
+ """Check if a password contains sAMAccount or displayName"""
+ users = list(
+ self._source.connection.extend.standard.paged_search(
+ search_base=user_dn,
+ search_filter=self._source.user_object_filter,
+ search_scope=ldap3.BASE,
+ attributes=["displayName", "sAMAccountName"],
+ )
+ )
+ if len(users) != 1:
+ raise AssertionError()
+ user_attributes = users[0]["attributes"]
+ # If sAMAccountName is longer than 3 chars, check if its contained in password
+ if len(user_attributes["sAMAccountName"]) >= 3:
+ if password.lower() in user_attributes["sAMAccountName"].lower():
+ return False
+ display_name_tokens = split(
+ RE_DISPLAYNAME_SEPARATORS, user_attributes["displayName"]
+ )
+ for token in display_name_tokens:
+ # Ignore tokens under 3 chars
+ if len(token) < 3:
+ continue
+ if token.lower() in password.lower():
+ return False
+ return True
+
+ def ad_password_complexity(
+ self, password: str, user: Optional[User] = None
+ ) -> bool:
+ """Check if password matches Active direcotry password policies
+
+ https://docs.microsoft.com/en-us/windows/security/threat-protection/
+ security-policy-settings/password-must-meet-complexity-requirements
+ """
+ if user:
+ # Check if password contains sAMAccountName or displayNames
+ if "distinguishedName" in user.attributes:
+ existing_user_check = self._ad_check_password_existing(
+ password, user.attributes.get("distinguishedName")
+ )
+ if not existing_user_check:
+ LOGGER.debug("Password failed name check", user=user)
+ return existing_user_check
+
+ # Step 2, match at least 3 of 5 categories
+ matched_categories = PasswordCategories.NONE
+ required = 3
+ for letter in password:
+ # Only match one category per letter,
+ if letter.islower():
+ matched_categories |= PasswordCategories.ALPHA_LOWER
+ elif letter.isupper():
+ matched_categories |= PasswordCategories.ALPHA_UPPER
+ elif not letter.isascii() and letter.isalpha():
+ # Not exactly matching microsoft's policy, but count it as "Other unicode" char
+ # when its alpha and not ascii
+ matched_categories |= PasswordCategories.ALPHA_OTHER
+ elif letter.isnumeric():
+ matched_categories |= PasswordCategories.NUMERIC
+ elif letter in NON_ALPHA:
+ matched_categories |= PasswordCategories.SYMBOL
+ if bin(matched_categories).count("1") < required:
+ LOGGER.debug(
+ "Password didn't match enough categories",
+ has=matched_categories,
+ must=required,
+ )
+ return False
+ LOGGER.debug(
+ "Password matched categories", has=matched_categories, must=required
+ )
+ return True
diff --git a/authentik/sources/ldap/settings.py b/authentik/sources/ldap/settings.py
new file mode 100644
index 000000000..4acdfbfdf
--- /dev/null
+++ b/authentik/sources/ldap/settings.py
@@ -0,0 +1,14 @@
+"""LDAP Settings"""
+from celery.schedules import crontab
+
+AUTHENTICATION_BACKENDS = [
+ "authentik.sources.ldap.auth.LDAPBackend",
+]
+
+CELERY_BEAT_SCHEDULE = {
+ "sources_ldap_sync": {
+ "task": "authentik.sources.ldap.tasks.ldap_sync_all",
+ "schedule": crontab(minute=0), # Run every hour
+ "options": {"queue": "authentik_scheduled"},
+ }
+}
diff --git a/authentik/sources/ldap/signals.py b/authentik/sources/ldap/signals.py
new file mode 100644
index 000000000..5e7f8c3c7
--- /dev/null
+++ b/authentik/sources/ldap/signals.py
@@ -0,0 +1,59 @@
+"""authentik ldap source signals"""
+from typing import Any, Dict
+
+from django.core.exceptions import ValidationError
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from django.utils.translation import gettext_lazy as _
+from ldap3.core.exceptions import LDAPException
+
+from authentik.core.models import User
+from authentik.core.signals import password_changed
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.sources.ldap.models import LDAPSource
+from authentik.sources.ldap.password import LDAPPasswordChanger
+from authentik.sources.ldap.tasks import ldap_sync
+from authentik.stages.prompt.signals import password_validate
+
+
+@receiver(post_save, sender=LDAPSource)
+# pylint: disable=unused-argument
+def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
+ """Ensure that source is synced on save (if enabled)"""
+ if instance.enabled:
+ ldap_sync.delay(instance.pk)
+
+
+@receiver(password_validate)
+# pylint: disable=unused-argument
+def ldap_password_validate(sender, password: str, plan_context: Dict[str, Any], **__):
+ """if there's an LDAP Source with enabled password sync, check the password"""
+ sources = LDAPSource.objects.filter(sync_users_password=True)
+ if not sources.exists():
+ return
+ source = sources.first()
+ changer = LDAPPasswordChanger(source)
+ if changer.check_ad_password_complexity_enabled():
+ passing = changer.ad_password_complexity(
+ password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
+ )
+ if not passing:
+ raise ValidationError(
+ _("Password does not match Active Direcory Complexity.")
+ )
+
+
+@receiver(password_changed)
+# pylint: disable=unused-argument
+def ldap_sync_password(sender, user: User, password: str, **_):
+ """Connect to ldap and update password. We do this in the background to get
+ automatic retries on error."""
+ sources = LDAPSource.objects.filter(sync_users_password=True)
+ if not sources.exists():
+ return
+ source = sources.first()
+ changer = LDAPPasswordChanger(source)
+ try:
+ changer.change_password(user, password)
+ except LDAPException as exc:
+ raise ValidationError("Failed to set password") from exc
diff --git a/authentik/sources/ldap/sync.py b/authentik/sources/ldap/sync.py
new file mode 100644
index 000000000..fa7ad88a3
--- /dev/null
+++ b/authentik/sources/ldap/sync.py
@@ -0,0 +1,191 @@
+"""Sync LDAP Users and groups into authentik"""
+from typing import Any, Dict
+
+import ldap3
+import ldap3.core.exceptions
+from django.db.utils import IntegrityError
+from structlog import get_logger
+
+from authentik.core.exceptions import PropertyMappingExpressionException
+from authentik.core.models import Group, User
+from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
+
+LOGGER = get_logger()
+
+
+class LDAPSynchronizer:
+ """Sync LDAP Users and groups into authentik"""
+
+ _source: LDAPSource
+
+ def __init__(self, source: LDAPSource):
+ self._source = source
+
+ @property
+ def base_dn_users(self) -> str:
+ """Shortcut to get full base_dn for user lookups"""
+ if self._source.additional_user_dn:
+ return f"{self._source.additional_user_dn},{self._source.base_dn}"
+ return self._source.base_dn
+
+ @property
+ def base_dn_groups(self) -> str:
+ """Shortcut to get full base_dn for group lookups"""
+ if self._source.additional_group_dn:
+ return f"{self._source.additional_group_dn},{self._source.base_dn}"
+ return self._source.base_dn
+
+ def sync_groups(self) -> int:
+ """Iterate over all LDAP Groups and create authentik_core.Group instances"""
+ if not self._source.sync_groups:
+ LOGGER.warning("Group syncing is disabled for this Source")
+ return -1
+ groups = self._source.connection.extend.standard.paged_search(
+ search_base=self.base_dn_groups,
+ search_filter=self._source.group_object_filter,
+ search_scope=ldap3.SUBTREE,
+ attributes=ldap3.ALL_ATTRIBUTES,
+ )
+ group_count = 0
+ for group in groups:
+ attributes = group.get("attributes", {})
+ if self._source.object_uniqueness_field not in attributes:
+ LOGGER.warning(
+ "Cannot find uniqueness Field in attributes", user=attributes.keys()
+ )
+ continue
+ uniq = attributes[self._source.object_uniqueness_field]
+ _, created = Group.objects.update_or_create(
+ attributes__ldap_uniq=uniq,
+ parent=self._source.sync_parent_group,
+ defaults={
+ "name": attributes.get("name", ""),
+ "attributes": {
+ "ldap_uniq": uniq,
+ "distinguishedName": attributes.get("distinguishedName"),
+ },
+ },
+ )
+ LOGGER.debug(
+ "Synced group", group=attributes.get("name", ""), created=created
+ )
+ group_count += 1
+ return group_count
+
+ def sync_users(self) -> int:
+ """Iterate over all LDAP Users and create authentik_core.User instances"""
+ if not self._source.sync_users:
+ LOGGER.warning("User syncing is disabled for this Source")
+ return -1
+ users = self._source.connection.extend.standard.paged_search(
+ search_base=self.base_dn_users,
+ search_filter=self._source.user_object_filter,
+ search_scope=ldap3.SUBTREE,
+ attributes=ldap3.ALL_ATTRIBUTES,
+ )
+ user_count = 0
+ for user in users:
+ attributes = user.get("attributes", {})
+ if self._source.object_uniqueness_field not in attributes:
+ LOGGER.warning(
+ "Cannot find uniqueness Field in attributes", user=user.keys()
+ )
+ continue
+ uniq = attributes[self._source.object_uniqueness_field]
+ try:
+ defaults = self._build_object_properties(attributes)
+ user, created = User.objects.update_or_create(
+ attributes__ldap_uniq=uniq,
+ defaults=defaults,
+ )
+ except IntegrityError as exc:
+ LOGGER.warning("Failed to create user", exc=exc)
+ LOGGER.warning(
+ (
+ "To merge new User with existing user, set the User's "
+ f"Attribute 'ldap_uniq' to '{uniq}'"
+ )
+ )
+ else:
+ if created:
+ user.set_unusable_password()
+ user.save()
+ LOGGER.debug(
+ "Synced User", user=attributes.get("name", ""), created=created
+ )
+ user_count += 1
+ return user_count
+
+ def sync_membership(self):
+ """Iterate over all Users and assign Groups using memberOf Field"""
+ users = self._source.connection.extend.standard.paged_search(
+ search_base=self.base_dn_users,
+ search_filter=self._source.user_object_filter,
+ search_scope=ldap3.SUBTREE,
+ attributes=[
+ self._source.user_group_membership_field,
+ self._source.object_uniqueness_field,
+ ],
+ )
+ group_cache: Dict[str, Group] = {}
+ for user in users:
+ member_of = user.get("attributes", {}).get(
+ self._source.user_group_membership_field, []
+ )
+ uniq = user.get("attributes", {}).get(
+ self._source.object_uniqueness_field, []
+ )
+ for group_dn in member_of:
+ # Check if group_dn is within our base_dn_groups, and skip if not
+ if not group_dn.endswith(self.base_dn_groups):
+ continue
+ # Check if we fetched the group already, and if not cache it for later
+ if group_dn not in group_cache:
+ groups = Group.objects.filter(
+ attributes__distinguishedName=group_dn
+ )
+ if not groups.exists():
+ LOGGER.warning(
+ "Group does not exist in our DB yet, run sync_groups first.",
+ group=group_dn,
+ )
+ return
+ group_cache[group_dn] = groups.first()
+ group = group_cache[group_dn]
+ users = User.objects.filter(attributes__ldap_uniq=uniq)
+ group.users.add(*list(users))
+ # Now that all users are added, lets write everything
+ for _, group in group_cache.items():
+ group.save()
+ LOGGER.debug("Successfully updated group membership")
+
+ def _build_object_properties(
+ self, attributes: Dict[str, Any]
+ ) -> Dict[str, Dict[Any, Any]]:
+ properties = {"attributes": {}}
+ for mapping in self._source.property_mappings.all().select_subclasses():
+ if not isinstance(mapping, LDAPPropertyMapping):
+ continue
+ mapping: LDAPPropertyMapping
+ try:
+ value = mapping.evaluate(user=None, request=None, ldap=attributes)
+ if value is None:
+ continue
+ object_field = mapping.object_field
+ if object_field.startswith("attributes."):
+ properties["attributes"][
+ object_field.replace("attributes.", "")
+ ] = value
+ else:
+ properties[object_field] = value
+ except PropertyMappingExpressionException as exc:
+ LOGGER.warning("Mapping failed to evaluate", exc=exc, mapping=mapping)
+ continue
+ if self._source.object_uniqueness_field in attributes:
+ properties["attributes"]["ldap_uniq"] = attributes.get(
+ self._source.object_uniqueness_field
+ )
+ properties["attributes"]["distinguishedName"] = attributes.get(
+ "distinguishedName"
+ )
+ return properties
diff --git a/authentik/sources/ldap/tasks.py b/authentik/sources/ldap/tasks.py
new file mode 100644
index 000000000..fcb47c756
--- /dev/null
+++ b/authentik/sources/ldap/tasks.py
@@ -0,0 +1,45 @@
+"""LDAP Sync tasks"""
+from time import time
+
+from django.core.cache import cache
+from django.utils.text import slugify
+from ldap3.core.exceptions import LDAPException
+
+from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
+from authentik.root.celery import CELERY_APP
+from authentik.sources.ldap.models import LDAPSource
+from authentik.sources.ldap.sync import LDAPSynchronizer
+
+
+@CELERY_APP.task()
+def ldap_sync_all():
+ """Sync all sources"""
+ for source in LDAPSource.objects.filter(enabled=True):
+ ldap_sync.delay(source.pk)
+
+
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def ldap_sync(self: MonitoredTask, source_pk: int):
+ """Synchronization of an LDAP Source"""
+ try:
+ source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
+ except LDAPSource.DoesNotExist:
+ # Because the source couldn't be found, we don't have a UID
+ # to set the state with
+ return
+ self.set_uid(slugify(source.name))
+ try:
+ syncer = LDAPSynchronizer(source)
+ user_count = syncer.sync_users()
+ group_count = syncer.sync_groups()
+ syncer.sync_membership()
+ cache_key = source.state_cache_prefix("last_sync")
+ cache.set(cache_key, time(), timeout=60 * 60)
+ self.set_status(
+ TaskResult(
+ TaskResultStatus.SUCCESSFUL,
+ [f"Synced {user_count} users", f"Synced {group_count} groups"],
+ )
+ )
+ except LDAPException as exc:
+ self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
diff --git a/authentik/sources/ldap/templates/ldap/property_mapping_form.html b/authentik/sources/ldap/templates/ldap/property_mapping_form.html
new file mode 100644
index 000000000..c202b5b29
--- /dev/null
+++ b/authentik/sources/ldap/templates/ldap/property_mapping_form.html
@@ -0,0 +1,14 @@
+{% extends "generic/form.html" %}
+
+{% load i18n %}
+
+{% block beneath_form %}
+
+{% endblock %}
diff --git a/passbook/sources/ldap/templates/ldap/source_list_status.html b/authentik/sources/ldap/templates/ldap/source_list_status.html
similarity index 100%
rename from passbook/sources/ldap/templates/ldap/source_list_status.html
rename to authentik/sources/ldap/templates/ldap/source_list_status.html
diff --git a/passbook/sources/ldap/tests/__init__.py b/authentik/sources/ldap/tests/__init__.py
similarity index 100%
rename from passbook/sources/ldap/tests/__init__.py
rename to authentik/sources/ldap/tests/__init__.py
diff --git a/authentik/sources/ldap/tests/test_auth.py b/authentik/sources/ldap/tests/test_auth.py
new file mode 100644
index 000000000..f3a09246a
--- /dev/null
+++ b/authentik/sources/ldap/tests/test_auth.py
@@ -0,0 +1,47 @@
+"""LDAP Source tests"""
+from unittest.mock import Mock, PropertyMock, patch
+
+from django.test import TestCase
+
+from authentik.core.models import User
+from authentik.providers.oauth2.generators import generate_client_secret
+from authentik.sources.ldap.auth import LDAPBackend
+from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
+from authentik.sources.ldap.sync import LDAPSynchronizer
+from authentik.sources.ldap.tests.utils import _build_mock_connection
+
+LDAP_PASSWORD = generate_client_secret()
+LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
+
+
+class LDAPSyncTests(TestCase):
+ """LDAP Sync tests"""
+
+ def setUp(self):
+ self.source = LDAPSource.objects.create(
+ name="ldap",
+ slug="ldap",
+ base_dn="DC=AD2012,DC=LAB",
+ additional_user_dn="ou=users",
+ additional_group_dn="ou=groups",
+ )
+ self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
+ self.source.save()
+
+ @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
+ def test_auth_synced_user(self):
+ """Test Cached auth"""
+ syncer = LDAPSynchronizer(self.source)
+ syncer.sync_users()
+
+ user = User.objects.get(username="user0_sn")
+ auth_user_by_bind = Mock(return_value=user)
+ with patch(
+ "authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
+ auth_user_by_bind,
+ ):
+ backend = LDAPBackend()
+ self.assertEqual(
+ backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
+ user,
+ )
diff --git a/authentik/sources/ldap/tests/test_password.py b/authentik/sources/ldap/tests/test_password.py
new file mode 100644
index 000000000..82f497e54
--- /dev/null
+++ b/authentik/sources/ldap/tests/test_password.py
@@ -0,0 +1,54 @@
+"""LDAP Source tests"""
+from unittest.mock import PropertyMock, patch
+
+from django.test import TestCase
+
+from authentik.core.models import User
+from authentik.providers.oauth2.generators import generate_client_secret
+from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
+from authentik.sources.ldap.password import LDAPPasswordChanger
+from authentik.sources.ldap.tests.utils import _build_mock_connection
+
+LDAP_PASSWORD = generate_client_secret()
+LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
+
+
+class LDAPPasswordTests(TestCase):
+ """LDAP Password tests"""
+
+ def setUp(self):
+ self.source = LDAPSource.objects.create(
+ name="ldap",
+ slug="ldap",
+ base_dn="DC=AD2012,DC=LAB",
+ additional_user_dn="ou=users",
+ additional_group_dn="ou=groups",
+ )
+ self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
+ self.source.save()
+
+ @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
+ def test_password_complexity(self):
+ """Test password without user"""
+ pwc = LDAPPasswordChanger(self.source)
+ self.assertFalse(pwc.ad_password_complexity("test")) # 1 category
+ self.assertFalse(pwc.ad_password_complexity("test1")) # 2 categories
+ self.assertTrue(pwc.ad_password_complexity("test1!")) # 2 categories
+
+ @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
+ def test_password_complexity_user(self):
+ """test password with user"""
+ pwc = LDAPPasswordChanger(self.source)
+ user = User.objects.create(
+ username="test",
+ attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"},
+ )
+ self.assertFalse(pwc.ad_password_complexity("test", user)) # 1 category
+ self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories
+ self.assertTrue(pwc.ad_password_complexity("test1!", user)) # 2 categories
+ self.assertFalse(
+ pwc.ad_password_complexity("erin!qewrqewr", user)
+ ) # displayName token
+ self.assertFalse(
+ pwc.ad_password_complexity("hagens!qewrqewr", user)
+ ) # displayName token
diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py
new file mode 100644
index 000000000..e6c14dca8
--- /dev/null
+++ b/authentik/sources/ldap/tests/test_sync.py
@@ -0,0 +1,51 @@
+"""LDAP Source tests"""
+from unittest.mock import PropertyMock, patch
+
+from django.test import TestCase
+
+from authentik.core.models import Group, User
+from authentik.providers.oauth2.generators import generate_client_secret
+from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
+from authentik.sources.ldap.sync import LDAPSynchronizer
+from authentik.sources.ldap.tasks import ldap_sync_all
+from authentik.sources.ldap.tests.utils import _build_mock_connection
+
+LDAP_PASSWORD = generate_client_secret()
+LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
+
+
+class LDAPSyncTests(TestCase):
+ """LDAP Sync tests"""
+
+ def setUp(self):
+ self.source = LDAPSource.objects.create(
+ name="ldap",
+ slug="ldap",
+ base_dn="DC=AD2012,DC=LAB",
+ additional_user_dn="ou=users",
+ additional_group_dn="ou=groups",
+ )
+ self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
+ self.source.save()
+
+ @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
+ def test_sync_users(self):
+ """Test user sync"""
+ syncer = LDAPSynchronizer(self.source)
+ syncer.sync_users()
+ self.assertTrue(User.objects.filter(username="user0_sn").exists())
+ self.assertFalse(User.objects.filter(username="user1_sn").exists())
+
+ @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
+ def test_sync_groups(self):
+ """Test group sync"""
+ syncer = LDAPSynchronizer(self.source)
+ syncer.sync_groups()
+ syncer.sync_membership()
+ group = Group.objects.filter(name="test-group")
+ self.assertTrue(group.exists())
+
+ @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
+ def test_tasks(self):
+ """Test Scheduled tasks"""
+ ldap_sync_all.delay().get()
diff --git a/passbook/sources/ldap/tests/utils.py b/authentik/sources/ldap/tests/utils.py
similarity index 100%
rename from passbook/sources/ldap/tests/utils.py
rename to authentik/sources/ldap/tests/utils.py
diff --git a/passbook/sources/oauth/__init__.py b/authentik/sources/oauth/__init__.py
similarity index 100%
rename from passbook/sources/oauth/__init__.py
rename to authentik/sources/oauth/__init__.py
diff --git a/authentik/sources/oauth/api.py b/authentik/sources/oauth/api.py
new file mode 100644
index 000000000..8aceead43
--- /dev/null
+++ b/authentik/sources/oauth/api.py
@@ -0,0 +1,29 @@
+"""OAuth Source Serializer"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
+from authentik.sources.oauth.models import OAuthSource
+
+
+class OAuthSourceSerializer(ModelSerializer):
+ """OAuth Source Serializer"""
+
+ class Meta:
+ model = OAuthSource
+ fields = SOURCE_SERIALIZER_FIELDS + [
+ "provider_type",
+ "request_token_url",
+ "authorization_url",
+ "access_token_url",
+ "profile_url",
+ "consumer_key",
+ "consumer_secret",
+ ]
+
+
+class OAuthSourceViewSet(ModelViewSet):
+ """Source Viewset"""
+
+ queryset = OAuthSource.objects.all()
+ serializer_class = OAuthSourceSerializer
diff --git a/authentik/sources/oauth/apps.py b/authentik/sources/oauth/apps.py
new file mode 100644
index 000000000..dde12f478
--- /dev/null
+++ b/authentik/sources/oauth/apps.py
@@ -0,0 +1,26 @@
+"""authentik oauth_client config"""
+from importlib import import_module
+
+from django.apps import AppConfig
+from django.conf import settings
+from structlog import get_logger
+
+LOGGER = get_logger()
+
+
+class AuthentikSourceOAuthConfig(AppConfig):
+ """authentik source.oauth config"""
+
+ name = "authentik.sources.oauth"
+ label = "authentik_sources_oauth"
+ verbose_name = "authentik Sources.OAuth"
+ mountpoint = "source/oauth/"
+
+ def ready(self):
+ """Load source_types from config file"""
+ for source_type in settings.AUTHENTIK_SOURCES_OAUTH_TYPES:
+ try:
+ import_module(source_type)
+ LOGGER.debug("Loaded OAuth Source Type", type=source_type)
+ except ImportError as exc:
+ LOGGER.debug(exc)
diff --git a/authentik/sources/oauth/auth.py b/authentik/sources/oauth/auth.py
new file mode 100644
index 000000000..62836f574
--- /dev/null
+++ b/authentik/sources/oauth/auth.py
@@ -0,0 +1,23 @@
+"""authentik oauth_client Authorization backend"""
+from typing import Optional
+
+from django.contrib.auth.backends import ModelBackend
+from django.http import HttpRequest
+
+from authentik.core.models import User
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+
+
+class AuthorizedServiceBackend(ModelBackend):
+ "Authentication backend for users registered with remote OAuth provider."
+
+ def authenticate(
+ self, request: HttpRequest, source: OAuthSource, identifier: str
+ ) -> Optional[User]:
+ "Fetch user for a given source by id."
+ access = UserOAuthSourceConnection.objects.filter(
+ source=source, identifier=identifier
+ ).select_related("user")
+ if not access.exists():
+ return None
+ return access.first().user
diff --git a/passbook/sources/oauth/clients/__init__.py b/authentik/sources/oauth/clients/__init__.py
similarity index 100%
rename from passbook/sources/oauth/clients/__init__.py
rename to authentik/sources/oauth/clients/__init__.py
diff --git a/authentik/sources/oauth/clients/base.py b/authentik/sources/oauth/clients/base.py
new file mode 100644
index 000000000..0672e3fbf
--- /dev/null
+++ b/authentik/sources/oauth/clients/base.py
@@ -0,0 +1,75 @@
+"""OAuth Clients"""
+from typing import Any, Dict, Optional
+from urllib.parse import urlencode
+
+from django.http import HttpRequest
+from requests import Session
+from requests.exceptions import RequestException
+from requests.models import Response
+from structlog import get_logger
+
+from authentik import __version__
+from authentik.sources.oauth.models import OAuthSource
+
+LOGGER = get_logger()
+
+
+class BaseOAuthClient:
+ """Base OAuth Client"""
+
+ session: Session
+
+ source: OAuthSource
+ request: HttpRequest
+
+ callback: Optional[str]
+
+ def __init__(
+ self, source: OAuthSource, request: HttpRequest, callback: Optional[str] = None
+ ):
+ self.source = source
+ self.session = Session()
+ self.request = request
+ self.callback = callback
+ self.session.headers.update({"User-Agent": f"authentik {__version__}"})
+
+ def get_access_token(self, **request_kwargs) -> Optional[Dict[str, Any]]:
+ "Fetch access token from callback request."
+ raise NotImplementedError("Defined in a sub-class") # pragma: no cover
+
+ def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]:
+ "Fetch user profile information."
+ try:
+ response = self.do_request("get", self.source.profile_url, token=token)
+ response.raise_for_status()
+ except RequestException as exc:
+ LOGGER.warning("Unable to fetch user profile", exc=exc)
+ return None
+ else:
+ return response.json()
+
+ def get_redirect_args(self) -> Dict[str, str]:
+ "Get request parameters for redirect url."
+ raise NotImplementedError("Defined in a sub-class") # pragma: no cover
+
+ def get_redirect_url(self, parameters=None):
+ "Build authentication redirect url."
+ args = self.get_redirect_args()
+ additional = parameters or {}
+ args.update(additional)
+ params = urlencode(args)
+ LOGGER.info("redirect args", **args)
+ return f"{self.source.authorization_url}?{params}"
+
+ def parse_raw_token(self, raw_token: str) -> Dict[str, Any]:
+ "Parse token and secret from raw token response."
+ raise NotImplementedError("Defined in a sub-class") # pragma: no cover
+
+ def do_request(self, method: str, url: str, **kwargs) -> Response:
+ """Wrapper around self.session.request, which can add special headers"""
+ return self.session.request(method, url, **kwargs)
+
+ @property
+ def session_key(self) -> str:
+ """Return Session Key"""
+ raise NotImplementedError("Defined in a sub-class") # pragma: no cover
diff --git a/authentik/sources/oauth/clients/oauth1.py b/authentik/sources/oauth/clients/oauth1.py
new file mode 100644
index 000000000..4ba926207
--- /dev/null
+++ b/authentik/sources/oauth/clients/oauth1.py
@@ -0,0 +1,102 @@
+"""OAuth 1 Clients"""
+from typing import Any, Dict, Optional
+from urllib.parse import parse_qsl
+
+from requests.exceptions import RequestException
+from requests.models import Response
+from requests_oauthlib import OAuth1
+from structlog import get_logger
+
+from authentik.sources.oauth.clients.base import BaseOAuthClient
+from authentik.sources.oauth.exceptions import OAuthSourceException
+
+LOGGER = get_logger()
+
+
+class OAuthClient(BaseOAuthClient):
+ """OAuth1 Client"""
+
+ _default_headers = {
+ "Accept": "application/json",
+ }
+
+ def get_access_token(self, **request_kwargs) -> Optional[Dict[str, Any]]:
+ "Fetch access token from callback request."
+ raw_token = self.request.session.get(self.session_key, None)
+ verifier = self.request.GET.get("oauth_verifier", None)
+ callback = self.request.build_absolute_uri(self.callback)
+ if raw_token is not None and verifier is not None:
+ token = self.parse_raw_token(raw_token)
+ try:
+ response = self.do_request(
+ "post",
+ self.source.access_token_url,
+ token=token,
+ headers=self._default_headers,
+ oauth_verifier=verifier,
+ oauth_callback=callback,
+ )
+ response.raise_for_status()
+ except RequestException as exc:
+ LOGGER.warning("Unable to fetch access token", exc=exc)
+ return None
+ else:
+ return self.parse_raw_token(response.text)
+ return None
+
+ def get_request_token(self) -> str:
+ "Fetch the OAuth request token. Only required for OAuth 1.0."
+ callback = self.request.build_absolute_uri(self.callback)
+ try:
+ response = self.do_request(
+ "post",
+ self.source.request_token_url,
+ headers=self._default_headers,
+ oauth_callback=callback,
+ )
+ response.raise_for_status()
+ except RequestException as exc:
+ raise OAuthSourceException from exc
+ else:
+ return response.text
+
+ def get_redirect_args(self) -> Dict[str, Any]:
+ "Get request parameters for redirect url."
+ callback = self.request.build_absolute_uri(self.callback)
+ raw_token = self.get_request_token()
+ token = self.parse_raw_token(raw_token)
+ self.request.session[self.session_key] = raw_token
+ return {
+ "oauth_token": token["oauth_token"],
+ "oauth_callback": callback,
+ }
+
+ def parse_raw_token(self, raw_token: str) -> Dict[str, Any]:
+ "Parse token and secret from raw token response."
+ return dict(parse_qsl(raw_token))
+
+ def do_request(self, method: str, url: str, **kwargs) -> Response:
+ "Build remote url request. Constructs necessary auth."
+ resource_owner_key = None
+ resource_owner_secret = None
+ if "token" in kwargs:
+ user_token: Dict[str, Any] = kwargs.pop("token")
+ resource_owner_key = user_token["oauth_token"]
+ resource_owner_secret = user_token["oauth_token_secret"]
+
+ callback = kwargs.pop("oauth_callback", None)
+ verifier = kwargs.pop("oauth_verifier", None)
+ oauth = OAuth1(
+ resource_owner_key=resource_owner_key,
+ resource_owner_secret=resource_owner_secret,
+ client_key=self.source.consumer_key,
+ client_secret=self.source.consumer_secret,
+ verifier=verifier,
+ callback_uri=callback,
+ )
+ kwargs["auth"] = oauth
+ return super().do_request(method, url, **kwargs)
+
+ @property
+ def session_key(self) -> str:
+ return f"oauth-client-{self.source.name}-request-token"
diff --git a/authentik/sources/oauth/clients/oauth2.py b/authentik/sources/oauth/clients/oauth2.py
new file mode 100644
index 000000000..d5228f4a5
--- /dev/null
+++ b/authentik/sources/oauth/clients/oauth2.py
@@ -0,0 +1,113 @@
+"""OAuth 2 Clients"""
+from json import loads
+from typing import Any, Dict, Optional
+from urllib.parse import parse_qsl
+
+from django.utils.crypto import constant_time_compare, get_random_string
+from requests.exceptions import RequestException
+from requests.models import Response
+from structlog import get_logger
+
+from authentik.sources.oauth.clients.base import BaseOAuthClient
+
+LOGGER = get_logger()
+
+
+class OAuth2Client(BaseOAuthClient):
+ """OAuth2 Client"""
+
+ _default_headers = {
+ "Accept": "application/json",
+ }
+
+ def check_application_state(self) -> bool:
+ "Check optional state parameter."
+ stored = self.request.session.get(self.session_key, None)
+ returned = self.request.GET.get("state", None)
+ check = False
+ if stored is not None:
+ if returned is not None:
+ check = constant_time_compare(stored, returned)
+ else:
+ LOGGER.warning("No state parameter returned by the source.")
+ else:
+ LOGGER.warning("No state stored in the session.")
+ return check
+
+ def get_application_state(self) -> str:
+ "Generate state optional parameter."
+ return get_random_string(32)
+
+ def get_access_token(self, **request_kwargs) -> Optional[Dict[str, Any]]:
+ "Fetch access token from callback request."
+ callback = self.request.build_absolute_uri(self.callback or self.request.path)
+ if not self.check_application_state():
+ LOGGER.warning("Application state check failed.")
+ return None
+ if "code" in self.request.GET:
+ args = {
+ "client_id": self.source.consumer_key,
+ "redirect_uri": callback,
+ "client_secret": self.source.consumer_secret,
+ "code": self.request.GET["code"],
+ "grant_type": "authorization_code",
+ }
+ else:
+ LOGGER.warning("No code returned by the source")
+ return None
+ try:
+ response = self.session.request(
+ "post",
+ self.source.access_token_url,
+ data=args,
+ headers=self._default_headers,
+ )
+ response.raise_for_status()
+ except RequestException as exc:
+ LOGGER.warning("Unable to fetch access token", exc=exc)
+ return None
+ else:
+ return response.json()
+
+ def get_redirect_args(self) -> Dict[str, str]:
+ "Get request parameters for redirect url."
+ callback = self.request.build_absolute_uri(self.callback)
+ client_id: str = self.source.consumer_key
+ args: Dict[str, str] = {
+ "client_id": client_id,
+ "redirect_uri": callback,
+ "response_type": "code",
+ }
+ state = self.get_application_state()
+ if state is not None:
+ args["state"] = state
+ self.request.session[self.session_key] = state
+ return args
+
+ def parse_raw_token(self, raw_token: str) -> Dict[str, Any]:
+ "Parse token and secret from raw token response."
+ # Load as json first then parse as query string
+ try:
+ token_data = loads(raw_token)
+ except ValueError:
+ return dict(parse_qsl(raw_token))
+ else:
+ return token_data
+
+ def do_request(self, method: str, url: str, **kwargs) -> Response:
+ "Build remote url request. Constructs necessary auth."
+ if "token" in kwargs:
+ token = kwargs.pop("token")
+
+ params = kwargs.get("params", {})
+ params["access_token"] = token["access_token"]
+ kwargs["params"] = params
+
+ headers = kwargs.get("headers", {})
+ headers["Authorization"] = f"{token['token_type']} {token['access_token']}"
+ kwargs["headers"] = headers
+ return super().do_request(method, url, **kwargs)
+
+ @property
+ def session_key(self):
+ return "oauth-client-{0}-request-state".format(self.source.name)
diff --git a/authentik/sources/oauth/exceptions.py b/authentik/sources/oauth/exceptions.py
new file mode 100644
index 000000000..0929e74c4
--- /dev/null
+++ b/authentik/sources/oauth/exceptions.py
@@ -0,0 +1,6 @@
+"""OAuth Source Exception"""
+from authentik.lib.sentry import SentryIgnoredException
+
+
+class OAuthSourceException(SentryIgnoredException):
+ """General Error during OAuth Flow occurred"""
diff --git a/authentik/sources/oauth/forms.py b/authentik/sources/oauth/forms.py
new file mode 100644
index 000000000..7018c096d
--- /dev/null
+++ b/authentik/sources/oauth/forms.py
@@ -0,0 +1,131 @@
+"""authentik oauth_client forms"""
+
+from django import forms
+
+from authentik.admin.forms.source import SOURCE_FORM_FIELDS
+from authentik.flows.models import Flow, FlowDesignation
+from authentik.sources.oauth.models import OAuthSource
+from authentik.sources.oauth.types.manager import MANAGER
+
+
+class OAuthSourceForm(forms.ModelForm):
+ """OAuthSource Form"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["authentication_flow"].queryset = Flow.objects.filter(
+ designation=FlowDesignation.AUTHENTICATION
+ )
+ self.fields["enrollment_flow"].queryset = Flow.objects.filter(
+ designation=FlowDesignation.ENROLLMENT
+ )
+ if hasattr(self.Meta, "overrides"):
+ for overide_field, overide_value in getattr(self.Meta, "overrides").items():
+ self.fields[overide_field].initial = overide_value
+ self.fields[overide_field].widget.attrs["readonly"] = "readonly"
+
+ class Meta:
+
+ model = OAuthSource
+ fields = SOURCE_FORM_FIELDS + [
+ "provider_type",
+ "request_token_url",
+ "authorization_url",
+ "access_token_url",
+ "profile_url",
+ "consumer_key",
+ "consumer_secret",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "consumer_key": forms.TextInput(),
+ "consumer_secret": forms.TextInput(),
+ "provider_type": forms.Select(choices=MANAGER.get_name_tuple()),
+ }
+
+
+class GitHubOAuthSourceForm(OAuthSourceForm):
+ """OAuth Source form with pre-determined URL for GitHub"""
+
+ class Meta(OAuthSourceForm.Meta):
+
+ overrides = {
+ "provider_type": "github",
+ "request_token_url": "",
+ "authorization_url": "https://github.com/login/oauth/authorize",
+ "access_token_url": "https://github.com/login/oauth/access_token",
+ "profile_url": "https://api.github.com/user",
+ }
+
+
+class TwitterOAuthSourceForm(OAuthSourceForm):
+ """OAuth Source form with pre-determined URL for Twitter"""
+
+ class Meta(OAuthSourceForm.Meta):
+
+ overrides = {
+ "provider_type": "twitter",
+ "request_token_url": "https://api.twitter.com/oauth/request_token",
+ "authorization_url": "https://api.twitter.com/oauth/authenticate",
+ "access_token_url": "https://api.twitter.com/oauth/access_token",
+ "profile_url": (
+ "https://api.twitter.com/1.1/account/"
+ "verify_credentials.json?include_email=true"
+ ),
+ }
+
+
+class FacebookOAuthSourceForm(OAuthSourceForm):
+ """OAuth Source form with pre-determined URL for Facebook"""
+
+ class Meta(OAuthSourceForm.Meta):
+
+ overrides = {
+ "provider_type": "facebook",
+ "request_token_url": "",
+ "authorization_url": "https://www.facebook.com/v7.0/dialog/oauth",
+ "access_token_url": "https://graph.facebook.com/v7.0/oauth/access_token",
+ "profile_url": "https://graph.facebook.com/v7.0/me?fields=id,name,email",
+ }
+
+
+class DiscordOAuthSourceForm(OAuthSourceForm):
+ """OAuth Source form with pre-determined URL for Discord"""
+
+ class Meta(OAuthSourceForm.Meta):
+
+ overrides = {
+ "provider_type": "discord",
+ "request_token_url": "",
+ "authorization_url": "https://discord.com/api/oauth2/authorize",
+ "access_token_url": "https://discord.com/api/oauth2/token",
+ "profile_url": "https://discord.com/api/users/@me",
+ }
+
+
+class GoogleOAuthSourceForm(OAuthSourceForm):
+ """OAuth Source form with pre-determined URL for Google"""
+
+ class Meta(OAuthSourceForm.Meta):
+
+ overrides = {
+ "provider_type": "google",
+ "request_token_url": "",
+ "authorization_url": "https://accounts.google.com/o/oauth2/auth",
+ "access_token_url": "https://accounts.google.com/o/oauth2/token",
+ "profile_url": "https://www.googleapis.com/oauth2/v1/userinfo",
+ }
+
+
+class AzureADOAuthSourceForm(OAuthSourceForm):
+ """OAuth Source form with pre-determined URL for AzureAD"""
+
+ class Meta(OAuthSourceForm.Meta):
+
+ overrides = {
+ "provider_type": "azure-ad",
+ "request_token_url": "",
+ "authorization_url": "https://login.microsoftonline.com/common/oauth2/authorize",
+ "access_token_url": "https://login.microsoftonline.com/common/oauth2/token",
+ "profile_url": "https://graph.windows.net/myorganization/me?api-version=1.6",
+ }
diff --git a/authentik/sources/oauth/migrations/0001_initial.py b/authentik/sources/oauth/migrations/0001_initial.py
new file mode 100644
index 000000000..b13defbe8
--- /dev/null
+++ b/authentik/sources/oauth/migrations/0001_initial.py
@@ -0,0 +1,81 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_core", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="OAuthSource",
+ fields=[
+ (
+ "source_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_core.Source",
+ ),
+ ),
+ ("provider_type", models.CharField(max_length=255)),
+ (
+ "request_token_url",
+ models.CharField(
+ blank=True, max_length=255, verbose_name="Request Token URL"
+ ),
+ ),
+ (
+ "authorization_url",
+ models.CharField(max_length=255, verbose_name="Authorization URL"),
+ ),
+ (
+ "access_token_url",
+ models.CharField(max_length=255, verbose_name="Access Token URL"),
+ ),
+ (
+ "profile_url",
+ models.CharField(max_length=255, verbose_name="Profile URL"),
+ ),
+ ("consumer_key", models.TextField()),
+ ("consumer_secret", models.TextField()),
+ ],
+ options={
+ "verbose_name": "Generic OAuth Source",
+ "verbose_name_plural": "Generic OAuth Sources",
+ },
+ bases=("authentik_core.source",),
+ ),
+ migrations.CreateModel(
+ name="UserOAuthSourceConnection",
+ fields=[
+ (
+ "usersourceconnection_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_core.UserSourceConnection",
+ ),
+ ),
+ ("identifier", models.CharField(max_length=255)),
+ ("access_token", models.TextField(blank=True, default=None, null=True)),
+ ],
+ options={
+ "verbose_name": "User OAuth Source Connection",
+ "verbose_name_plural": "User OAuth Source Connections",
+ },
+ bases=("authentik_core.usersourceconnection",),
+ ),
+ ]
diff --git a/authentik/sources/oauth/migrations/0002_auto_20200520_1108.py b/authentik/sources/oauth/migrations/0002_auto_20200520_1108.py
new file mode 100644
index 000000000..7452ef5b3
--- /dev/null
+++ b/authentik/sources/oauth/migrations/0002_auto_20200520_1108.py
@@ -0,0 +1,50 @@
+# Generated by Django 3.0.6 on 2020-05-20 11:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_oauth", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="oauthsource",
+ name="access_token_url",
+ field=models.CharField(
+ help_text="URL used by authentik to retrive tokens.",
+ max_length=255,
+ verbose_name="Access Token URL",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="oauthsource",
+ name="request_token_url",
+ field=models.CharField(
+ blank=True,
+ help_text="URL used to request the initial token. This URL is only required for OAuth 1.",
+ max_length=255,
+ verbose_name="Request Token URL",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="oauthsource",
+ name="authorization_url",
+ field=models.CharField(
+ help_text="URL the user is redirect to to conest the flow.",
+ max_length=255,
+ verbose_name="Authorization URL",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="oauthsource",
+ name="profile_url",
+ field=models.CharField(
+ help_text="URL used by authentik to get user information.",
+ max_length=255,
+ verbose_name="Profile URL",
+ ),
+ ),
+ ]
diff --git a/passbook/sources/oauth/migrations/__init__.py b/authentik/sources/oauth/migrations/__init__.py
similarity index 100%
rename from passbook/sources/oauth/migrations/__init__.py
rename to authentik/sources/oauth/migrations/__init__.py
diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py
new file mode 100644
index 000000000..4e0534fcd
--- /dev/null
+++ b/authentik/sources/oauth/models.py
@@ -0,0 +1,207 @@
+"""OAuth Client models"""
+from typing import Optional, Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.urls import reverse, reverse_lazy
+from django.utils.translation import gettext_lazy as _
+
+from authentik.core.models import Source, UserSourceConnection
+from authentik.core.types import UILoginButton
+
+
+class OAuthSource(Source):
+ """Login using a Generic OAuth provider."""
+
+ provider_type = models.CharField(max_length=255)
+ request_token_url = models.CharField(
+ blank=True,
+ max_length=255,
+ verbose_name=_("Request Token URL"),
+ help_text=_(
+ "URL used to request the initial token. This URL is only required for OAuth 1."
+ ),
+ )
+ authorization_url = models.CharField(
+ max_length=255,
+ verbose_name=_("Authorization URL"),
+ help_text=_("URL the user is redirect to to conest the flow."),
+ )
+ access_token_url = models.CharField(
+ max_length=255,
+ verbose_name=_("Access Token URL"),
+ help_text=_("URL used by authentik to retrive tokens."),
+ )
+ profile_url = models.CharField(
+ max_length=255,
+ verbose_name=_("Profile URL"),
+ help_text=_("URL used by authentik to get user information."),
+ )
+ consumer_key = models.TextField()
+ consumer_secret = models.TextField()
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.oauth.forms import OAuthSourceForm
+
+ return OAuthSourceForm
+
+ @property
+ def ui_login_button(self) -> UILoginButton:
+ return UILoginButton(
+ url=reverse_lazy(
+ "authentik_sources_oauth:oauth-client-login",
+ kwargs={"source_slug": self.slug},
+ ),
+ icon_path=f"authentik/sources/{self.provider_type}.svg",
+ name=self.name,
+ )
+
+ @property
+ def ui_additional_info(self) -> str:
+ url = reverse_lazy(
+ "authentik_sources_oauth:oauth-client-callback",
+ kwargs={"source_slug": self.slug},
+ )
+ return f"Callback URL: {url} "
+
+ @property
+ def ui_user_settings(self) -> Optional[str]:
+ view_name = "authentik_sources_oauth:oauth-client-user"
+ return reverse(view_name, kwargs={"source_slug": self.slug})
+
+ def __str__(self) -> str:
+ return f"OAuth Source {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Generic OAuth Source")
+ verbose_name_plural = _("Generic OAuth Sources")
+
+
+class GitHubOAuthSource(OAuthSource):
+ """Social Login using GitHub.com or a GitHub-Enterprise Instance."""
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.oauth.forms import GitHubOAuthSourceForm
+
+ return GitHubOAuthSourceForm
+
+ class Meta:
+
+ abstract = True
+ verbose_name = _("GitHub OAuth Source")
+ verbose_name_plural = _("GitHub OAuth Sources")
+
+
+class TwitterOAuthSource(OAuthSource):
+ """Social Login using Twitter.com"""
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.oauth.forms import TwitterOAuthSourceForm
+
+ return TwitterOAuthSourceForm
+
+ class Meta:
+
+ abstract = True
+ verbose_name = _("Twitter OAuth Source")
+ verbose_name_plural = _("Twitter OAuth Sources")
+
+
+class FacebookOAuthSource(OAuthSource):
+ """Social Login using Facebook.com."""
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.oauth.forms import FacebookOAuthSourceForm
+
+ return FacebookOAuthSourceForm
+
+ class Meta:
+
+ abstract = True
+ verbose_name = _("Facebook OAuth Source")
+ verbose_name_plural = _("Facebook OAuth Sources")
+
+
+class DiscordOAuthSource(OAuthSource):
+ """Social Login using Discord."""
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.oauth.forms import DiscordOAuthSourceForm
+
+ return DiscordOAuthSourceForm
+
+ class Meta:
+
+ abstract = True
+ verbose_name = _("Discord OAuth Source")
+ verbose_name_plural = _("Discord OAuth Sources")
+
+
+class GoogleOAuthSource(OAuthSource):
+ """Social Login using Google or Gsuite."""
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.oauth.forms import GoogleOAuthSourceForm
+
+ return GoogleOAuthSourceForm
+
+ class Meta:
+
+ abstract = True
+ verbose_name = _("Google OAuth Source")
+ verbose_name_plural = _("Google OAuth Sources")
+
+
+class AzureADOAuthSource(OAuthSource):
+ """Social Login using Azure AD."""
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.oauth.forms import AzureADOAuthSourceForm
+
+ return AzureADOAuthSourceForm
+
+ class Meta:
+
+ abstract = True
+ verbose_name = _("Azure AD OAuth Source")
+ verbose_name_plural = _("Azure AD OAuth Sources")
+
+
+class OpenIDOAuthSource(OAuthSource):
+ """Login using a Generic OpenID-Connect compliant provider."""
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.oauth.forms import OAuthSourceForm
+
+ return OAuthSourceForm
+
+ class Meta:
+
+ abstract = True
+ verbose_name = _("OpenID OAuth Source")
+ verbose_name_plural = _("OpenID OAuth Sources")
+
+
+class UserOAuthSourceConnection(UserSourceConnection):
+ """Authorized remote OAuth provider."""
+
+ identifier = models.CharField(max_length=255)
+ access_token = models.TextField(blank=True, null=True, default=None)
+
+ def save(self, *args, **kwargs):
+ self.access_token = self.access_token or None
+ super().save(*args, **kwargs)
+
+ class Meta:
+
+ verbose_name = _("User OAuth Source Connection")
+ verbose_name_plural = _("User OAuth Source Connections")
diff --git a/authentik/sources/oauth/settings.py b/authentik/sources/oauth/settings.py
new file mode 100644
index 000000000..45792a85e
--- /dev/null
+++ b/authentik/sources/oauth/settings.py
@@ -0,0 +1,12 @@
+"""Oauth2 Client Settings"""
+
+AUTHENTIK_SOURCES_OAUTH_TYPES = [
+ "authentik.sources.oauth.types.discord",
+ "authentik.sources.oauth.types.facebook",
+ "authentik.sources.oauth.types.github",
+ "authentik.sources.oauth.types.google",
+ "authentik.sources.oauth.types.reddit",
+ "authentik.sources.oauth.types.twitter",
+ "authentik.sources.oauth.types.azure_ad",
+ "authentik.sources.oauth.types.oidc",
+]
diff --git a/authentik/sources/oauth/templates/oauth_client/user.html b/authentik/sources/oauth/templates/oauth_client/user.html
new file mode 100644
index 000000000..0576f0971
--- /dev/null
+++ b/authentik/sources/oauth/templates/oauth_client/user.html
@@ -0,0 +1,24 @@
+{% load i18n %}
+
+
diff --git a/authentik/sources/oauth/tests.py b/authentik/sources/oauth/tests.py
new file mode 100644
index 000000000..a6a22dff2
--- /dev/null
+++ b/authentik/sources/oauth/tests.py
@@ -0,0 +1,38 @@
+"""OAuth Source tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from authentik.sources.oauth.models import OAuthSource
+
+
+class OAuthSourceTests(TestCase):
+ """OAuth Source tests"""
+
+ def setUp(self):
+ self.client = Client()
+ self.source = OAuthSource.objects.create(
+ name="test",
+ slug="test",
+ provider_type="openid-connect",
+ authorization_url="",
+ profile_url="",
+ consumer_key="",
+ )
+
+ def test_source_redirect(self):
+ """test redirect view"""
+ self.client.get(
+ reverse(
+ "authentik_sources_oauth:oauth-client-login",
+ kwargs={"source_slug": self.source.slug},
+ )
+ )
+
+ def test_source_callback(self):
+ """test callback view"""
+ self.client.get(
+ reverse(
+ "authentik_sources_oauth:oauth-client-callback",
+ kwargs={"source_slug": self.source.slug},
+ )
+ )
diff --git a/passbook/sources/oauth/types/__init__.py b/authentik/sources/oauth/types/__init__.py
similarity index 100%
rename from passbook/sources/oauth/types/__init__.py
rename to authentik/sources/oauth/types/__init__.py
diff --git a/authentik/sources/oauth/types/azure_ad.py b/authentik/sources/oauth/types/azure_ad.py
new file mode 100644
index 000000000..4f370593e
--- /dev/null
+++ b/authentik/sources/oauth/types/azure_ad.py
@@ -0,0 +1,28 @@
+"""AzureAD OAuth2 Views"""
+from typing import Any, Dict
+from uuid import UUID
+
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+from authentik.sources.oauth.types.manager import MANAGER, RequestKind
+from authentik.sources.oauth.views.callback import OAuthCallback
+
+
+@MANAGER.source(kind=RequestKind.callback, name="Azure AD")
+class AzureADOAuthCallback(OAuthCallback):
+ """AzureAD OAuth2 Callback"""
+
+ def get_user_id(self, source: OAuthSource, info: Dict[str, Any]) -> str:
+ return str(UUID(info.get("objectId")).int)
+
+ def get_user_enroll_context(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ mail = info.get("mail", None) or info.get("otherMails", [None])[0]
+ return {
+ "username": info.get("displayName"),
+ "email": mail,
+ "name": info.get("displayName"),
+ }
diff --git a/authentik/sources/oauth/types/discord.py b/authentik/sources/oauth/types/discord.py
new file mode 100644
index 000000000..af0ec3e30
--- /dev/null
+++ b/authentik/sources/oauth/types/discord.py
@@ -0,0 +1,34 @@
+"""Discord OAuth Views"""
+from typing import Any, Dict
+
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+from authentik.sources.oauth.types.manager import MANAGER, RequestKind
+from authentik.sources.oauth.views.callback import OAuthCallback
+from authentik.sources.oauth.views.redirect import OAuthRedirect
+
+
+@MANAGER.source(kind=RequestKind.redirect, name="Discord")
+class DiscordOAuthRedirect(OAuthRedirect):
+ """Discord OAuth2 Redirect"""
+
+ def get_additional_parameters(self, source):
+ return {
+ "scope": "email identify",
+ }
+
+
+@MANAGER.source(kind=RequestKind.callback, name="Discord")
+class DiscordOAuth2Callback(OAuthCallback):
+ """Discord OAuth2 Callback"""
+
+ def get_user_enroll_context(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ return {
+ "username": info.get("username"),
+ "email": info.get("email", None),
+ "name": info.get("username"),
+ }
diff --git a/authentik/sources/oauth/types/facebook.py b/authentik/sources/oauth/types/facebook.py
new file mode 100644
index 000000000..78fcb0391
--- /dev/null
+++ b/authentik/sources/oauth/types/facebook.py
@@ -0,0 +1,47 @@
+"""Facebook OAuth Views"""
+from typing import Any, Dict, Optional
+
+from facebook import GraphAPI
+
+from authentik.sources.oauth.clients.oauth2 import OAuth2Client
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+from authentik.sources.oauth.types.manager import MANAGER, RequestKind
+from authentik.sources.oauth.views.callback import OAuthCallback
+from authentik.sources.oauth.views.redirect import OAuthRedirect
+
+
+@MANAGER.source(kind=RequestKind.redirect, name="Facebook")
+class FacebookOAuthRedirect(OAuthRedirect):
+ """Facebook OAuth2 Redirect"""
+
+ def get_additional_parameters(self, source):
+ return {
+ "scope": "email",
+ }
+
+
+class FacebookOAuth2Client(OAuth2Client):
+ """Facebook OAuth2 Client"""
+
+ def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]:
+ api = GraphAPI(access_token=token["access_token"])
+ return api.get_object("me", fields="id,name,email")
+
+
+@MANAGER.source(kind=RequestKind.callback, name="Facebook")
+class FacebookOAuth2Callback(OAuthCallback):
+ """Facebook OAuth2 Callback"""
+
+ client_class = FacebookOAuth2Client
+
+ def get_user_enroll_context(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ return {
+ "username": info.get("name"),
+ "email": info.get("email"),
+ "name": info.get("name"),
+ }
diff --git a/authentik/sources/oauth/types/github.py b/authentik/sources/oauth/types/github.py
new file mode 100644
index 000000000..b0abbb49a
--- /dev/null
+++ b/authentik/sources/oauth/types/github.py
@@ -0,0 +1,23 @@
+"""GitHub OAuth Views"""
+from typing import Any, Dict
+
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+from authentik.sources.oauth.types.manager import MANAGER, RequestKind
+from authentik.sources.oauth.views.callback import OAuthCallback
+
+
+@MANAGER.source(kind=RequestKind.callback, name="GitHub")
+class GitHubOAuth2Callback(OAuthCallback):
+ """GitHub OAuth2 Callback"""
+
+ def get_user_enroll_context(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ return {
+ "username": info.get("login"),
+ "email": info.get("email"),
+ "name": info.get("name"),
+ }
diff --git a/authentik/sources/oauth/types/google.py b/authentik/sources/oauth/types/google.py
new file mode 100644
index 000000000..813fd5eae
--- /dev/null
+++ b/authentik/sources/oauth/types/google.py
@@ -0,0 +1,34 @@
+"""Google OAuth Views"""
+from typing import Any, Dict
+
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+from authentik.sources.oauth.types.manager import MANAGER, RequestKind
+from authentik.sources.oauth.views.callback import OAuthCallback
+from authentik.sources.oauth.views.redirect import OAuthRedirect
+
+
+@MANAGER.source(kind=RequestKind.redirect, name="Google")
+class GoogleOAuthRedirect(OAuthRedirect):
+ """Google OAuth2 Redirect"""
+
+ def get_additional_parameters(self, source):
+ return {
+ "scope": "email profile",
+ }
+
+
+@MANAGER.source(kind=RequestKind.callback, name="Google")
+class GoogleOAuth2Callback(OAuthCallback):
+ """Google OAuth2 Callback"""
+
+ def get_user_enroll_context(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ return {
+ "username": info.get("email"),
+ "email": info.get("email"),
+ "name": info.get("name"),
+ }
diff --git a/authentik/sources/oauth/types/manager.py b/authentik/sources/oauth/types/manager.py
new file mode 100644
index 000000000..6824ab624
--- /dev/null
+++ b/authentik/sources/oauth/types/manager.py
@@ -0,0 +1,64 @@
+"""Source type manager"""
+from enum import Enum
+from typing import Callable, Dict, List
+
+from django.utils.text import slugify
+from structlog import get_logger
+
+from authentik.sources.oauth.models import OAuthSource
+from authentik.sources.oauth.views.callback import OAuthCallback
+from authentik.sources.oauth.views.redirect import OAuthRedirect
+
+LOGGER = get_logger()
+
+
+class RequestKind(Enum):
+ """Enum of OAuth Request types"""
+
+ callback = "callback"
+ redirect = "redirect"
+
+
+class SourceTypeManager:
+ """Manager to hold all Source types."""
+
+ __source_types: Dict[RequestKind, Dict[str, Callable]] = {}
+ __names: List[str] = []
+
+ def source(self, kind: RequestKind, name: str):
+ """Class decorator to register classes inline."""
+
+ def inner_wrapper(cls):
+ if kind.value not in self.__source_types:
+ self.__source_types[kind.value] = {}
+ self.__source_types[kind.value][slugify(name)] = cls
+ self.__names.append(name)
+ return cls
+
+ return inner_wrapper
+
+ def get_name_tuple(self):
+ """Get list of tuples of all registered names"""
+ return [(slugify(x), x) for x in set(self.__names)]
+
+ def find(self, source: OAuthSource, kind: RequestKind) -> Callable:
+ """Find fitting Source Type"""
+ if kind.value in self.__source_types:
+ if source.provider_type in self.__source_types[kind.value]:
+ return self.__source_types[kind.value][source.provider_type]
+ LOGGER.warning(
+ "no matching type found, using default",
+ wanted=source.provider_type,
+ have=self.__source_types[kind.value].keys(),
+ )
+ # Return defaults
+ if kind == RequestKind.callback:
+ return OAuthCallback
+ if kind == RequestKind.redirect:
+ return OAuthRedirect
+ raise KeyError(
+ f"Provider Type {source.provider_type} (type {kind.value}) not found."
+ )
+
+
+MANAGER = SourceTypeManager()
diff --git a/authentik/sources/oauth/types/oidc.py b/authentik/sources/oauth/types/oidc.py
new file mode 100644
index 000000000..90742b1c6
--- /dev/null
+++ b/authentik/sources/oauth/types/oidc.py
@@ -0,0 +1,37 @@
+"""OpenID Connect OAuth Views"""
+from typing import Any, Dict
+
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+from authentik.sources.oauth.types.manager import MANAGER, RequestKind
+from authentik.sources.oauth.views.callback import OAuthCallback
+from authentik.sources.oauth.views.redirect import OAuthRedirect
+
+
+@MANAGER.source(kind=RequestKind.redirect, name="OpenID Connect")
+class OpenIDConnectOAuthRedirect(OAuthRedirect):
+ """OpenIDConnect OAuth2 Redirect"""
+
+ def get_additional_parameters(self, source: OAuthSource):
+ return {
+ "scope": "openid email profile",
+ }
+
+
+@MANAGER.source(kind=RequestKind.callback, name="OpenID Connect")
+class OpenIDConnectOAuth2Callback(OAuthCallback):
+ """OpenIDConnect OAuth2 Callback"""
+
+ def get_user_id(self, source: OAuthSource, info: Dict[str, str]) -> str:
+ return info.get("sub", "")
+
+ def get_user_enroll_context(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ return {
+ "username": info.get("nickname"),
+ "email": info.get("email"),
+ "name": info.get("name"),
+ }
diff --git a/authentik/sources/oauth/types/reddit.py b/authentik/sources/oauth/types/reddit.py
new file mode 100644
index 000000000..33852a777
--- /dev/null
+++ b/authentik/sources/oauth/types/reddit.py
@@ -0,0 +1,50 @@
+"""Reddit OAuth Views"""
+from typing import Any, Dict
+
+from requests.auth import HTTPBasicAuth
+
+from authentik.sources.oauth.clients.oauth2 import OAuth2Client
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+from authentik.sources.oauth.types.manager import MANAGER, RequestKind
+from authentik.sources.oauth.views.callback import OAuthCallback
+from authentik.sources.oauth.views.redirect import OAuthRedirect
+
+
+@MANAGER.source(kind=RequestKind.redirect, name="reddit")
+class RedditOAuthRedirect(OAuthRedirect):
+ """Reddit OAuth2 Redirect"""
+
+ def get_additional_parameters(self, source):
+ return {
+ "scope": "identity",
+ "duration": "permanent",
+ }
+
+
+class RedditOAuth2Client(OAuth2Client):
+ """Reddit OAuth2 Client"""
+
+ def get_access_token(self, **request_kwargs):
+ "Fetch access token from callback request."
+ auth = HTTPBasicAuth(self.source.consumer_key, self.source.consumer_secret)
+ return super().get_access_token(auth=auth)
+
+
+@MANAGER.source(kind=RequestKind.callback, name="reddit")
+class RedditOAuth2Callback(OAuthCallback):
+ """Reddit OAuth2 Callback"""
+
+ client_class = RedditOAuth2Client
+
+ def get_user_enroll_context(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ return {
+ "username": info.get("name"),
+ "email": None,
+ "name": info.get("name"),
+ "password": None,
+ }
diff --git a/authentik/sources/oauth/types/twitter.py b/authentik/sources/oauth/types/twitter.py
new file mode 100644
index 000000000..bd27f22d4
--- /dev/null
+++ b/authentik/sources/oauth/types/twitter.py
@@ -0,0 +1,23 @@
+"""Twitter OAuth Views"""
+from typing import Any, Dict
+
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+from authentik.sources.oauth.types.manager import MANAGER, RequestKind
+from authentik.sources.oauth.views.callback import OAuthCallback
+
+
+@MANAGER.source(kind=RequestKind.callback, name="Twitter")
+class TwitterOAuthCallback(OAuthCallback):
+ """Twitter OAuth2 Callback"""
+
+ def get_user_enroll_context(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ return {
+ "username": info.get("screen_name"),
+ "email": info.get("email"),
+ "name": info.get("name"),
+ }
diff --git a/authentik/sources/oauth/urls.py b/authentik/sources/oauth/urls.py
new file mode 100644
index 000000000..085546d6d
--- /dev/null
+++ b/authentik/sources/oauth/urls.py
@@ -0,0 +1,30 @@
+"""authentik OAuth source urls"""
+
+from django.urls import path
+
+from authentik.sources.oauth.types.manager import RequestKind
+from authentik.sources.oauth.views.dispatcher import DispatcherView
+from authentik.sources.oauth.views.user import DisconnectView, UserSettingsView
+
+urlpatterns = [
+ path(
+ "login//",
+ DispatcherView.as_view(kind=RequestKind.redirect),
+ name="oauth-client-login",
+ ),
+ path(
+ "callback//",
+ DispatcherView.as_view(kind=RequestKind.callback),
+ name="oauth-client-callback",
+ ),
+ path(
+ "user//",
+ UserSettingsView.as_view(),
+ name="oauth-client-user",
+ ),
+ path(
+ "user//disconnect/",
+ DisconnectView.as_view(),
+ name="oauth-client-disconnect",
+ ),
+]
diff --git a/passbook/sources/oauth/views/__init__.py b/authentik/sources/oauth/views/__init__.py
similarity index 100%
rename from passbook/sources/oauth/views/__init__.py
rename to authentik/sources/oauth/views/__init__.py
diff --git a/authentik/sources/oauth/views/base.py b/authentik/sources/oauth/views/base.py
new file mode 100644
index 000000000..bfdd73faa
--- /dev/null
+++ b/authentik/sources/oauth/views/base.py
@@ -0,0 +1,27 @@
+"""OAuth Base views"""
+from typing import Optional, Type
+
+from django.http.request import HttpRequest
+
+from authentik.sources.oauth.clients.base import BaseOAuthClient
+from authentik.sources.oauth.clients.oauth1 import OAuthClient
+from authentik.sources.oauth.clients.oauth2 import OAuth2Client
+from authentik.sources.oauth.models import OAuthSource
+
+
+# pylint: disable=too-few-public-methods
+class OAuthClientMixin:
+ "Mixin for getting OAuth client for a source."
+
+ request: HttpRequest # Set by View class
+
+ client_class: Optional[Type[BaseOAuthClient]] = None
+
+ def get_client(self, source: OAuthSource, **kwargs) -> BaseOAuthClient:
+ "Get instance of the OAuth client for this source."
+ if self.client_class is not None:
+ # pylint: disable=not-callable
+ return self.client_class(source, self.request, **kwargs)
+ if source.request_token_url:
+ return OAuthClient(source, self.request, **kwargs)
+ return OAuth2Client(source, self.request, **kwargs)
diff --git a/authentik/sources/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py
new file mode 100644
index 000000000..c9874e785
--- /dev/null
+++ b/authentik/sources/oauth/views/callback.py
@@ -0,0 +1,234 @@
+"""OAuth Callback Views"""
+from typing import Any, Dict, Optional
+
+from django.conf import settings
+from django.contrib import messages
+from django.http import Http404, HttpRequest, HttpResponse
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.utils.translation import gettext as _
+from django.views.generic import View
+from structlog import get_logger
+
+from authentik.audit.models import Event, EventAction
+from authentik.core.models import User
+from authentik.flows.models import Flow, in_memory_stage
+from authentik.flows.planner import (
+ PLAN_CONTEXT_PENDING_USER,
+ PLAN_CONTEXT_SSO,
+ FlowPlanner,
+)
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.lib.utils.urls import redirect_with_qs
+from authentik.policies.utils import delete_none_keys
+from authentik.sources.oauth.auth import AuthorizedServiceBackend
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+from authentik.sources.oauth.views.base import OAuthClientMixin
+from authentik.sources.oauth.views.flows import (
+ PLAN_CONTEXT_SOURCES_OAUTH_ACCESS,
+ PostUserEnrollmentStage,
+)
+from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+LOGGER = get_logger()
+
+
+class OAuthCallback(OAuthClientMixin, View):
+ "Base OAuth callback view."
+
+ source_id = None
+ source = None
+
+ # pylint: disable=too-many-return-statements
+ def get(self, request: HttpRequest, *_, **kwargs) -> HttpResponse:
+ """View Get handler"""
+ slug = kwargs.get("source_slug", "")
+ try:
+ self.source = OAuthSource.objects.get(slug=slug)
+ except OAuthSource.DoesNotExist:
+ raise Http404(f"Unknown OAuth source '{slug}'.")
+
+ if not self.source.enabled:
+ raise Http404(f"Source {slug} is not enabled.")
+ client = self.get_client(
+ self.source, callback=self.get_callback_url(self.source)
+ )
+ # Fetch access token
+ token = client.get_access_token()
+ if token is None:
+ return self.handle_login_failure(self.source, "Could not retrieve token.")
+ if "error" in token:
+ return self.handle_login_failure(self.source, token["error"])
+ # Fetch profile info
+ info = client.get_profile_info(token)
+ if info is None:
+ return self.handle_login_failure(self.source, "Could not retrieve profile.")
+ identifier = self.get_user_id(self.source, info)
+ if identifier is None:
+ return self.handle_login_failure(self.source, "Could not determine id.")
+ # Get or create access record
+ defaults = {
+ "access_token": token.get("access_token"),
+ }
+ existing = UserOAuthSourceConnection.objects.filter(
+ source=self.source, identifier=identifier
+ )
+
+ if existing.exists():
+ connection = existing.first()
+ connection.access_token = token.get("access_token")
+ UserOAuthSourceConnection.objects.filter(pk=connection.pk).update(
+ **defaults
+ )
+ else:
+ connection = UserOAuthSourceConnection(
+ source=self.source,
+ identifier=identifier,
+ access_token=token.get("access_token"),
+ )
+ user = AuthorizedServiceBackend().authenticate(
+ source=self.source, identifier=identifier, request=request
+ )
+ if user is None:
+ if self.request.user.is_authenticated:
+ LOGGER.debug("Linking existing user", source=self.source)
+ return self.handle_existing_user_link(self.source, connection, info)
+ LOGGER.debug("Handling enrollment of new user", source=self.source)
+ return self.handle_enroll(self.source, connection, info)
+ LOGGER.debug("Handling existing user", source=self.source)
+ return self.handle_existing_user(self.source, user, connection, info)
+
+ # pylint: disable=unused-argument
+ def get_callback_url(self, source: OAuthSource) -> str:
+ "Return callback url if different than the current url."
+ return ""
+
+ # pylint: disable=unused-argument
+ def get_error_redirect(self, source: OAuthSource, reason: str) -> str:
+ "Return url to redirect on login failure."
+ return settings.LOGIN_URL
+
+ def get_user_enroll_context(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ """Create a dict of User data"""
+ raise NotImplementedError()
+
+ # pylint: disable=unused-argument
+ def get_user_id(
+ self, source: UserOAuthSourceConnection, info: Dict[str, Any]
+ ) -> Optional[str]:
+ """Return unique identifier from the profile info."""
+ if "id" in info:
+ return info["id"]
+ return None
+
+ def handle_login_failure(self, source: OAuthSource, reason: str) -> HttpResponse:
+ "Message user and redirect on error."
+ LOGGER.warning("Authentication Failure", reason=reason)
+ messages.error(self.request, _("Authentication Failed."))
+ return redirect(self.get_error_redirect(source, reason))
+
+ def handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse:
+ """Prepare Authentication Plan, redirect user FlowExecutor"""
+ 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,
+ }
+ )
+ # 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)
+ self.request.session[SESSION_KEY_PLAN] = plan
+ return redirect_with_qs(
+ "authentik_flows:flow-executor-shell",
+ self.request.GET,
+ flow_slug=flow.slug,
+ )
+
+ # pylint: disable=unused-argument
+ def handle_existing_user(
+ self,
+ source: OAuthSource,
+ user: User,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> HttpResponse:
+ "Login user and redirect."
+ messages.success(
+ self.request,
+ _(
+ "Successfully authenticated with %(source)s!"
+ % {"source": self.source.name}
+ ),
+ )
+ flow_kwargs = {PLAN_CONTEXT_PENDING_USER: user}
+ return self.handle_login_flow(source.authentication_flow, **flow_kwargs)
+
+ def handle_existing_user_link(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> HttpResponse:
+ """Handler when the user was already authenticated and linked an external source
+ to their account."""
+ # there's already a user logged in, just link them up
+ user = self.request.user
+ access.user = user
+ access.save()
+ UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
+ Event.new(
+ EventAction.SOURCE_LINKED, message="Linked OAuth Source", source=source
+ ).from_http(self.request)
+ messages.success(
+ self.request,
+ _("Successfully linked %(source)s!" % {"source": self.source.name}),
+ )
+ return redirect(
+ reverse(
+ "authentik_sources_oauth:oauth-client-user",
+ kwargs={"source_slug": self.source.slug},
+ )
+ )
+
+ def handle_enroll(
+ self,
+ source: OAuthSource,
+ access: UserOAuthSourceConnection,
+ info: Dict[str, Any],
+ ) -> HttpResponse:
+ """User was not authenticated and previous request was not authenticated."""
+ messages.success(
+ self.request,
+ _(
+ "Successfully authenticated with %(source)s!"
+ % {"source": self.source.name}
+ ),
+ )
+ # Because we inject a stage into the planned flow, we can't use `self.handle_login_flow`
+ context = {
+ # 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_PROMPT: delete_none_keys(
+ self.get_user_enroll_context(source, access, info)
+ ),
+ PLAN_CONTEXT_SOURCES_OAUTH_ACCESS: access,
+ }
+ # We run the Flow planner here so we can pass the Pending user in the context
+ planner = FlowPlanner(source.enrollment_flow)
+ plan = planner.plan(self.request, context)
+ plan.append(in_memory_stage(PostUserEnrollmentStage))
+ self.request.session[SESSION_KEY_PLAN] = plan
+ return redirect_with_qs(
+ "authentik_flows:flow-executor-shell",
+ self.request.GET,
+ flow_slug=source.enrollment_flow.slug,
+ )
diff --git a/authentik/sources/oauth/views/dispatcher.py b/authentik/sources/oauth/views/dispatcher.py
new file mode 100644
index 000000000..bb192ad7b
--- /dev/null
+++ b/authentik/sources/oauth/views/dispatcher.py
@@ -0,0 +1,26 @@
+"""Dispatch OAuth views to respective views"""
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+from django.views import View
+from structlog import get_logger
+
+from authentik.sources.oauth.models import OAuthSource
+from authentik.sources.oauth.types.manager import MANAGER, RequestKind
+
+LOGGER = get_logger()
+
+
+class DispatcherView(View):
+ """Dispatch OAuth Redirect/Callback views to their proper class based on URL parameters"""
+
+ kind = ""
+
+ def dispatch(self, *args, **kwargs):
+ """Find Source by slug and forward request"""
+ slug = kwargs.get("source_slug", None)
+ if not slug:
+ raise Http404
+ source = get_object_or_404(OAuthSource, slug=slug)
+ view = MANAGER.find(source, kind=RequestKind(self.kind))
+ LOGGER.debug("dispatching OAuth2 request to", view=view, kind=self.kind)
+ return view.as_view()(*args, **kwargs)
diff --git a/authentik/sources/oauth/views/flows.py b/authentik/sources/oauth/views/flows.py
new file mode 100644
index 000000000..ac326f1f0
--- /dev/null
+++ b/authentik/sources/oauth/views/flows.py
@@ -0,0 +1,30 @@
+"""OAuth Stages"""
+from django.http import HttpRequest, HttpResponse
+
+from authentik.audit.models import Event, EventAction
+from authentik.core.models import User
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.sources.oauth.models import UserOAuthSourceConnection
+
+PLAN_CONTEXT_SOURCES_OAUTH_ACCESS = "sources_oauth_access"
+
+
+class PostUserEnrollmentStage(StageView):
+ """Dynamically injected stage which saves the OAuth Connection after
+ the user has been enrolled."""
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ access: UserOAuthSourceConnection = self.executor.plan.context[
+ PLAN_CONTEXT_SOURCES_OAUTH_ACCESS
+ ]
+ user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ access.user = user
+ access.save()
+ UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
+ Event.new(
+ EventAction.SOURCE_LINKED,
+ message="Linked OAuth Source",
+ source=access.source,
+ ).from_http(self.request)
+ return self.executor.stage_ok()
diff --git a/authentik/sources/oauth/views/redirect.py b/authentik/sources/oauth/views/redirect.py
new file mode 100644
index 000000000..af1bb4dc2
--- /dev/null
+++ b/authentik/sources/oauth/views/redirect.py
@@ -0,0 +1,45 @@
+"""OAuth Redirect Views"""
+from typing import Any, Dict
+
+from django.http import Http404
+from django.urls import reverse
+from django.views.generic import RedirectView
+from structlog import get_logger
+
+from authentik.sources.oauth.models import OAuthSource
+from authentik.sources.oauth.views.base import OAuthClientMixin
+
+LOGGER = get_logger()
+
+
+class OAuthRedirect(OAuthClientMixin, RedirectView):
+ "Redirect user to OAuth source to enable access."
+
+ permanent = False
+ params = None
+
+ # pylint: disable=unused-argument
+ def get_additional_parameters(self, source: OAuthSource) -> Dict[str, Any]:
+ "Return additional redirect parameters for this source."
+ return self.params or {}
+
+ def get_callback_url(self, source: OAuthSource) -> str:
+ "Return the callback url for this source."
+ return reverse(
+ "authentik_sources_oauth:oauth-client-callback",
+ kwargs={"source_slug": source.slug},
+ )
+
+ def get_redirect_url(self, **kwargs) -> str:
+ "Build redirect url for a given source."
+ slug = kwargs.get("source_slug", "")
+ try:
+ source = OAuthSource.objects.get(slug=slug)
+ except OAuthSource.DoesNotExist:
+ raise Http404(f"Unknown OAuth source '{slug}'.")
+ else:
+ if not source.enabled:
+ raise Http404(f"source {slug} is not enabled.")
+ client = self.get_client(source, callback=self.get_callback_url(source))
+ params = self.get_additional_parameters(source)
+ return client.get_redirect_url(params)
diff --git a/authentik/sources/oauth/views/user.py b/authentik/sources/oauth/views/user.py
new file mode 100644
index 000000000..c6d7dbe5b
--- /dev/null
+++ b/authentik/sources/oauth/views/user.py
@@ -0,0 +1,70 @@
+"""authentik oauth_client user views"""
+from typing import Optional
+
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
+from django.utils.translation import gettext as _
+from django.views.generic import TemplateView, View
+
+from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
+
+
+class UserSettingsView(LoginRequiredMixin, TemplateView):
+ """Show user current connection state"""
+
+ template_name = "oauth_client/user.html"
+
+ def get_context_data(self, **kwargs):
+ source = get_object_or_404(OAuthSource, slug=self.kwargs.get("source_slug"))
+ connections = UserOAuthSourceConnection.objects.filter(
+ user=self.request.user, source=source
+ )
+ kwargs["source"] = source
+ kwargs["connections"] = connections
+ return super().get_context_data(**kwargs)
+
+
+class DisconnectView(LoginRequiredMixin, View):
+ """Delete connection with source"""
+
+ source: Optional[OAuthSource] = None
+ aas: Optional[UserOAuthSourceConnection] = None
+
+ def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse:
+ self.source = get_object_or_404(OAuthSource, slug=source_slug)
+ self.aas = get_object_or_404(
+ UserOAuthSourceConnection, source=self.source, user=request.user
+ )
+ return super().dispatch(request, source_slug)
+
+ def post(self, request: HttpRequest, source_slug: str) -> HttpResponse:
+ """Delete connection object"""
+ if "confirmdelete" in request.POST:
+ # User confirmed deletion
+ self.aas.delete()
+ messages.success(request, _("Connection successfully deleted"))
+ return redirect(
+ reverse(
+ "authentik_sources_oauth:oauth-client-user",
+ kwargs={"source_slug": self.source.slug},
+ )
+ )
+ return self.get(request, source_slug)
+
+ # pylint: disable=unused-argument
+ def get(self, request: HttpRequest, source_slug: str) -> HttpResponse:
+ """Show delete form"""
+ return render(
+ request,
+ "generic/delete.html",
+ {
+ "object": self.source,
+ "delete_url": reverse(
+ "authentik_sources_oauth:oauth-client-disconnect",
+ kwargs={"source_slug": self.source.slug},
+ ),
+ },
+ )
diff --git a/passbook/sources/saml/__init__.py b/authentik/sources/saml/__init__.py
similarity index 100%
rename from passbook/sources/saml/__init__.py
rename to authentik/sources/saml/__init__.py
diff --git a/authentik/sources/saml/api.py b/authentik/sources/saml/api.py
new file mode 100644
index 000000000..cb2c57ca4
--- /dev/null
+++ b/authentik/sources/saml/api.py
@@ -0,0 +1,33 @@
+"""SAMLSource API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.admin.forms.source import SOURCE_FORM_FIELDS
+from authentik.sources.saml.models import SAMLSource
+
+
+class SAMLSourceSerializer(ModelSerializer):
+ """SAMLSource Serializer"""
+
+ class Meta:
+
+ model = SAMLSource
+ fields = SOURCE_FORM_FIELDS + [
+ "issuer",
+ "sso_url",
+ "slo_url",
+ "allow_idp_initiated",
+ "name_id_policy",
+ "binding_type",
+ "signing_kp",
+ "digest_algorithm",
+ "signature_algorithm",
+ "temporary_user_delete_after",
+ ]
+
+
+class SAMLSourceViewSet(ModelViewSet):
+ """SAMLSource Viewset"""
+
+ queryset = SAMLSource.objects.all()
+ serializer_class = SAMLSourceSerializer
diff --git a/authentik/sources/saml/apps.py b/authentik/sources/saml/apps.py
new file mode 100644
index 000000000..5b3615517
--- /dev/null
+++ b/authentik/sources/saml/apps.py
@@ -0,0 +1,17 @@
+"""Authentik SAML app config"""
+
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class AuthentikSourceSAMLConfig(AppConfig):
+ """authentik saml_idp app config"""
+
+ name = "authentik.sources.saml"
+ label = "authentik_sources_saml"
+ verbose_name = "authentik Sources.SAML"
+ mountpoint = "source/saml/"
+
+ def ready(self):
+ import_module("authentik.sources.saml.signals")
diff --git a/authentik/sources/saml/exceptions.py b/authentik/sources/saml/exceptions.py
new file mode 100644
index 000000000..09f7afbff
--- /dev/null
+++ b/authentik/sources/saml/exceptions.py
@@ -0,0 +1,18 @@
+"""authentik saml source exceptions"""
+from authentik.lib.sentry import SentryIgnoredException
+
+
+class MissingSAMLResponse(SentryIgnoredException):
+ """Exception raised when request does not contain SAML Response."""
+
+
+class UnsupportedNameIDFormat(SentryIgnoredException):
+ """Exception raised when SAML Response contains NameID Format not supported."""
+
+
+class MismatchedRequestID(SentryIgnoredException):
+ """Exception raised when the returned request ID doesn't match the saved ID."""
+
+
+class InvalidSignature(SentryIgnoredException):
+ """Signature of XML Object is either missing or invalid"""
diff --git a/authentik/sources/saml/forms.py b/authentik/sources/saml/forms.py
new file mode 100644
index 000000000..bd2fdcf9f
--- /dev/null
+++ b/authentik/sources/saml/forms.py
@@ -0,0 +1,49 @@
+"""authentik SAML SP Forms"""
+
+from django import forms
+
+from authentik.admin.forms.source import SOURCE_FORM_FIELDS
+from authentik.crypto.models import CertificateKeyPair
+from authentik.flows.models import Flow, FlowDesignation
+from authentik.sources.saml.models import SAMLSource
+
+
+class SAMLSourceForm(forms.ModelForm):
+ """SAML Provider form"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.fields["authentication_flow"].queryset = Flow.objects.filter(
+ designation=FlowDesignation.AUTHENTICATION
+ )
+ self.fields["enrollment_flow"].queryset = Flow.objects.filter(
+ designation=FlowDesignation.ENROLLMENT
+ )
+ self.fields["signing_kp"].queryset = CertificateKeyPair.objects.filter(
+ certificate_data__isnull=False,
+ key_data__isnull=False,
+ )
+
+ class Meta:
+
+ model = SAMLSource
+ fields = SOURCE_FORM_FIELDS + [
+ "issuer",
+ "sso_url",
+ "slo_url",
+ "binding_type",
+ "name_id_policy",
+ "allow_idp_initiated",
+ "signing_kp",
+ "digest_algorithm",
+ "signature_algorithm",
+ "temporary_user_delete_after",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "issuer": forms.TextInput(),
+ "sso_url": forms.TextInput(),
+ "slo_url": forms.TextInput(),
+ "temporary_user_delete_after": forms.TextInput(),
+ }
diff --git a/authentik/sources/saml/migrations/0001_initial.py b/authentik/sources/saml/migrations/0001_initial.py
new file mode 100644
index 000000000..775d9bb5f
--- /dev/null
+++ b/authentik/sources/saml/migrations/0001_initial.py
@@ -0,0 +1,68 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_crypto", "0001_initial"),
+ ("authentik_core", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="SAMLSource",
+ fields=[
+ (
+ "source_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_core.Source",
+ ),
+ ),
+ (
+ "issuer",
+ models.TextField(
+ blank=True,
+ default=None,
+ help_text="Also known as Entity ID. Defaults the Metadata URL.",
+ verbose_name="Issuer",
+ ),
+ ),
+ ("idp_url", models.URLField(verbose_name="IDP URL")),
+ (
+ "idp_logout_url",
+ models.URLField(
+ blank=True,
+ default=None,
+ null=True,
+ verbose_name="IDP Logout URL",
+ ),
+ ),
+ ("auto_logout", models.BooleanField(default=False)),
+ (
+ "signing_kp",
+ models.ForeignKey(
+ default=None,
+ help_text="Certificate Key Pair of the IdP which Assertions are validated against.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_crypto.CertificateKeyPair",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "SAML Source",
+ "verbose_name_plural": "SAML Sources",
+ },
+ bases=("authentik_core.source",),
+ ),
+ ]
diff --git a/authentik/sources/saml/migrations/0002_auto_20200523_2329.py b/authentik/sources/saml/migrations/0002_auto_20200523_2329.py
new file mode 100644
index 000000000..b1397f78b
--- /dev/null
+++ b/authentik/sources/saml/migrations/0002_auto_20200523_2329.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.0.6 on 2020-05-23 23:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_saml", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="samlsource",
+ name="binding_type",
+ field=models.CharField(
+ choices=[("REDIRECT", "Redirect"), ("POST", "Post")],
+ default="REDIRECT",
+ max_length=100,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlsource",
+ name="idp_url",
+ field=models.URLField(
+ help_text="URL that the initial SAML Request is sent to. Also known as a Binding.",
+ verbose_name="IDP URL",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/saml/migrations/0003_auto_20200624_1957.py b/authentik/sources/saml/migrations/0003_auto_20200624_1957.py
new file mode 100644
index 000000000..cf0db37a8
--- /dev/null
+++ b/authentik/sources/saml/migrations/0003_auto_20200624_1957.py
@@ -0,0 +1,70 @@
+# Generated by Django 3.0.7 on 2020-06-24 19:57
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import authentik.lib.utils.time
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_crypto", "0002_create_self_signed_kp"),
+ ("authentik_sources_saml", "0002_auto_20200523_2329"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="samlsource",
+ name="auto_logout",
+ ),
+ migrations.RenameField(
+ model_name="samlsource",
+ old_name="idp_url",
+ new_name="sso_url",
+ ),
+ migrations.RenameField(
+ model_name="samlsource",
+ old_name="idp_logout_url",
+ new_name="slo_url",
+ ),
+ migrations.AddField(
+ model_name="samlsource",
+ name="temporary_user_delete_after",
+ field=models.TextField(
+ default="days=1",
+ help_text="Time offset when temporary users should be deleted. This only applies if your IDP uses the NameID Format 'transient', and the user doesn't log out manually. (Format: hours=1;minutes=2;seconds=3).",
+ validators=[authentik.lib.utils.time.timedelta_string_validator],
+ verbose_name="Delete temporary users after",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlsource",
+ name="signing_kp",
+ field=models.ForeignKey(
+ help_text="Certificate Key Pair of the IdP which Assertion's Signature is validated against.",
+ on_delete=django.db.models.deletion.PROTECT,
+ to="authentik_crypto.CertificateKeyPair",
+ verbose_name="Singing Keypair",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlsource",
+ name="slo_url",
+ field=models.URLField(
+ blank=True,
+ default=None,
+ help_text="Optional URL if your IDP supports Single-Logout.",
+ null=True,
+ verbose_name="SLO URL",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlsource",
+ name="sso_url",
+ field=models.URLField(
+ help_text="URL that the initial Login request is sent to.",
+ verbose_name="SSO URL",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/saml/migrations/0004_auto_20200708_1207.py b/authentik/sources/saml/migrations/0004_auto_20200708_1207.py
new file mode 100644
index 000000000..836510d84
--- /dev/null
+++ b/authentik/sources/saml/migrations/0004_auto_20200708_1207.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.0.8 on 2020-07-08 12:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_saml", "0003_auto_20200624_1957"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="samlsource",
+ name="binding_type",
+ field=models.CharField(
+ choices=[
+ ("REDIRECT", "Redirect Binding"),
+ ("POST", "POST Binding"),
+ ("POST_AUTO", "POST Binding with auto-confirmation"),
+ ],
+ default="REDIRECT",
+ max_length=100,
+ ),
+ ),
+ ]
diff --git a/authentik/sources/saml/migrations/0005_samlsource_name_id_policy.py b/authentik/sources/saml/migrations/0005_samlsource_name_id_policy.py
new file mode 100644
index 000000000..4269255e3
--- /dev/null
+++ b/authentik/sources/saml/migrations/0005_samlsource_name_id_policy.py
@@ -0,0 +1,40 @@
+# Generated by Django 3.0.8 on 2020-07-08 13:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_saml", "0004_auto_20200708_1207"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="samlsource",
+ name="name_id_policy",
+ field=models.TextField(
+ choices=[
+ ("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "Email"),
+ (
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
+ "Persistent",
+ ),
+ (
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectName",
+ "X509",
+ ),
+ (
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
+ "Windows",
+ ),
+ (
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
+ "Transient",
+ ),
+ ],
+ default="urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
+ help_text="NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent.",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py b/authentik/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py
new file mode 100644
index 000000000..06aca5877
--- /dev/null
+++ b/authentik/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1.1 on 2020-09-11 22:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_saml", "0005_samlsource_name_id_policy"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="samlsource",
+ name="allow_idp_initiated",
+ field=models.BooleanField(
+ default=False,
+ help_text="Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done.",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/saml/migrations/0007_auto_20201112_1055.py b/authentik/sources/saml/migrations/0007_auto_20201112_1055.py
new file mode 100644
index 000000000..d1b7d91a7
--- /dev/null
+++ b/authentik/sources/saml/migrations/0007_auto_20201112_1055.py
@@ -0,0 +1,51 @@
+# Generated by Django 3.1.3 on 2020-11-12 10:55
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_crypto", "0002_create_self_signed_kp"),
+ ("authentik_sources_saml", "0006_samlsource_allow_idp_initiated"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="samlsource",
+ name="digest_algorithm",
+ field=models.CharField(
+ choices=[("sha1", "SHA1"), ("sha256", "SHA256")],
+ default="sha256",
+ max_length=50,
+ ),
+ ),
+ migrations.AddField(
+ model_name="samlsource",
+ name="signature_algorithm",
+ field=models.CharField(
+ choices=[
+ ("rsa-sha1", "RSA-SHA1"),
+ ("rsa-sha256", "RSA-SHA256"),
+ ("ecdsa-sha256", "ECDSA-SHA256"),
+ ("dsa-sha1", "DSA-SHA1"),
+ ],
+ default="rsa-sha256",
+ max_length=50,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlsource",
+ name="signing_kp",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="Keypair which is used to sign outgoing requests. Leave empty to disable signing.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ to="authentik_crypto.certificatekeypair",
+ verbose_name="Singing Keypair",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/saml/migrations/0008_auto_20201112_2016.py b/authentik/sources/saml/migrations/0008_auto_20201112_2016.py
new file mode 100644
index 000000000..dc823e6ae
--- /dev/null
+++ b/authentik/sources/saml/migrations/0008_auto_20201112_2016.py
@@ -0,0 +1,70 @@
+# Generated by Django 3.1.3 on 2020-11-12 20:16
+
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.sources.saml.processors import constants
+
+
+def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ SAMLSource = apps.get_model("authentik_sources_saml", "SAMLSource")
+ signature_translation_map = {
+ "rsa-sha1": constants.RSA_SHA1,
+ "rsa-sha256": constants.RSA_SHA256,
+ "ecdsa-sha256": constants.RSA_SHA256,
+ "dsa-sha1": constants.DSA_SHA1,
+ }
+ digest_translation_map = {
+ "sha1": constants.SHA1,
+ "sha256": constants.SHA256,
+ }
+
+ for source in SAMLSource.objects.all():
+ source.signature_algorithm = signature_translation_map.get(
+ source.signature_algorithm, constants.RSA_SHA256
+ )
+ source.digest_algorithm = digest_translation_map.get(
+ source.digest_algorithm, constants.SHA256
+ )
+ source.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_saml", "0007_auto_20201112_1055"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="samlsource",
+ name="signature_algorithm",
+ field=models.CharField(
+ choices=[
+ (constants.RSA_SHA1, "RSA-SHA1"),
+ (constants.RSA_SHA256, "RSA-SHA256"),
+ (constants.RSA_SHA384, "RSA-SHA384"),
+ (constants.RSA_SHA512, "RSA-SHA512"),
+ (constants.DSA_SHA1, "DSA-SHA1"),
+ ],
+ default=constants.RSA_SHA256,
+ max_length=50,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="samlsource",
+ name="digest_algorithm",
+ field=models.CharField(
+ choices=[
+ (constants.SHA1, "SHA1"),
+ (constants.SHA256, "SHA256"),
+ (constants.SHA384, "SHA384"),
+ (constants.SHA512, "SHA512"),
+ ],
+ default=constants.SHA256,
+ max_length=50,
+ ),
+ ),
+ migrations.RunPython(update_algorithms),
+ ]
diff --git a/passbook/sources/saml/migrations/__init__.py b/authentik/sources/saml/migrations/__init__.py
similarity index 100%
rename from passbook/sources/saml/migrations/__init__.py
rename to authentik/sources/saml/migrations/__init__.py
diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py
new file mode 100644
index 000000000..a68c8cb97
--- /dev/null
+++ b/authentik/sources/saml/models.py
@@ -0,0 +1,181 @@
+"""saml sp models"""
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.http import HttpRequest
+from django.shortcuts import reverse
+from django.urls import reverse_lazy
+from django.utils.translation import gettext_lazy as _
+
+from authentik.core.models import Source
+from authentik.core.types import UILoginButton
+from authentik.crypto.models import CertificateKeyPair
+from authentik.lib.utils.time import timedelta_string_validator
+from authentik.sources.saml.processors.constants import (
+ DSA_SHA1,
+ RSA_SHA1,
+ RSA_SHA256,
+ RSA_SHA384,
+ RSA_SHA512,
+ SAML_NAME_ID_FORMAT_EMAIL,
+ SAML_NAME_ID_FORMAT_PERSISTENT,
+ SAML_NAME_ID_FORMAT_TRANSIENT,
+ SAML_NAME_ID_FORMAT_WINDOWS,
+ SAML_NAME_ID_FORMAT_X509,
+ SHA1,
+ SHA256,
+ SHA384,
+ SHA512,
+)
+
+
+class SAMLBindingTypes(models.TextChoices):
+ """SAML Binding types"""
+
+ Redirect = "REDIRECT", _("Redirect Binding")
+ POST = "POST", _("POST Binding")
+ POST_AUTO = "POST_AUTO", _("POST Binding with auto-confirmation")
+
+
+class SAMLNameIDPolicy(models.TextChoices):
+ """SAML NameID Policies"""
+
+ EMAIL = SAML_NAME_ID_FORMAT_EMAIL
+ PERSISTENT = SAML_NAME_ID_FORMAT_PERSISTENT
+ X509 = SAML_NAME_ID_FORMAT_X509
+ WINDOWS = SAML_NAME_ID_FORMAT_WINDOWS
+ TRANSIENT = SAML_NAME_ID_FORMAT_TRANSIENT
+
+
+class SAMLSource(Source):
+ """Authenticate using an external SAML Identity Provider."""
+
+ issuer = models.TextField(
+ blank=True,
+ default=None,
+ verbose_name=_("Issuer"),
+ help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
+ )
+
+ sso_url = models.URLField(
+ verbose_name=_("SSO URL"),
+ help_text=_("URL that the initial Login request is sent to."),
+ )
+ slo_url = models.URLField(
+ default=None,
+ blank=True,
+ null=True,
+ verbose_name=_("SLO URL"),
+ help_text=_("Optional URL if your IDP supports Single-Logout."),
+ )
+
+ allow_idp_initiated = models.BooleanField(
+ default=False,
+ help_text=_(
+ "Allows authentication flows initiated by the IdP. This can be a security risk, "
+ "as no validation of the request ID is done."
+ ),
+ )
+ name_id_policy = models.TextField(
+ choices=SAMLNameIDPolicy.choices,
+ default=SAMLNameIDPolicy.TRANSIENT,
+ help_text=_(
+ "NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent."
+ ),
+ )
+ binding_type = models.CharField(
+ max_length=100,
+ choices=SAMLBindingTypes.choices,
+ default=SAMLBindingTypes.Redirect,
+ )
+
+ temporary_user_delete_after = models.TextField(
+ default="days=1",
+ verbose_name=_("Delete temporary users after"),
+ validators=[timedelta_string_validator],
+ help_text=_(
+ (
+ "Time offset when temporary users should be deleted. This only applies if your IDP "
+ "uses the NameID Format 'transient', and the user doesn't log out manually. "
+ "(Format: hours=1;minutes=2;seconds=3)."
+ )
+ ),
+ )
+
+ signing_kp = models.ForeignKey(
+ CertificateKeyPair,
+ default=None,
+ blank=True,
+ null=True,
+ verbose_name=_("Singing Keypair"),
+ help_text=_(
+ "Keypair which is used to sign outgoing requests. Leave empty to disable signing."
+ ),
+ on_delete=models.SET_DEFAULT,
+ )
+
+ digest_algorithm = models.CharField(
+ max_length=50,
+ choices=(
+ (SHA1, _("SHA1")),
+ (SHA256, _("SHA256")),
+ (SHA384, _("SHA384")),
+ (SHA512, _("SHA512")),
+ ),
+ default=SHA256,
+ )
+ signature_algorithm = models.CharField(
+ max_length=50,
+ choices=(
+ (RSA_SHA1, _("RSA-SHA1")),
+ (RSA_SHA256, _("RSA-SHA256")),
+ (RSA_SHA384, _("RSA-SHA384")),
+ (RSA_SHA512, _("RSA-SHA512")),
+ (DSA_SHA1, _("DSA-SHA1")),
+ ),
+ default=RSA_SHA256,
+ )
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.sources.saml.forms import SAMLSourceForm
+
+ return SAMLSourceForm
+
+ def get_issuer(self, request: HttpRequest) -> str:
+ """Get Source's Issuer, falling back to our Metadata URL if none is set"""
+ if self.issuer is None:
+ return self.build_full_url(request, view="metadata")
+ return self.issuer
+
+ def build_full_url(self, request: HttpRequest, view: str = "acs") -> str:
+ """Build Full ACS URL to be used in IDP"""
+ return request.build_absolute_uri(
+ reverse(f"authentik_sources_saml:{view}", kwargs={"source_slug": self.slug})
+ )
+
+ @property
+ def ui_login_button(self) -> UILoginButton:
+ return UILoginButton(
+ name=self.name,
+ url=reverse_lazy(
+ "authentik_sources_saml:login", kwargs={"source_slug": self.slug}
+ ),
+ icon_path="",
+ )
+
+ @property
+ def ui_additional_info(self) -> str:
+ metadata_url = reverse_lazy(
+ "authentik_sources_saml:metadata", kwargs={"source_slug": self.slug}
+ )
+ return f'Metadata Download '
+
+ def __str__(self):
+ return f"SAML Source {self.name}"
+
+ class Meta:
+
+ verbose_name = _("SAML Source")
+ verbose_name_plural = _("SAML Sources")
diff --git a/passbook/sources/saml/processors/__init__.py b/authentik/sources/saml/processors/__init__.py
similarity index 100%
rename from passbook/sources/saml/processors/__init__.py
rename to authentik/sources/saml/processors/__init__.py
diff --git a/passbook/sources/saml/processors/constants.py b/authentik/sources/saml/processors/constants.py
similarity index 100%
rename from passbook/sources/saml/processors/constants.py
rename to authentik/sources/saml/processors/constants.py
diff --git a/authentik/sources/saml/processors/metadata.py b/authentik/sources/saml/processors/metadata.py
new file mode 100644
index 000000000..b379db67b
--- /dev/null
+++ b/authentik/sources/saml/processors/metadata.py
@@ -0,0 +1,93 @@
+"""SAML Service Provider Metadata Processor"""
+from typing import Iterator, Optional
+
+from django.http import HttpRequest
+from lxml.etree import Element, SubElement, tostring # nosec
+
+from authentik.providers.saml.utils.encoding import strip_pem_header
+from authentik.sources.saml.models import SAMLSource
+from authentik.sources.saml.processors.constants import (
+ NS_MAP,
+ NS_SAML_METADATA,
+ NS_SIGNATURE,
+ SAML_BINDING_POST,
+ SAML_NAME_ID_FORMAT_EMAIL,
+ SAML_NAME_ID_FORMAT_PERSISTENT,
+ SAML_NAME_ID_FORMAT_TRANSIENT,
+ SAML_NAME_ID_FORMAT_WINDOWS,
+ SAML_NAME_ID_FORMAT_X509,
+)
+
+
+class MetadataProcessor:
+ """SAML Service Provider Metadata Processor"""
+
+ source: SAMLSource
+ http_request: HttpRequest
+
+ def __init__(self, source: SAMLSource, request: HttpRequest):
+ self.source = source
+ self.http_request = request
+
+ def get_signing_key_descriptor(self) -> Optional[Element]:
+ """Get Singing KeyDescriptor, if enabled for the source"""
+ if self.source.signing_kp:
+ key_descriptor = Element(f"{{{NS_SAML_METADATA}}}KeyDescriptor")
+ key_descriptor.attrib["use"] = "signing"
+ key_info = SubElement(key_descriptor, f"{{{NS_SIGNATURE}}}KeyInfo")
+ x509_data = SubElement(key_info, f"{{{NS_SIGNATURE}}}X509Data")
+ x509_certificate = SubElement(
+ x509_data, f"{{{NS_SIGNATURE}}}X509Certificate"
+ )
+ x509_certificate.text = strip_pem_header(
+ self.source.signing_kp.certificate_data.replace("\r", "")
+ ).replace("\n", "")
+ return key_descriptor
+ return None
+
+ def get_name_id_formats(self) -> Iterator[Element]:
+ """Get compatible NameID Formats"""
+ formats = [
+ SAML_NAME_ID_FORMAT_EMAIL,
+ SAML_NAME_ID_FORMAT_PERSISTENT,
+ SAML_NAME_ID_FORMAT_X509,
+ SAML_NAME_ID_FORMAT_WINDOWS,
+ SAML_NAME_ID_FORMAT_TRANSIENT,
+ ]
+ for name_id_format in formats:
+ element = Element(f"{{{NS_SAML_METADATA}}}NameIDFormat")
+ element.text = name_id_format
+ yield element
+
+ def build_entity_descriptor(self) -> str:
+ """Build full EntityDescriptor"""
+ entity_descriptor = Element(
+ f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP
+ )
+ entity_descriptor.attrib["entityID"] = self.source.get_issuer(self.http_request)
+
+ sp_sso_descriptor = SubElement(
+ entity_descriptor, f"{{{NS_SAML_METADATA}}}SPSSODescriptor"
+ )
+ sp_sso_descriptor.attrib[
+ "protocolSupportEnumeration"
+ ] = "urn:oasis:names:tc:SAML:2.0:protocol"
+
+ signing_descriptor = self.get_signing_key_descriptor()
+ if signing_descriptor is not None:
+ sp_sso_descriptor.append(signing_descriptor)
+
+ for name_id_format in self.get_name_id_formats():
+ sp_sso_descriptor.append(name_id_format)
+
+ assertion_consumer_service = SubElement(
+ sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}AssertionConsumerService"
+ )
+ assertion_consumer_service.attrib["isDefault"] = "true"
+ assertion_consumer_service.attrib["index"] = "0"
+ assertion_consumer_service.attrib["Binding"] = SAML_BINDING_POST
+ assertion_consumer_service.attrib["Location"] = self.source.build_full_url(
+ self.http_request
+ )
+
+ return tostring(entity_descriptor).decode()
diff --git a/authentik/sources/saml/processors/request.py b/authentik/sources/saml/processors/request.py
new file mode 100644
index 000000000..7c9cbd7f3
--- /dev/null
+++ b/authentik/sources/saml/processors/request.py
@@ -0,0 +1,172 @@
+"""SAML AuthnRequest Processor"""
+from base64 import b64encode
+from typing import Dict
+from urllib.parse import quote_plus
+
+import xmlsec
+from django.http import HttpRequest
+from lxml import etree # nosec
+from lxml.etree import Element # nosec
+
+from authentik.providers.saml.utils import get_random_id
+from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
+from authentik.providers.saml.utils.time import get_time_string
+from authentik.sources.saml.models import SAMLSource
+from authentik.sources.saml.processors.constants import (
+ DIGEST_ALGORITHM_TRANSLATION_MAP,
+ NS_MAP,
+ NS_SAML_ASSERTION,
+ NS_SAML_PROTOCOL,
+ SIGN_ALGORITHM_TRANSFORM_MAP,
+)
+
+SESSION_REQUEST_ID = "authentik_source_saml_request_id"
+
+
+class RequestProcessor:
+ """SAML AuthnRequest Processor"""
+
+ source: SAMLSource
+ http_request: HttpRequest
+
+ relay_state: str
+
+ request_id: str
+ issue_instant: str
+
+ def __init__(self, source: SAMLSource, request: HttpRequest, relay_state: str):
+ self.source = source
+ self.http_request = request
+ self.relay_state = relay_state
+ self.request_id = get_random_id()
+ self.http_request.session[SESSION_REQUEST_ID] = self.request_id
+ self.issue_instant = get_time_string()
+
+ def get_issuer(self) -> Element:
+ """Get Issuer Element"""
+ issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
+ issuer.text = self.source.get_issuer(self.http_request)
+ return issuer
+
+ def get_name_id_policy(self) -> Element:
+ """Get NameID Policy Element"""
+ name_id_policy = Element(f"{{{NS_SAML_PROTOCOL}}}NameIDPolicy")
+ name_id_policy.attrib["Format"] = self.source.name_id_policy
+ return name_id_policy
+
+ def get_auth_n(self) -> Element:
+ """Get full AuthnRequest"""
+ auth_n_request = Element(f"{{{NS_SAML_PROTOCOL}}}AuthnRequest", nsmap=NS_MAP)
+ auth_n_request.attrib[
+ "AssertionConsumerServiceURL"
+ ] = self.source.build_full_url(self.http_request)
+ auth_n_request.attrib["Destination"] = self.source.sso_url
+ auth_n_request.attrib["ID"] = self.request_id
+ auth_n_request.attrib["IssueInstant"] = self.issue_instant
+ auth_n_request.attrib["ProtocolBinding"] = self.source.binding_type
+ auth_n_request.attrib["Version"] = "2.0"
+ # Create issuer object
+ auth_n_request.append(self.get_issuer())
+
+ if self.source.signing_kp:
+ sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
+ self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
+ )
+ signature = xmlsec.template.create(
+ auth_n_request,
+ xmlsec.constants.TransformExclC14N,
+ sign_algorithm_transform,
+ ns="ds", # type: ignore
+ )
+ auth_n_request.append(signature)
+
+ # Create NameID Policy Object
+ auth_n_request.append(self.get_name_id_policy())
+ return auth_n_request
+
+ def build_auth_n(self) -> str:
+ """Get Signed string representation of AuthN Request
+ (used for POST Bindings)"""
+ auth_n_request = self.get_auth_n()
+
+ if self.source.signing_kp:
+ xmlsec.tree.add_ids(auth_n_request, ["ID"])
+
+ ctx = xmlsec.SignatureContext()
+
+ key = xmlsec.Key.from_memory(
+ self.source.signing_kp.key_data, xmlsec.constants.KeyDataFormatPem, None
+ )
+ key.load_cert_from_memory(
+ self.source.signing_kp.certificate_data,
+ xmlsec.constants.KeyDataFormatCertPem,
+ )
+ ctx.key = key
+
+ digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
+ self.source.digest_algorithm, xmlsec.constants.TransformSha1
+ )
+
+ signature_node = xmlsec.tree.find_node(
+ auth_n_request, xmlsec.constants.NodeSignature
+ )
+
+ ref = xmlsec.template.add_reference(
+ signature_node,
+ digest_algorithm_transform,
+ uri="#" + auth_n_request.attrib["ID"],
+ )
+ xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
+ xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
+ key_info = xmlsec.template.ensure_key_info(signature_node)
+ xmlsec.template.add_x509_data(key_info)
+
+ ctx.sign(signature_node)
+
+ return etree.tostring(auth_n_request).decode()
+
+ def build_auth_n_detached(self) -> Dict[str, str]:
+ """Get Dict AuthN Request for Redirect bindings, with detached
+ Signature. See https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf"""
+ auth_n_request = self.get_auth_n()
+
+ saml_request = deflate_and_base64_encode(
+ etree.tostring(auth_n_request).decode()
+ )
+
+ response_dict = {
+ "SAMLRequest": saml_request,
+ }
+
+ if self.relay_state != "":
+ response_dict["RelayState"] = self.relay_state
+
+ if self.source.signing_kp:
+ sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
+ self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
+ )
+
+ # Create the full querystring in the correct order to be signed
+ querystring = f"SAMLRequest={quote_plus(saml_request)}&"
+ if "RelayState" in response_dict:
+ querystring += f"RelayState={quote_plus(response_dict['RelayState'])}&"
+ querystring += f"SigAlg={quote_plus(self.source.signature_algorithm)}"
+
+ ctx = xmlsec.SignatureContext()
+
+ key = xmlsec.Key.from_memory(
+ self.source.signing_kp.key_data, xmlsec.constants.KeyDataFormatPem, None
+ )
+ key.load_cert_from_memory(
+ self.source.signing_kp.certificate_data,
+ xmlsec.constants.KeyDataFormatPem,
+ )
+ ctx.key = key
+
+ signature = ctx.sign_binary(
+ querystring.encode("utf-8"), sign_algorithm_transform
+ )
+ response_dict["Signature"] = b64encode(signature).decode()
+ response_dict["SigAlg"] = self.source.signature_algorithm
+
+ return response_dict
diff --git a/authentik/sources/saml/processors/response.py b/authentik/sources/saml/processors/response.py
new file mode 100644
index 000000000..5aecf7df4
--- /dev/null
+++ b/authentik/sources/saml/processors/response.py
@@ -0,0 +1,215 @@
+"""authentik saml source processor"""
+from base64 import b64decode
+from typing import TYPE_CHECKING, Any, Dict
+
+import xmlsec
+from defusedxml.lxml import fromstring
+from django.core.cache import cache
+from django.core.exceptions import SuspiciousOperation
+from django.http import HttpRequest, HttpResponse
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.flows.models import Flow
+from authentik.flows.planner import (
+ PLAN_CONTEXT_PENDING_USER,
+ PLAN_CONTEXT_SSO,
+ FlowPlanner,
+)
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.lib.utils.urls import redirect_with_qs
+from authentik.policies.utils import delete_none_keys
+from authentik.sources.saml.exceptions import (
+ InvalidSignature,
+ MismatchedRequestID,
+ MissingSAMLResponse,
+ UnsupportedNameIDFormat,
+)
+from authentik.sources.saml.models import SAMLSource
+from authentik.sources.saml.processors.constants import (
+ NS_MAP,
+ SAML_NAME_ID_FORMAT_EMAIL,
+ SAML_NAME_ID_FORMAT_PERSISTENT,
+ SAML_NAME_ID_FORMAT_TRANSIENT,
+ SAML_NAME_ID_FORMAT_WINDOWS,
+ SAML_NAME_ID_FORMAT_X509,
+)
+from authentik.sources.saml.processors.request import SESSION_REQUEST_ID
+from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+LOGGER = get_logger()
+if TYPE_CHECKING:
+ from xml.etree.ElementTree import Element # nosec
+
+CACHE_SEEN_REQUEST_ID = "authentik_saml_seen_ids_%s"
+DEFAULT_BACKEND = "django.contrib.auth.backends.ModelBackend"
+
+
+class ResponseProcessor:
+ """SAML Response Processor"""
+
+ _source: SAMLSource
+
+ _root: Any
+ _root_xml: str
+
+ def __init__(self, source: SAMLSource):
+ self._source = source
+
+ def parse(self, request: HttpRequest):
+ """Check if `request` contains SAML Response data, parse and validate it."""
+ # First off, check if we have any SAML Data at all.
+ raw_response = request.POST.get("SAMLResponse", None)
+ if not raw_response:
+ raise MissingSAMLResponse("Request does not contain 'SAMLResponse'")
+ # Check if response is compressed, b64 decode it
+ self._root_xml = b64decode(raw_response.encode()).decode()
+ self._root = fromstring(self._root_xml)
+
+ if self._source.signing_kp:
+ self._verify_signed()
+ self._verify_request_id(request)
+
+ def _verify_signed(self):
+ """Verify SAML Response's Signature"""
+ signature_nodes = self._root.xpath(
+ "/samlp:Response/saml:Assertion/ds:Signature", namespaces=NS_MAP
+ )
+ if len(signature_nodes) != 1:
+ raise InvalidSignature()
+ signature_node = signature_nodes[0]
+ xmlsec.tree.add_ids(self._root, ["ID"])
+
+ ctx = xmlsec.SignatureContext()
+ key = xmlsec.Key.from_memory(
+ self._source.signing_kp.certificate_data,
+ xmlsec.constants.KeyDataFormatCertPem,
+ )
+ ctx.key = key
+
+ ctx.set_enabled_key_data([xmlsec.constants.KeyDataX509])
+ try:
+ ctx.verify(signature_node)
+ except (xmlsec.InternalError, xmlsec.VerificationError) as exc:
+ raise InvalidSignature from exc
+ LOGGER.debug("Successfully verified signautre")
+
+ def _verify_request_id(self, request: HttpRequest):
+ if self._source.allow_idp_initiated:
+ # If IdP-initiated SSO flows are enabled, we want to cache the Response ID
+ # somewhat mitigate replay attacks
+ seen_ids = cache.get(CACHE_SEEN_REQUEST_ID % self._source.pk, [])
+ if self._root.attrib["ID"] in seen_ids:
+ raise SuspiciousOperation("Replay attack detected")
+ seen_ids.append(self._root.attrib["ID"])
+ cache.set(CACHE_SEEN_REQUEST_ID % self._source.pk, seen_ids)
+ return
+ if (
+ SESSION_REQUEST_ID not in request.session
+ or "InResponseTo" not in self._root.attrib
+ ):
+ raise MismatchedRequestID(
+ "Missing InResponseTo and IdP-initiated Logins are not allowed"
+ )
+ if request.session[SESSION_REQUEST_ID] != self._root.attrib["InResponseTo"]:
+ raise MismatchedRequestID("Mismatched request ID")
+
+ def _handle_name_id_transient(self, request: HttpRequest) -> HttpResponse:
+ """Handle a NameID with the Format of Transient. This is a bit more complex than other
+ formats, as we need to create a temporary User that is used in the session. This
+ user has an attribute that refers to our Source for cleanup. The user is also deleted
+ on logout and periodically."""
+ # Create a temporary User
+ name_id = self._get_name_id().text
+ user: User = User.objects.create(
+ username=name_id,
+ attributes={
+ "saml": {"source": self._source.pk.hex, "delete_on_logout": True}
+ },
+ )
+ LOGGER.debug("Created temporary user for NameID Transient", username=name_id)
+ user.set_unusable_password()
+ user.save()
+ return self._flow_response(
+ request,
+ self._source.authentication_flow,
+ **{
+ PLAN_CONTEXT_PENDING_USER: user,
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND: DEFAULT_BACKEND,
+ },
+ )
+
+ def _get_name_id(self) -> "Element":
+ """Get NameID Element"""
+ assertion = self._root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion")
+ subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject")
+ name_id = subject.find("{urn:oasis:names:tc:SAML:2.0:assertion}NameID")
+ if name_id is None:
+ raise ValueError("NameID Element not found!")
+ return name_id
+
+ def _get_name_id_filter(self) -> Dict[str, str]:
+ """Returns the subject's NameID as a Filter for the `User`"""
+ name_id_el = self._get_name_id()
+ name_id = name_id_el.text
+ if not name_id:
+ raise UnsupportedNameIDFormat("Subject's NameID is empty.")
+ _format = name_id_el.attrib["Format"]
+ if _format == SAML_NAME_ID_FORMAT_EMAIL:
+ return {"email": name_id}
+ if _format == SAML_NAME_ID_FORMAT_PERSISTENT:
+ return {"username": name_id}
+ if _format == SAML_NAME_ID_FORMAT_X509:
+ # This attribute is statically set by the LDAP source
+ return {"attributes__distinguishedName": name_id}
+ if _format == SAML_NAME_ID_FORMAT_WINDOWS:
+ if "\\" in name_id:
+ name_id = name_id.split("\\")[1]
+ return {"username": name_id}
+ raise UnsupportedNameIDFormat(
+ f"Assertion contains NameID with unsupported format {_format}."
+ )
+
+ def prepare_flow(self, request: HttpRequest) -> HttpResponse:
+ """Prepare flow plan depending on whether or not the user exists"""
+ name_id = self._get_name_id()
+ # Sanity check, show a warning if NameIDPolicy doesn't match what we go
+ if self._source.name_id_policy != name_id.attrib["Format"]:
+ LOGGER.warning(
+ "NameID from IdP doesn't match our policy",
+ expected=self._source.name_id_policy,
+ got=name_id.attrib["Format"],
+ )
+ # transient NameIDs are handeled seperately as they don't have to go through flows.
+ if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
+ return self._handle_name_id_transient(request)
+
+ name_id_filter = self._get_name_id_filter()
+ matching_users = User.objects.filter(**name_id_filter)
+ if matching_users.exists():
+ # User exists already, switch to authentication flow
+ return self._flow_response(
+ request,
+ self._source.authentication_flow,
+ **{
+ PLAN_CONTEXT_PENDING_USER: matching_users.first(),
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND: DEFAULT_BACKEND,
+ },
+ )
+ return self._flow_response(
+ request,
+ self._source.enrollment_flow,
+ **{PLAN_CONTEXT_PROMPT: delete_none_keys(name_id_filter)},
+ )
+
+ def _flow_response(
+ self, request: HttpRequest, flow: Flow, **kwargs
+ ) -> HttpResponse:
+ kwargs[PLAN_CONTEXT_SSO] = True
+ request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(request, kwargs)
+ return redirect_with_qs(
+ "authentik_flows:flow-executor-shell",
+ request.GET,
+ flow_slug=flow.slug,
+ )
diff --git a/authentik/sources/saml/settings.py b/authentik/sources/saml/settings.py
new file mode 100644
index 000000000..3e3dc45cf
--- /dev/null
+++ b/authentik/sources/saml/settings.py
@@ -0,0 +1,10 @@
+"""saml source settings"""
+from celery.schedules import crontab
+
+CELERY_BEAT_SCHEDULE = {
+ "saml_source_cleanup": {
+ "task": "authentik.sources.saml.tasks.clean_temporary_users",
+ "schedule": crontab(minute="*/5"),
+ "options": {"queue": "authentik_scheduled"},
+ }
+}
diff --git a/authentik/sources/saml/signals.py b/authentik/sources/saml/signals.py
new file mode 100644
index 000000000..b4629f89a
--- /dev/null
+++ b/authentik/sources/saml/signals.py
@@ -0,0 +1,22 @@
+"""authentik saml source signal listener"""
+from django.contrib.auth.signals import user_logged_out
+from django.dispatch import receiver
+from django.http import HttpRequest
+from structlog import get_logger
+
+from authentik.core.models import User
+
+LOGGER = get_logger()
+
+
+@receiver(user_logged_out)
+# pylint: disable=unused-argument
+def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
+ """Delete temporary user if the `delete_on_logout` flag is enabled"""
+ if not user:
+ return
+ if "saml" in user.attributes:
+ if "delete_on_logout" in user.attributes["saml"]:
+ if user.attributes["saml"]["delete_on_logout"]:
+ LOGGER.debug("Deleted temporary user", user=user)
+ user.delete()
diff --git a/authentik/sources/saml/tasks.py b/authentik/sources/saml/tasks.py
new file mode 100644
index 000000000..a2ed2a0a8
--- /dev/null
+++ b/authentik/sources/saml/tasks.py
@@ -0,0 +1,42 @@
+"""authentik saml source tasks"""
+from django.utils.timezone import now
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
+from authentik.lib.utils.time import timedelta_from_string
+from authentik.root.celery import CELERY_APP
+from authentik.sources.saml.models import SAMLSource
+
+LOGGER = get_logger()
+
+
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def clean_temporary_users(self: MonitoredTask):
+ """Remove temporary users created by SAML Sources"""
+ _now = now()
+ messages = []
+ deleted_users = 0
+ for user in User.objects.filter(attributes__saml__isnull=False):
+ sources = SAMLSource.objects.filter(
+ pk=user.attributes.get("saml", {}).get("source", "")
+ )
+ if not sources.exists():
+ LOGGER.warning(
+ "User has an invalid SAML Source and won't be deleted!", user=user
+ )
+ messages.append(
+ f"User {user} has an invalid SAML Source and won't be deleted!"
+ )
+ continue
+ source = sources.first()
+ source_delta = timedelta_from_string(source.temporary_user_delete_after)
+ if _now - user.last_login >= source_delta:
+ LOGGER.debug(
+ "User is expired and will be deleted.", user=user, delta=source_delta
+ )
+ # TODO: Check if user is signed in anywhere?
+ user.delete()
+ deleted_users += 1
+ messages.append(f"Successfully deleted {deleted_users} users.")
+ self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
diff --git a/authentik/sources/saml/templates/saml/sp/login.html b/authentik/sources/saml/templates/saml/sp/login.html
new file mode 100644
index 000000000..5cf5c0502
--- /dev/null
+++ b/authentik/sources/saml/templates/saml/sp/login.html
@@ -0,0 +1,26 @@
+{% extends "login/base_full.html" %}
+
+{% load authentik_utils %}
+{% load i18n %}
+
+{% block title %}
+{% trans 'Authorize Application' %}
+{% endblock %}
+
+{% block card %}
+
+ {% csrf_token %}
+
+
+
+
+ {% blocktrans with source=source.name %}
+ You're about to sign-in via {{ source }}.
+ {% endblocktrans %}
+
+
+
+ {% trans "Continue" %}
+
+
+{% endblock %}
diff --git a/authentik/sources/saml/tests.py b/authentik/sources/saml/tests.py
new file mode 100644
index 000000000..f01dc5fdc
--- /dev/null
+++ b/authentik/sources/saml/tests.py
@@ -0,0 +1,26 @@
+"""SAML Source tests"""
+from defusedxml import ElementTree
+from django.test import RequestFactory, TestCase
+
+from authentik.crypto.models import CertificateKeyPair
+from authentik.sources.saml.models import SAMLSource
+from authentik.sources.saml.processors.metadata import MetadataProcessor
+
+
+class TestMetadataProcessor(TestCase):
+ """Test MetadataProcessor"""
+
+ def setUp(self):
+ self.source = SAMLSource.objects.create(
+ slug="provider",
+ issuer="authentik",
+ signing_kp=CertificateKeyPair.objects.first(),
+ )
+ self.factory = RequestFactory()
+
+ def test_metadata(self):
+ """Test Metadata generation being valid"""
+ request = self.factory.get("/")
+ xml = MetadataProcessor(self.source, request).build_entity_descriptor()
+ metadata = ElementTree.fromstring(xml)
+ self.assertEqual(metadata.attrib["entityID"], "authentik")
diff --git a/authentik/sources/saml/urls.py b/authentik/sources/saml/urls.py
new file mode 100644
index 000000000..889f3f57a
--- /dev/null
+++ b/authentik/sources/saml/urls.py
@@ -0,0 +1,11 @@
+"""saml sp urls"""
+from django.urls import path
+
+from authentik.sources.saml.views import ACSView, InitiateView, MetadataView, SLOView
+
+urlpatterns = [
+ path("/", InitiateView.as_view(), name="login"),
+ path("/acs/", ACSView.as_view(), name="acs"),
+ path("/slo/", SLOView.as_view(), name="slo"),
+ path("/metadata/", MetadataView.as_view(), name="metadata"),
+]
diff --git a/authentik/sources/saml/views.py b/authentik/sources/saml/views.py
new file mode 100644
index 000000000..c5618e7e5
--- /dev/null
+++ b/authentik/sources/saml/views.py
@@ -0,0 +1,108 @@
+"""saml sp views"""
+from django.contrib.auth import logout
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import Http404, HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.decorators import method_decorator
+from django.utils.http import urlencode
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from django.views.decorators.csrf import csrf_exempt
+from xmlsec import VerificationError
+
+from authentik.lib.views import bad_request_message
+from authentik.providers.saml.utils.encoding import nice64
+from authentik.sources.saml.exceptions import (
+ MissingSAMLResponse,
+ UnsupportedNameIDFormat,
+)
+from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
+from authentik.sources.saml.processors.metadata import MetadataProcessor
+from authentik.sources.saml.processors.request import RequestProcessor
+from authentik.sources.saml.processors.response import ResponseProcessor
+
+
+class InitiateView(View):
+ """Get the Form with SAML Request, which sends us to the IDP"""
+
+ def get(self, request: HttpRequest, source_slug: str) -> HttpResponse:
+ """Replies with an XHTML SSO Request."""
+ source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
+ if not source.enabled:
+ raise Http404
+ relay_state = request.GET.get("next", "")
+ auth_n_req = RequestProcessor(source, request, relay_state)
+ # If the source is configured for Redirect bindings, we can just redirect there
+ if source.binding_type == SAMLBindingTypes.Redirect:
+ url_args = urlencode(auth_n_req.build_auth_n_detached())
+ return redirect(f"{source.sso_url}?{url_args}")
+ # As POST Binding we show a form
+ saml_request = nice64(auth_n_req.build_auth_n())
+ if source.binding_type == SAMLBindingTypes.POST:
+ return render(
+ request,
+ "saml/sp/login.html",
+ {
+ "request_url": source.sso_url,
+ "request": saml_request,
+ "relay_state": relay_state,
+ "source": source,
+ },
+ )
+ # Or an auto-submit form
+ if source.binding_type == SAMLBindingTypes.POST_AUTO:
+ return render(
+ request,
+ "generic/autosubmit_form_full.html",
+ {
+ "title": _("Redirecting to %(app)s..." % {"app": source.name}),
+ "attrs": {"SAMLRequest": saml_request, "RelayState": relay_state},
+ "url": source.sso_url,
+ },
+ )
+ raise Http404
+
+
+@method_decorator(csrf_exempt, name="dispatch")
+class ACSView(View):
+ """AssertionConsumerService, consume assertion and log user in"""
+
+ def post(self, request: HttpRequest, source_slug: str) -> HttpResponse:
+ """Handles a POSTed SSO Assertion and logs the user in."""
+ source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
+ if not source.enabled:
+ raise Http404
+ processor = ResponseProcessor(source)
+ try:
+ processor.parse(request)
+ except MissingSAMLResponse as exc:
+ return bad_request_message(request, str(exc))
+ except VerificationError as exc:
+ return bad_request_message(request, str(exc))
+
+ try:
+ return processor.prepare_flow(request)
+ except UnsupportedNameIDFormat as exc:
+ return bad_request_message(request, str(exc))
+
+
+class SLOView(LoginRequiredMixin, View):
+ """Single-Logout-View"""
+
+ def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse:
+ """Log user out and redirect them to the IdP's SLO URL."""
+ source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
+ if not source.enabled:
+ raise Http404
+ logout(request)
+ return redirect(source.slo_url)
+
+
+class MetadataView(View):
+ """Return XML Metadata for IDP"""
+
+ def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse:
+ """Replies with the XML Metadata SPSSODescriptor."""
+ source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
+ metadata = MetadataProcessor(source, request).build_entity_descriptor()
+ return HttpResponse(metadata, content_type="text/xml")
diff --git a/passbook/stages/__init__.py b/authentik/stages/__init__.py
similarity index 100%
rename from passbook/stages/__init__.py
rename to authentik/stages/__init__.py
diff --git a/passbook/stages/captcha/__init__.py b/authentik/stages/captcha/__init__.py
similarity index 100%
rename from passbook/stages/captcha/__init__.py
rename to authentik/stages/captcha/__init__.py
diff --git a/authentik/stages/captcha/api.py b/authentik/stages/captcha/api.py
new file mode 100644
index 000000000..f3389a78b
--- /dev/null
+++ b/authentik/stages/captcha/api.py
@@ -0,0 +1,21 @@
+"""CaptchaStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.captcha.models import CaptchaStage
+
+
+class CaptchaStageSerializer(ModelSerializer):
+ """CaptchaStage Serializer"""
+
+ class Meta:
+
+ model = CaptchaStage
+ fields = ["pk", "name", "public_key", "private_key"]
+
+
+class CaptchaStageViewSet(ModelViewSet):
+ """CaptchaStage Viewset"""
+
+ queryset = CaptchaStage.objects.all()
+ serializer_class = CaptchaStageSerializer
diff --git a/authentik/stages/captcha/apps.py b/authentik/stages/captcha/apps.py
new file mode 100644
index 000000000..69df384a2
--- /dev/null
+++ b/authentik/stages/captcha/apps.py
@@ -0,0 +1,10 @@
+"""authentik captcha app"""
+from django.apps import AppConfig
+
+
+class AuthentikStageCaptchaConfig(AppConfig):
+ """authentik captcha app"""
+
+ name = "authentik.stages.captcha"
+ label = "authentik_stages_captcha"
+ verbose_name = "authentik Stages.Captcha"
diff --git a/authentik/stages/captcha/forms.py b/authentik/stages/captcha/forms.py
new file mode 100644
index 000000000..d2ba260ad
--- /dev/null
+++ b/authentik/stages/captcha/forms.py
@@ -0,0 +1,25 @@
+"""authentik captcha stage forms"""
+from captcha.fields import ReCaptchaField
+from django import forms
+
+from authentik.stages.captcha.models import CaptchaStage
+
+
+class CaptchaForm(forms.Form):
+ """authentik captcha stage form"""
+
+ captcha = ReCaptchaField()
+
+
+class CaptchaStageForm(forms.ModelForm):
+ """Form to edit CaptchaStage Instance"""
+
+ class Meta:
+
+ model = CaptchaStage
+ fields = ["name", "public_key", "private_key"]
+ widgets = {
+ "name": forms.TextInput(),
+ "public_key": forms.TextInput(),
+ "private_key": forms.TextInput(),
+ }
diff --git a/authentik/stages/captcha/migrations/0001_initial.py b/authentik/stages/captcha/migrations/0001_initial.py
new file mode 100644
index 000000000..b1e1c3e3a
--- /dev/null
+++ b/authentik/stages/captcha/migrations/0001_initial.py
@@ -0,0 +1,49 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="CaptchaStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ (
+ "public_key",
+ models.TextField(
+ help_text="Public key, acquired from https://www.google.com/recaptcha/intro/v3.html"
+ ),
+ ),
+ (
+ "private_key",
+ models.TextField(
+ help_text="Private key, acquired from https://www.google.com/recaptcha/intro/v3.html"
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Captcha Stage",
+ "verbose_name_plural": "Captcha Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/captcha/migrations/__init__.py b/authentik/stages/captcha/migrations/__init__.py
similarity index 100%
rename from passbook/stages/captcha/migrations/__init__.py
rename to authentik/stages/captcha/migrations/__init__.py
diff --git a/authentik/stages/captcha/models.py b/authentik/stages/captcha/models.py
new file mode 100644
index 000000000..c6eeac1f3
--- /dev/null
+++ b/authentik/stages/captcha/models.py
@@ -0,0 +1,51 @@
+"""authentik captcha stage"""
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Stage
+
+
+class CaptchaStage(Stage):
+ """Verify the user is human using Google's reCaptcha."""
+
+ public_key = models.TextField(
+ help_text=_(
+ "Public key, acquired from https://www.google.com/recaptcha/intro/v3.html"
+ )
+ )
+ private_key = models.TextField(
+ help_text=_(
+ "Private key, acquired from https://www.google.com/recaptcha/intro/v3.html"
+ )
+ )
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.captcha.api import CaptchaStageSerializer
+
+ return CaptchaStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.captcha.stage import CaptchaStageView
+
+ return CaptchaStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.captcha.forms import CaptchaStageForm
+
+ return CaptchaStageForm
+
+ def __str__(self):
+ return f"Captcha Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Captcha Stage")
+ verbose_name_plural = _("Captcha Stages")
diff --git a/authentik/stages/captcha/settings.py b/authentik/stages/captcha/settings.py
new file mode 100644
index 000000000..6b63a51db
--- /dev/null
+++ b/authentik/stages/captcha/settings.py
@@ -0,0 +1,9 @@
+"""authentik captcha stage settings"""
+# https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do
+RECAPTCHA_PUBLIC_KEY = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"
+RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe"
+
+NOCAPTCHA = True
+INSTALLED_APPS = ["captcha"]
+
+SILENCED_SYSTEM_CHECKS = ["captcha.recaptcha_test_key_error"]
diff --git a/authentik/stages/captcha/stage.py b/authentik/stages/captcha/stage.py
new file mode 100644
index 000000000..5f8c968cd
--- /dev/null
+++ b/authentik/stages/captcha/stage.py
@@ -0,0 +1,24 @@
+"""authentik captcha stage"""
+
+from django.views.generic import FormView
+
+from authentik.flows.stage import StageView
+from authentik.stages.captcha.forms import CaptchaForm
+
+
+class CaptchaStageView(FormView, StageView):
+ """Simple captcha checker, logic is handeled in django-captcha module"""
+
+ form_class = CaptchaForm
+
+ def form_valid(self, form):
+ return self.executor.stage_ok()
+
+ def get_form(self, form_class=None):
+ form = CaptchaForm(**self.get_form_kwargs())
+ form.fields["captcha"].public_key = self.executor.current_stage.public_key
+ form.fields["captcha"].private_key = self.executor.current_stage.private_key
+ form.fields["captcha"].widget.attrs["data-sitekey"] = form.fields[
+ "captcha"
+ ].public_key
+ return form
diff --git a/authentik/stages/captcha/tests.py b/authentik/stages/captcha/tests.py
new file mode 100644
index 000000000..b3664fa37
--- /dev/null
+++ b/authentik/stages/captcha/tests.py
@@ -0,0 +1,55 @@
+"""captcha tests"""
+from django.conf import settings
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import User
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import FlowPlan
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.stages.captcha.models import CaptchaStage
+
+
+class TestCaptchaStage(TestCase):
+ """Captcha tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create_user(
+ username="unittest", email="test@beryju.org"
+ )
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-captcha",
+ slug="test-captcha",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = CaptchaStage.objects.create(
+ name="captcha",
+ public_key=settings.RECAPTCHA_PUBLIC_KEY,
+ private_key=settings.RECAPTCHA_PRIVATE_KEY,
+ )
+ FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
+
+ def test_valid(self):
+ """Test valid captcha"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ {"g-recaptcha-response": "PASSED"},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
diff --git a/passbook/stages/consent/__init__.py b/authentik/stages/consent/__init__.py
similarity index 100%
rename from passbook/stages/consent/__init__.py
rename to authentik/stages/consent/__init__.py
diff --git a/authentik/stages/consent/api.py b/authentik/stages/consent/api.py
new file mode 100644
index 000000000..3c7f1415d
--- /dev/null
+++ b/authentik/stages/consent/api.py
@@ -0,0 +1,21 @@
+"""ConsentStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.consent.models import ConsentStage
+
+
+class ConsentStageSerializer(ModelSerializer):
+ """ConsentStage Serializer"""
+
+ class Meta:
+
+ model = ConsentStage
+ fields = ["pk", "name", "mode", "consent_expire_in"]
+
+
+class ConsentStageViewSet(ModelViewSet):
+ """ConsentStage Viewset"""
+
+ queryset = ConsentStage.objects.all()
+ serializer_class = ConsentStageSerializer
diff --git a/authentik/stages/consent/apps.py b/authentik/stages/consent/apps.py
new file mode 100644
index 000000000..ba4b04e60
--- /dev/null
+++ b/authentik/stages/consent/apps.py
@@ -0,0 +1,10 @@
+"""authentik consent app"""
+from django.apps import AppConfig
+
+
+class AuthentikStageConsentConfig(AppConfig):
+ """authentik consent app"""
+
+ name = "authentik.stages.consent"
+ label = "authentik_stages_consent"
+ verbose_name = "authentik Stages.Consent"
diff --git a/authentik/stages/consent/forms.py b/authentik/stages/consent/forms.py
new file mode 100644
index 000000000..21d6e4573
--- /dev/null
+++ b/authentik/stages/consent/forms.py
@@ -0,0 +1,20 @@
+"""authentik consent stage forms"""
+from django import forms
+
+from authentik.stages.consent.models import ConsentStage
+
+
+class ConsentForm(forms.Form):
+ """authentik consent stage form"""
+
+
+class ConsentStageForm(forms.ModelForm):
+ """Form to edit ConsentStage Instance"""
+
+ class Meta:
+
+ model = ConsentStage
+ fields = ["name", "mode", "consent_expire_in"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/authentik/stages/consent/migrations/0001_initial.py b/authentik/stages/consent/migrations/0001_initial.py
new file mode 100644
index 000000000..af038a034
--- /dev/null
+++ b/authentik/stages/consent/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.6 on 2020-05-24 11:46
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0007_auto_20200703_2059"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ConsentStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Consent Stage",
+ "verbose_name_plural": "Consent Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/authentik/stages/consent/migrations/0002_auto_20200720_0941.py b/authentik/stages/consent/migrations/0002_auto_20200720_0941.py
new file mode 100644
index 000000000..6521b4c0b
--- /dev/null
+++ b/authentik/stages/consent/migrations/0002_auto_20200720_0941.py
@@ -0,0 +1,83 @@
+# Generated by Django 3.0.8 on 2020-07-20 09:41
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+import authentik.core.models
+import authentik.lib.utils.time
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0006_auto_20200709_1608"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("authentik_stages_consent", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="consentstage",
+ name="consent_expire_in",
+ field=models.TextField(
+ default="weeks=4",
+ help_text="Offset after which consent expires. (Format: hours=1;minutes=2;seconds=3).",
+ validators=[authentik.lib.utils.time.timedelta_string_validator],
+ verbose_name="Consent expires in",
+ ),
+ ),
+ migrations.AddField(
+ model_name="consentstage",
+ name="mode",
+ field=models.TextField(
+ choices=[
+ ("always_require", "Always Require"),
+ ("permanent", "Permanent"),
+ ("expiring", "Expiring"),
+ ],
+ default="always_require",
+ ),
+ ),
+ migrations.CreateModel(
+ name="UserConsent",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "expires",
+ models.DateTimeField(
+ default=authentik.core.models.default_token_duration
+ ),
+ ),
+ ("expiring", models.BooleanField(default=True)),
+ (
+ "application",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="authentik_core.Application",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="ak_consent",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User Consent",
+ "verbose_name_plural": "User Consents",
+ "unique_together": {("user", "application")},
+ },
+ ),
+ ]
diff --git a/authentik/stages/consent/migrations/0003_auto_20200924_1403.py b/authentik/stages/consent/migrations/0003_auto_20200924_1403.py
new file mode 100644
index 000000000..aef024dc4
--- /dev/null
+++ b/authentik/stages/consent/migrations/0003_auto_20200924_1403.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.1.1 on 2020-09-24 14:03
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("authentik_stages_consent", "0002_auto_20200720_0941"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="userconsent",
+ name="user",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ ]
diff --git a/passbook/stages/consent/migrations/__init__.py b/authentik/stages/consent/migrations/__init__.py
similarity index 100%
rename from passbook/stages/consent/migrations/__init__.py
rename to authentik/stages/consent/migrations/__init__.py
diff --git a/authentik/stages/consent/models.py b/authentik/stages/consent/models.py
new file mode 100644
index 000000000..5598da54c
--- /dev/null
+++ b/authentik/stages/consent/models.py
@@ -0,0 +1,81 @@
+"""authentik consent stage"""
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.core.models import Application, ExpiringModel, User
+from authentik.flows.models import Stage
+from authentik.lib.utils.time import timedelta_string_validator
+
+
+class ConsentMode(models.TextChoices):
+ """Modes a Consent Stage can operate in"""
+
+ ALWAYS_REQUIRE = "always_require"
+ PERMANENT = "permanent"
+ EXPIRING = "expiring"
+
+
+class ConsentStage(Stage):
+ """Prompt the user for confirmation."""
+
+ mode = models.TextField(
+ choices=ConsentMode.choices, default=ConsentMode.ALWAYS_REQUIRE
+ )
+ consent_expire_in = models.TextField(
+ validators=[timedelta_string_validator],
+ default="weeks=4",
+ verbose_name="Consent expires in",
+ help_text=_(
+ (
+ "Offset after which consent expires. "
+ "(Format: hours=1;minutes=2;seconds=3)."
+ )
+ ),
+ )
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.consent.api import ConsentStageSerializer
+
+ return ConsentStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.consent.stage import ConsentStageView
+
+ return ConsentStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.consent.forms import ConsentStageForm
+
+ return ConsentStageForm
+
+ def __str__(self):
+ return f"Consent Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Consent Stage")
+ verbose_name_plural = _("Consent Stages")
+
+
+class UserConsent(ExpiringModel):
+ """Consent given by a user for an application"""
+
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+ application = models.ForeignKey(Application, on_delete=models.CASCADE)
+
+ def __str__(self):
+ return f"User Consent {self.application} by {self.user}"
+
+ class Meta:
+
+ unique_together = (("user", "application"),)
+ verbose_name = _("User Consent")
+ verbose_name_plural = _("User Consents")
diff --git a/authentik/stages/consent/stage.py b/authentik/stages/consent/stage.py
new file mode 100644
index 000000000..c4f2feea4
--- /dev/null
+++ b/authentik/stages/consent/stage.py
@@ -0,0 +1,73 @@
+"""authentik consent stage"""
+from typing import Any, Dict, List
+
+from django.http import HttpRequest, HttpResponse
+from django.utils.timezone import now
+from django.views.generic import FormView
+
+from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.lib.utils.time import timedelta_from_string
+from authentik.stages.consent.forms import ConsentForm
+from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent
+
+PLAN_CONTEXT_CONSENT_TEMPLATE = "consent_template"
+
+
+class ConsentStageView(FormView, StageView):
+ """Simple consent checker."""
+
+ form_class = ConsentForm
+
+ def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ kwargs["current_stage"] = self.executor.current_stage
+ kwargs["context"] = self.executor.plan.context
+ return kwargs
+
+ def get_template_names(self) -> List[str]:
+ # PLAN_CONTEXT_CONSENT_TEMPLATE has to be set by a template that calls this stage
+ if PLAN_CONTEXT_CONSENT_TEMPLATE in self.executor.plan.context:
+ template_name = self.executor.plan.context[PLAN_CONTEXT_CONSENT_TEMPLATE]
+ return [template_name]
+ return ["stages/consent/fallback.html"]
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ current_stage: ConsentStage = self.executor.current_stage
+ # For always require, we always show the form
+ if current_stage.mode == ConsentMode.ALWAYS_REQUIRE:
+ return super().get(request, *args, **kwargs)
+ # at this point we need to check consent from database
+ if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context:
+ # No application in this plan, hence we can't check DB and require user consent
+ return super().get(request, *args, **kwargs)
+
+ application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
+
+ user = self.request.user
+ if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
+ user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+
+ if UserConsent.filter_not_expired(user=user, application=application).exists():
+ return self.executor.stage_ok()
+
+ # No consent found, show form
+ return super().get(request, *args, **kwargs)
+
+ def form_valid(self, form: ConsentForm) -> HttpResponse:
+ current_stage: ConsentStage = self.executor.current_stage
+ if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context:
+ return self.executor.stage_ok()
+ application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
+ # Since we only get here when no consent exists, we can create it without update
+ if current_stage.mode == ConsentMode.PERMANENT:
+ UserConsent.objects.create(
+ user=self.request.user, application=application, expiring=False
+ )
+ if current_stage.mode == ConsentMode.EXPIRING:
+ UserConsent.objects.create(
+ user=self.request.user,
+ application=application,
+ expires=now() + timedelta_from_string(current_stage.consent_expire_in),
+ )
+ return self.executor.stage_ok()
diff --git a/passbook/stages/consent/templates/stages/consent/fallback.html b/authentik/stages/consent/templates/stages/consent/fallback.html
similarity index 100%
rename from passbook/stages/consent/templates/stages/consent/fallback.html
rename to authentik/stages/consent/templates/stages/consent/fallback.html
diff --git a/authentik/stages/consent/tests.py b/authentik/stages/consent/tests.py
new file mode 100644
index 000000000..7ab6dbf63
--- /dev/null
+++ b/authentik/stages/consent/tests.py
@@ -0,0 +1,135 @@
+"""consent tests"""
+from time import sleep
+
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import Application, User
+from authentik.core.tasks import clean_expired_models
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlan
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent
+
+
+class TestConsentStage(TestCase):
+ """Consent tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create_user(
+ username="unittest", email="test@beryju.org"
+ )
+ self.application = Application.objects.create(
+ name="test-application",
+ slug="test-application",
+ )
+ self.client = Client()
+
+ def test_always_required(self):
+ """Test always required consent"""
+ flow = Flow.objects.create(
+ name="test-consent",
+ slug="test-consent",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ stage = ConsentStage.objects.create(
+ name="consent", mode=ConsentMode.ALWAYS_REQUIRE
+ )
+ FlowStageBinding.objects.create(target=flow, stage=stage, order=2)
+
+ plan = FlowPlan(flow_pk=flow.pk.hex, stages=[stage], markers=[StageMarker()])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+ response = self.client.post(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ {},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+ self.assertFalse(UserConsent.objects.filter(user=self.user).exists())
+
+ def test_permanent(self):
+ """Test permanent consent from user"""
+ self.client.force_login(self.user)
+ flow = Flow.objects.create(
+ name="test-consent",
+ slug="test-consent",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ stage = ConsentStage.objects.create(name="consent", mode=ConsentMode.PERMANENT)
+ FlowStageBinding.objects.create(target=flow, stage=stage, order=2)
+
+ plan = FlowPlan(
+ flow_pk=flow.pk.hex,
+ stages=[stage],
+ markers=[StageMarker()],
+ context={PLAN_CONTEXT_APPLICATION: self.application},
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+ response = self.client.post(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ {},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+ self.assertTrue(
+ UserConsent.objects.filter(
+ user=self.user, application=self.application
+ ).exists()
+ )
+
+ def test_expire(self):
+ """Test expiring consent from user"""
+ self.client.force_login(self.user)
+ flow = Flow.objects.create(
+ name="test-consent",
+ slug="test-consent",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ stage = ConsentStage.objects.create(
+ name="consent", mode=ConsentMode.EXPIRING, consent_expire_in="seconds=1"
+ )
+ FlowStageBinding.objects.create(target=flow, stage=stage, order=2)
+
+ plan = FlowPlan(
+ flow_pk=flow.pk.hex,
+ stages=[stage],
+ markers=[StageMarker()],
+ context={PLAN_CONTEXT_APPLICATION: self.application},
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+ response = self.client.post(
+ reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ {},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+ self.assertTrue(
+ UserConsent.objects.filter(
+ user=self.user, application=self.application
+ ).exists()
+ )
+ sleep(1)
+ clean_expired_models.delay().get()
+ self.assertFalse(
+ UserConsent.objects.filter(
+ user=self.user, application=self.application
+ ).exists()
+ )
diff --git a/passbook/stages/dummy/__init__.py b/authentik/stages/dummy/__init__.py
similarity index 100%
rename from passbook/stages/dummy/__init__.py
rename to authentik/stages/dummy/__init__.py
diff --git a/authentik/stages/dummy/api.py b/authentik/stages/dummy/api.py
new file mode 100644
index 000000000..fa875a9fa
--- /dev/null
+++ b/authentik/stages/dummy/api.py
@@ -0,0 +1,21 @@
+"""DummyStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.dummy.models import DummyStage
+
+
+class DummyStageSerializer(ModelSerializer):
+ """DummyStage Serializer"""
+
+ class Meta:
+
+ model = DummyStage
+ fields = ["pk", "name"]
+
+
+class DummyStageViewSet(ModelViewSet):
+ """DummyStage Viewset"""
+
+ queryset = DummyStage.objects.all()
+ serializer_class = DummyStageSerializer
diff --git a/authentik/stages/dummy/apps.py b/authentik/stages/dummy/apps.py
new file mode 100644
index 000000000..35f93e88c
--- /dev/null
+++ b/authentik/stages/dummy/apps.py
@@ -0,0 +1,11 @@
+"""authentik dummy stage config"""
+
+from django.apps import AppConfig
+
+
+class AuthentikStageDummyConfig(AppConfig):
+ """authentik dummy stage config"""
+
+ name = "authentik.stages.dummy"
+ label = "authentik_stages_dummy"
+ verbose_name = "authentik Stages.Dummy"
diff --git a/authentik/stages/dummy/forms.py b/authentik/stages/dummy/forms.py
new file mode 100644
index 000000000..92420cdd6
--- /dev/null
+++ b/authentik/stages/dummy/forms.py
@@ -0,0 +1,16 @@
+"""authentik administration forms"""
+from django import forms
+
+from authentik.stages.dummy.models import DummyStage
+
+
+class DummyStageForm(forms.ModelForm):
+ """Form to create/edit Dummy Stage"""
+
+ class Meta:
+
+ model = DummyStage
+ fields = ["name"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/authentik/stages/dummy/migrations/0001_initial.py b/authentik/stages/dummy/migrations/0001_initial.py
new file mode 100644
index 000000000..180a09947
--- /dev/null
+++ b/authentik/stages/dummy/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="DummyStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Dummy Stage",
+ "verbose_name_plural": "Dummy Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/dummy/migrations/__init__.py b/authentik/stages/dummy/migrations/__init__.py
similarity index 100%
rename from passbook/stages/dummy/migrations/__init__.py
rename to authentik/stages/dummy/migrations/__init__.py
diff --git a/authentik/stages/dummy/models.py b/authentik/stages/dummy/models.py
new file mode 100644
index 000000000..3dfb768bd
--- /dev/null
+++ b/authentik/stages/dummy/models.py
@@ -0,0 +1,41 @@
+"""dummy stage models"""
+from typing import Type
+
+from django.forms import ModelForm
+from django.utils.translation import gettext as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Stage
+
+
+class DummyStage(Stage):
+ """Used for debugging."""
+
+ __debug_only__ = True
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.dummy.api import DummyStageSerializer
+
+ return DummyStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.dummy.stage import DummyStageView
+
+ return DummyStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.dummy.forms import DummyStageForm
+
+ return DummyStageForm
+
+ def __str__(self):
+ return f"Dummy Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Dummy Stage")
+ verbose_name_plural = _("Dummy Stages")
diff --git a/authentik/stages/dummy/stage.py b/authentik/stages/dummy/stage.py
new file mode 100644
index 000000000..9cedfa479
--- /dev/null
+++ b/authentik/stages/dummy/stage.py
@@ -0,0 +1,19 @@
+"""authentik multi-stage authentication engine"""
+from typing import Any, Dict
+
+from django.http import HttpRequest
+
+from authentik.flows.stage import StageView
+
+
+class DummyStageView(StageView):
+ """Dummy stage for testing with multiple stages"""
+
+ def post(self, request: HttpRequest):
+ """Just redirect to next stage"""
+ return self.executor.stage_ok()
+
+ def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ kwargs["title"] = self.executor.current_stage.name
+ return kwargs
diff --git a/authentik/stages/dummy/tests.py b/authentik/stages/dummy/tests.py
new file mode 100644
index 000000000..61493ead6
--- /dev/null
+++ b/authentik/stages/dummy/tests.py
@@ -0,0 +1,58 @@
+"""dummy tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import User
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.stages.dummy.forms import DummyStageForm
+from authentik.stages.dummy.models import DummyStage
+
+
+class TestDummyStage(TestCase):
+ """Dummy tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-dummy",
+ slug="test-dummy",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = DummyStage.objects.create(
+ name="dummy",
+ )
+ FlowStageBinding.objects.create(
+ target=self.flow,
+ stage=self.stage,
+ order=0,
+ )
+
+ def test_valid_render(self):
+ """Test that View renders correctly"""
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_post(self):
+ """Test with valid email, check that URL redirects back to itself"""
+ url = reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ response = self.client.post(url, {})
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test"}
+ self.assertEqual(DummyStageForm(data).is_valid(), True)
diff --git a/passbook/stages/email/__init__.py b/authentik/stages/email/__init__.py
similarity index 100%
rename from passbook/stages/email/__init__.py
rename to authentik/stages/email/__init__.py
diff --git a/authentik/stages/email/api.py b/authentik/stages/email/api.py
new file mode 100644
index 000000000..b3a4860f0
--- /dev/null
+++ b/authentik/stages/email/api.py
@@ -0,0 +1,36 @@
+"""EmailStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.email.models import EmailStage
+
+
+class EmailStageSerializer(ModelSerializer):
+ """EmailStage Serializer"""
+
+ class Meta:
+
+ model = EmailStage
+ fields = [
+ "pk",
+ "name",
+ "host",
+ "port",
+ "username",
+ "password",
+ "use_tls",
+ "use_ssl",
+ "timeout",
+ "from_address",
+ "token_expiry",
+ "subject",
+ "template",
+ ]
+ extra_kwargs = {"password": {"write_only": True}}
+
+
+class EmailStageViewSet(ModelViewSet):
+ """EmailStage Viewset"""
+
+ queryset = EmailStage.objects.all()
+ serializer_class = EmailStageSerializer
diff --git a/authentik/stages/email/apps.py b/authentik/stages/email/apps.py
new file mode 100644
index 000000000..e7fe92115
--- /dev/null
+++ b/authentik/stages/email/apps.py
@@ -0,0 +1,15 @@
+"""authentik email stage config"""
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class AuthentikStageEmailConfig(AppConfig):
+ """authentik email stage config"""
+
+ name = "authentik.stages.email"
+ label = "authentik_stages_email"
+ verbose_name = "authentik Stages.Email"
+
+ def ready(self):
+ import_module("authentik.stages.email.tasks")
diff --git a/authentik/stages/email/forms.py b/authentik/stages/email/forms.py
new file mode 100644
index 000000000..6c3ea986f
--- /dev/null
+++ b/authentik/stages/email/forms.py
@@ -0,0 +1,44 @@
+"""authentik administration forms"""
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from authentik.stages.email.models import EmailStage
+
+
+class EmailStageSendForm(forms.Form):
+ """Form used when sending the email to prevent multiple emails being sent"""
+
+ invalid = forms.CharField(widget=forms.HiddenInput, required=True)
+
+
+class EmailStageForm(forms.ModelForm):
+ """Form to create/edit Email Stage"""
+
+ class Meta:
+
+ model = EmailStage
+ fields = [
+ "name",
+ "host",
+ "port",
+ "username",
+ "password",
+ "use_tls",
+ "use_ssl",
+ "timeout",
+ "from_address",
+ "token_expiry",
+ "subject",
+ "template",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "host": forms.TextInput(),
+ "subject": forms.TextInput(),
+ "username": forms.TextInput(),
+ "password": forms.TextInput(),
+ }
+ labels = {
+ "use_tls": _("Use TLS"),
+ "use_ssl": _("Use SSL"),
+ }
diff --git a/authentik/stages/email/migrations/0001_initial.py b/authentik/stages/email/migrations/0001_initial.py
new file mode 100644
index 000000000..b3412e76d
--- /dev/null
+++ b/authentik/stages/email/migrations/0001_initial.py
@@ -0,0 +1,71 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="EmailStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ("host", models.TextField(default="localhost")),
+ ("port", models.IntegerField(default=25)),
+ ("username", models.TextField(blank=True, default="")),
+ ("password", models.TextField(blank=True, default="")),
+ ("use_tls", models.BooleanField(default=False)),
+ ("use_ssl", models.BooleanField(default=False)),
+ ("timeout", models.IntegerField(default=10)),
+ (
+ "from_address",
+ models.EmailField(default="system@authentik.local", max_length=254),
+ ),
+ (
+ "token_expiry",
+ models.IntegerField(
+ default=30, help_text="Time in minutes the token sent is valid."
+ ),
+ ),
+ ("subject", models.TextField(default="authentik")),
+ (
+ "template",
+ models.TextField(
+ choices=[
+ (
+ "stages/email/for_email/password_reset.html",
+ "Password Reset",
+ ),
+ (
+ "stages/email/for_email/account_confirmation.html",
+ "Account Confirmation",
+ ),
+ ],
+ default="stages/email/for_email/password_reset.html",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Email Stage",
+ "verbose_name_plural": "Email Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/email/migrations/__init__.py b/authentik/stages/email/migrations/__init__.py
similarity index 100%
rename from passbook/stages/email/migrations/__init__.py
rename to authentik/stages/email/migrations/__init__.py
diff --git a/authentik/stages/email/models.py b/authentik/stages/email/models.py
new file mode 100644
index 000000000..448e0f97b
--- /dev/null
+++ b/authentik/stages/email/models.py
@@ -0,0 +1,85 @@
+"""email stage models"""
+from typing import Type
+
+from django.core.mail import get_connection
+from django.core.mail.backends.base import BaseEmailBackend
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Stage
+
+
+class EmailTemplates(models.TextChoices):
+ """Templates used for rendering the Email"""
+
+ PASSWORD_RESET = (
+ "stages/email/for_email/password_reset.html",
+ _("Password Reset"),
+ ) # nosec
+ ACCOUNT_CONFIRM = (
+ "stages/email/for_email/account_confirmation.html",
+ _("Account Confirmation"),
+ )
+
+
+class EmailStage(Stage):
+ """Sends an Email to the user with a token to confirm their Email address."""
+
+ host = models.TextField(default="localhost")
+ port = models.IntegerField(default=25)
+ username = models.TextField(default="", blank=True)
+ password = models.TextField(default="", blank=True)
+ use_tls = models.BooleanField(default=False)
+ use_ssl = models.BooleanField(default=False)
+ timeout = models.IntegerField(default=10)
+ from_address = models.EmailField(default="system@authentik.local")
+
+ token_expiry = models.IntegerField(
+ default=30, help_text=_("Time in minutes the token sent is valid.")
+ )
+ subject = models.TextField(default="authentik")
+ template = models.TextField(
+ choices=EmailTemplates.choices, default=EmailTemplates.PASSWORD_RESET
+ )
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.email.api import EmailStageSerializer
+
+ return EmailStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.email.stage import EmailStageView
+
+ return EmailStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.email.forms import EmailStageForm
+
+ return EmailStageForm
+
+ @property
+ def backend(self) -> BaseEmailBackend:
+ """Get fully configured EMail Backend instance"""
+ return get_connection(
+ host=self.host,
+ port=self.port,
+ username=self.username,
+ password=self.password,
+ use_tls=self.use_tls,
+ use_ssl=self.use_ssl,
+ timeout=self.timeout,
+ )
+
+ def __str__(self):
+ return f"Email Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Email Stage")
+ verbose_name_plural = _("Email Stages")
diff --git a/authentik/stages/email/stage.py b/authentik/stages/email/stage.py
new file mode 100644
index 000000000..4273859a4
--- /dev/null
+++ b/authentik/stages/email/stage.py
@@ -0,0 +1,90 @@
+"""authentik multi-stage authentication engine"""
+from datetime import timedelta
+
+from django.contrib import messages
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404, reverse
+from django.utils.http import urlencode
+from django.utils.timezone import now
+from django.utils.translation import gettext as _
+from django.views.generic import FormView
+from structlog import get_logger
+
+from authentik.core.models import Token
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.flows.views import SESSION_KEY_GET
+from authentik.stages.email.forms import EmailStageSendForm
+from authentik.stages.email.models import EmailStage
+from authentik.stages.email.tasks import send_mails
+from authentik.stages.email.utils import TemplateEmailMessage
+
+LOGGER = get_logger()
+QS_KEY_TOKEN = "token"
+PLAN_CONTEXT_EMAIL_SENT = "email_sent"
+
+
+class EmailStageView(FormView, StageView):
+ """Email stage which sends Email for verification"""
+
+ form_class = EmailStageSendForm
+ template_name = "stages/email/waiting_message.html"
+
+ def get_full_url(self, **kwargs) -> str:
+ """Get full URL to be used in template"""
+ base_url = reverse(
+ "authentik_flows:flow-executor-shell",
+ kwargs={"flow_slug": self.executor.flow.slug},
+ )
+ relative_url = f"{base_url}?{urlencode(kwargs)}"
+ return self.request.build_absolute_uri(relative_url)
+
+ def send_email(self):
+ """Helper function that sends the actual email. Implies that you've
+ already checked that there is a pending user."""
+ pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ current_stage: EmailStage = self.executor.current_stage
+ valid_delta = timedelta(
+ minutes=current_stage.token_expiry + 1
+ ) # + 1 because django timesince always rounds down
+ token = Token.objects.create(user=pending_user, expires=now() + valid_delta)
+ # Send mail to user
+ message = TemplateEmailMessage(
+ subject=_(current_stage.subject),
+ template_name=current_stage.template,
+ to=[pending_user.email],
+ template_context={
+ "url": self.get_full_url(**{QS_KEY_TOKEN: token.pk.hex}),
+ "user": pending_user,
+ "expires": token.expires,
+ },
+ )
+ send_mails(current_stage, message)
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ # Check if the user came back from the email link to verify
+ if QS_KEY_TOKEN in request.session.get(SESSION_KEY_GET, {}):
+ token = get_object_or_404(
+ Token, pk=request.session[SESSION_KEY_GET][QS_KEY_TOKEN]
+ )
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user
+ token.delete()
+ messages.success(request, _("Successfully verified Email."))
+ return self.executor.stage_ok()
+ if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ messages.error(self.request, _("No pending user."))
+ return self.executor.stage_invalid()
+ # Check if we've already sent the initial e-mail
+ if PLAN_CONTEXT_EMAIL_SENT not in self.executor.plan.context:
+ self.send_email()
+ self.executor.plan.context[PLAN_CONTEXT_EMAIL_SENT] = True
+ return super().get(request, *args, **kwargs)
+
+ def form_invalid(self, form: EmailStageSendForm) -> HttpResponse:
+ if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ messages.error(self.request, _("No pending user."))
+ return super().form_invalid(form)
+ self.send_email()
+ # We can't call stage_ok yet, as we're still waiting
+ # for the user to click the link in the email
+ return super().form_invalid(form)
diff --git a/passbook/stages/email/static/stages/email/css/base.css b/authentik/stages/email/static/stages/email/css/base.css
similarity index 100%
rename from passbook/stages/email/static/stages/email/css/base.css
rename to authentik/stages/email/static/stages/email/css/base.css
diff --git a/authentik/stages/email/tasks.py b/authentik/stages/email/tasks.py
new file mode 100644
index 000000000..28131cd38
--- /dev/null
+++ b/authentik/stages/email/tasks.py
@@ -0,0 +1,65 @@
+"""email stage tasks"""
+from email.utils import make_msgid
+from smtplib import SMTPException
+from typing import Any, Dict, List
+
+from celery import group
+from django.core.mail import EmailMultiAlternatives
+from django.core.mail.utils import DNS_NAME
+from structlog import get_logger
+
+from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
+from authentik.root.celery import CELERY_APP
+from authentik.stages.email.models import EmailStage
+
+LOGGER = get_logger()
+
+
+def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]):
+ """Wrapper to convert EmailMessage to dict and send it from worker"""
+ tasks = []
+ for message in messages:
+ tasks.append(send_mail.s(stage.pk, message.__dict__))
+ lazy_group = group(*tasks)
+ promise = lazy_group()
+ return promise
+
+
+@CELERY_APP.task(
+ bind=True,
+ autoretry_for=(
+ SMTPException,
+ ConnectionError,
+ ),
+ retry_backoff=True,
+ base=MonitoredTask,
+)
+def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any]):
+ """Send Email for Email Stage. Retries are scheduled automatically."""
+ self.save_on_success = False
+ message_id = make_msgid(domain=DNS_NAME)
+ self.set_uid(message_id)
+ try:
+ stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
+ backend = stage.backend
+ backend.open()
+ # Since django's EmailMessage objects are not JSON serialisable,
+ # we need to rebuild them from a dict
+ message_object = EmailMultiAlternatives()
+ for key, value in message.items():
+ setattr(message_object, key, value)
+ message_object.from_email = stage.from_address
+ # Because we use the Message-ID as UID for the task, manually assign it
+ message_object.extra_headers["Message-ID"] = message_id
+
+ LOGGER.debug("Sending mail", to=message_object.to)
+ stage.backend.send_messages([message_object])
+ self.set_status(
+ TaskResult(
+ TaskResultStatus.SUCCESSFUL,
+ messages=["Successfully sent Mail."],
+ )
+ )
+ except (SMTPException, ConnectionError) as exc:
+ self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
+ raise exc
diff --git a/authentik/stages/email/templates/stages/email/for_email/account_confirmation.html b/authentik/stages/email/templates/stages/email/for_email/account_confirmation.html
new file mode 100644
index 000000000..8c2b353e4
--- /dev/null
+++ b/authentik/stages/email/templates/stages/email/for_email/account_confirmation.html
@@ -0,0 +1,38 @@
+{% extends 'stages/email/for_email/base.html' %}
+
+{% load authentik_stages_email %}
+{% load i18n %}
+
+{% block content %}
+
+
+ {% trans 'Welcome!' %}
+
+
+ {% trans "We're excited to have you get started. First, you need to confirm your account. Just press the button below."%}
+
+
+
+ {% blocktrans with url=url %}
+ If that doesn't work, copy and paste the following link in your browser: {{ url }}
+ {% endblocktrans %}
+
+
+ {% trans "If you have any questions, just reply to this email—we're always happy to help out." %}
+
+
+{% endblock %}
diff --git a/authentik/stages/email/templates/stages/email/for_email/base.html b/authentik/stages/email/templates/stages/email/for_email/base.html
new file mode 100644
index 000000000..1261007a9
--- /dev/null
+++ b/authentik/stages/email/templates/stages/email/for_email/base.html
@@ -0,0 +1,65 @@
+{% load authentik_stages_email %}
+{% load authentik_utils %}
+{% load static %}
+{% load i18n %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block content %}
+ {% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/passbook/stages/email/templates/stages/email/for_email/generic_email.html b/authentik/stages/email/templates/stages/email/for_email/generic_email.html
similarity index 100%
rename from passbook/stages/email/templates/stages/email/for_email/generic_email.html
rename to authentik/stages/email/templates/stages/email/for_email/generic_email.html
diff --git a/authentik/stages/email/templates/stages/email/for_email/password_reset.html b/authentik/stages/email/templates/stages/email/for_email/password_reset.html
new file mode 100644
index 000000000..d6818daf2
--- /dev/null
+++ b/authentik/stages/email/templates/stages/email/for_email/password_reset.html
@@ -0,0 +1,40 @@
+{% extends "stages/email/for_email/base.html" %}
+
+{% load authentik_utils %}
+{% load i18n %}
+{% load humanize %}
+
+{% block content %}
+
+
+ {% blocktrans with username=user.username %}
+ Hi {{ username }},
+ {% endblocktrans %}
+
+
+ {% blocktrans %}
+ You recently requested to change your password for you authentik account. Use the button below to set a new password.
+ {% endblocktrans %}
+
+
+
+ {% blocktrans with expires=expires|naturaltime %}
+ If you did not request a password change, please ignore this Email. The link above is valid for {{ expires }}.
+ {% endblocktrans %}
+
+
+{% endblock %}
diff --git a/passbook/stages/email/templates/stages/email/waiting_message.html b/authentik/stages/email/templates/stages/email/waiting_message.html
similarity index 100%
rename from passbook/stages/email/templates/stages/email/waiting_message.html
rename to authentik/stages/email/templates/stages/email/waiting_message.html
diff --git a/passbook/stages/email/templatetags/__init__.py b/authentik/stages/email/templatetags/__init__.py
similarity index 100%
rename from passbook/stages/email/templatetags/__init__.py
rename to authentik/stages/email/templatetags/__init__.py
diff --git a/authentik/stages/email/templatetags/authentik_stages_email.py b/authentik/stages/email/templatetags/authentik_stages_email.py
new file mode 100644
index 000000000..1f9948434
--- /dev/null
+++ b/authentik/stages/email/templatetags/authentik_stages_email.py
@@ -0,0 +1,31 @@
+"""authentik core inlining template tags"""
+from base64 import b64encode
+from pathlib import Path
+
+from django import template
+from django.contrib.staticfiles import finders
+
+register = template.Library()
+
+
+@register.simple_tag()
+def inline_static_ascii(path: str) -> str:
+ """Inline static asset. Doesn't check file contents, plain text is assumed.
+ If no file could be found, original path is returned"""
+ result = Path(finders.find(path))
+ if result:
+ with open(result) as _file:
+ return _file.read()
+ return path
+
+
+@register.simple_tag()
+def inline_static_binary(path: str) -> str:
+ """Inline static asset. Uses file extension for base64 block. If no file could be found,
+ path is returned."""
+ result = Path(finders.find(path))
+ if result and result.is_file():
+ with open(result) as _file:
+ b64content = b64encode(_file.read().encode())
+ return f"data:image/{result.suffix};base64,{b64content.decode('utf-8')}"
+ return path
diff --git a/authentik/stages/email/tests.py b/authentik/stages/email/tests.py
new file mode 100644
index 000000000..c71a0b2d5
--- /dev/null
+++ b/authentik/stages/email/tests.py
@@ -0,0 +1,126 @@
+"""email tests"""
+from unittest.mock import MagicMock, patch
+
+from django.core import mail
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import Token, User
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.stages.email.models import EmailStage
+from authentik.stages.email.stage import QS_KEY_TOKEN
+
+
+class TestEmailStage(TestCase):
+ """Email tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create_user(
+ username="unittest", email="test@beryju.org"
+ )
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-email",
+ slug="test-email",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = EmailStage.objects.create(
+ name="email",
+ )
+ FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
+
+ def test_rendering(self):
+ """Test with pending user"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ url = reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_without_user(self):
+ """Test without pending user"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ url = reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_pending_user(self):
+ """Test with pending user"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ url = reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ with self.settings(
+ EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend"
+ ):
+ response = self.client.post(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(mail.outbox), 1)
+ self.assertEqual(mail.outbox[0].subject, "authentik")
+
+ def test_token(self):
+ """Test with token"""
+ # Make sure token exists
+ self.test_pending_user()
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()):
+ # Call the executor shell to preseed the session
+ url = reverse(
+ "authentik_flows:flow-executor-shell",
+ kwargs={"flow_slug": self.flow.slug},
+ )
+ token = Token.objects.get(user=self.user)
+ url += f"?{QS_KEY_TOKEN}={token.pk.hex}"
+ self.client.get(url)
+ # Call the actual executor to get the JSON Response
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor",
+ kwargs={"flow_slug": self.flow.slug},
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+
+ session = self.client.session
+ plan: FlowPlan = session[SESSION_KEY_PLAN]
+ self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER], self.user)
diff --git a/passbook/stages/email/utils.py b/authentik/stages/email/utils.py
similarity index 100%
rename from passbook/stages/email/utils.py
rename to authentik/stages/email/utils.py
diff --git a/passbook/stages/identification/__init__.py b/authentik/stages/identification/__init__.py
similarity index 100%
rename from passbook/stages/identification/__init__.py
rename to authentik/stages/identification/__init__.py
diff --git a/authentik/stages/identification/api.py b/authentik/stages/identification/api.py
new file mode 100644
index 000000000..7c463fa21
--- /dev/null
+++ b/authentik/stages/identification/api.py
@@ -0,0 +1,29 @@
+"""Identification Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.identification.models import IdentificationStage
+
+
+class IdentificationStageSerializer(ModelSerializer):
+ """IdentificationStage Serializer"""
+
+ class Meta:
+
+ model = IdentificationStage
+ fields = [
+ "pk",
+ "name",
+ "user_fields",
+ "case_insensitive_matching",
+ "template",
+ "enrollment_flow",
+ "recovery_flow",
+ ]
+
+
+class IdentificationStageViewSet(ModelViewSet):
+ """IdentificationStage Viewset"""
+
+ queryset = IdentificationStage.objects.all()
+ serializer_class = IdentificationStageSerializer
diff --git a/authentik/stages/identification/apps.py b/authentik/stages/identification/apps.py
new file mode 100644
index 000000000..e44ebc159
--- /dev/null
+++ b/authentik/stages/identification/apps.py
@@ -0,0 +1,10 @@
+"""authentik identification stage app config"""
+from django.apps import AppConfig
+
+
+class AuthentikStageIdentificationConfig(AppConfig):
+ """authentik identification stage config"""
+
+ name = "authentik.stages.identification"
+ label = "authentik_stages_identification"
+ verbose_name = "authentik Stages.Identification"
diff --git a/authentik/stages/identification/forms.py b/authentik/stages/identification/forms.py
new file mode 100644
index 000000000..d371545ff
--- /dev/null
+++ b/authentik/stages/identification/forms.py
@@ -0,0 +1,73 @@
+"""authentik flows identification forms"""
+from django import forms
+from django.core.validators import validate_email
+from django.utils.translation import gettext_lazy as _
+from structlog import get_logger
+
+from authentik.admin.fields import ArrayFieldSelectMultiple
+from authentik.flows.models import Flow, FlowDesignation
+from authentik.lib.utils.ui import human_list
+from authentik.stages.identification.models import IdentificationStage, UserFields
+
+LOGGER = get_logger()
+
+
+class IdentificationStageForm(forms.ModelForm):
+ """Form to create/edit IdentificationStage instances"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["enrollment_flow"].queryset = Flow.objects.filter(
+ designation=FlowDesignation.ENROLLMENT
+ )
+ self.fields["recovery_flow"].queryset = Flow.objects.filter(
+ designation=FlowDesignation.RECOVERY
+ )
+
+ class Meta:
+
+ model = IdentificationStage
+ fields = [
+ "name",
+ "user_fields",
+ "case_insensitive_matching",
+ "template",
+ "enrollment_flow",
+ "recovery_flow",
+ ]
+ widgets = {
+ "name": forms.TextInput(),
+ "user_fields": ArrayFieldSelectMultiple(choices=UserFields.choices),
+ }
+
+
+class IdentificationForm(forms.Form):
+ """Allow users to login"""
+
+ stage: IdentificationStage
+
+ title = _("Log in to your account")
+ uid_field = forms.CharField(label=_(""))
+
+ def __init__(self, *args, **kwargs):
+ self.stage = kwargs.pop("stage")
+ super().__init__(*args, **kwargs)
+ if self.stage.user_fields == [UserFields.E_MAIL]:
+ self.fields["uid_field"] = forms.EmailField()
+ label = human_list([x.title() for x in self.stage.user_fields])
+ self.fields["uid_field"].label = label
+ self.fields["uid_field"].widget.attrs.update(
+ {
+ "placeholder": _(label),
+ "autofocus": "autofocus",
+ # Autocomplete according to
+ # https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
+ "autocomplete": "username",
+ }
+ )
+
+ def clean_uid_field(self):
+ """Validate uid_field after EmailValidator if 'email' is the only selected uid_fields"""
+ if self.stage.user_fields == [UserFields.E_MAIL]:
+ validate_email(self.cleaned_data.get("uid_field"))
+ return self.cleaned_data.get("uid_field")
diff --git a/authentik/stages/identification/migrations/0001_initial.py b/authentik/stages/identification/migrations/0001_initial.py
new file mode 100644
index 000000000..e28eb3ade
--- /dev/null
+++ b/authentik/stages/identification/migrations/0001_initial.py
@@ -0,0 +1,58 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.contrib.postgres.fields
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="IdentificationStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ (
+ "user_fields",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[("email", "E Mail"), ("username", "Username")],
+ max_length=100,
+ ),
+ help_text="Fields of the user object to match against.",
+ size=None,
+ ),
+ ),
+ (
+ "template",
+ models.TextField(
+ choices=[
+ ("stages/identification/login.html", "Default Login"),
+ ("stages/identification/recovery.html", "Default Recovery"),
+ ]
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Identification Stage",
+ "verbose_name_plural": "Identification Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/authentik/stages/identification/migrations/0002_auto_20200530_2204.py b/authentik/stages/identification/migrations/0002_auto_20200530_2204.py
new file mode 100644
index 000000000..7b5c6610e
--- /dev/null
+++ b/authentik/stages/identification/migrations/0002_auto_20200530_2204.py
@@ -0,0 +1,41 @@
+# Generated by Django 3.0.6 on 2020-05-30 22:04
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0003_auto_20200523_1133"),
+ ("authentik_stages_identification", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="identificationstage",
+ name="enrollment_flow",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="Optional enrollment flow, which is linked at the bottom of the page.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ related_name="+",
+ to="authentik_flows.Flow",
+ ),
+ ),
+ migrations.AddField(
+ model_name="identificationstage",
+ name="recovery_flow",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="Optional enrollment flow, which is linked at the bottom of the page.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ related_name="+",
+ to="authentik_flows.Flow",
+ ),
+ ),
+ ]
diff --git a/authentik/stages/identification/migrations/0003_auto_20200615_1641.py b/authentik/stages/identification/migrations/0003_auto_20200615_1641.py
new file mode 100644
index 000000000..4955dc35a
--- /dev/null
+++ b/authentik/stages/identification/migrations/0003_auto_20200615_1641.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.0.7 on 2020-06-15 16:41
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0007_auto_20200703_2059"),
+ ("authentik_stages_identification", "0002_auto_20200530_2204"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="identificationstage",
+ name="recovery_flow",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="Optional recovery flow, which is linked at the bottom of the page.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ related_name="+",
+ to="authentik_flows.Flow",
+ ),
+ ),
+ ]
diff --git a/authentik/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py b/authentik/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py
new file mode 100644
index 000000000..aae31537c
--- /dev/null
+++ b/authentik/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1.1 on 2020-09-30 21:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_identification", "0003_auto_20200615_1641"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="identificationstage",
+ name="case_insensitive_matching",
+ field=models.BooleanField(
+ default=True,
+ help_text="When enabled, user fields are matched regardless of their casing.",
+ ),
+ ),
+ ]
diff --git a/authentik/stages/identification/migrations/0005_auto_20201003_1734.py b/authentik/stages/identification/migrations/0005_auto_20201003_1734.py
new file mode 100644
index 000000000..30f6d6b7f
--- /dev/null
+++ b/authentik/stages/identification/migrations/0005_auto_20201003_1734.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.1.2 on 2020-10-03 17:34
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ (
+ "authentik_stages_identification",
+ "0004_identificationstage_case_insensitive_matching",
+ ),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="identificationstage",
+ name="user_fields",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[("email", "E Mail"), ("username", "Username")],
+ max_length=100,
+ ),
+ help_text="Fields of the user object to match against. (Hold shift to select multiple options)",
+ size=None,
+ ),
+ ),
+ ]
diff --git a/passbook/stages/identification/migrations/__init__.py b/authentik/stages/identification/migrations/__init__.py
similarity index 100%
rename from passbook/stages/identification/migrations/__init__.py
rename to authentik/stages/identification/migrations/__init__.py
diff --git a/authentik/stages/identification/models.py b/authentik/stages/identification/models.py
new file mode 100644
index 000000000..94e28cbe8
--- /dev/null
+++ b/authentik/stages/identification/models.py
@@ -0,0 +1,96 @@
+"""identification stage models"""
+from typing import Type
+
+from django.contrib.postgres.fields import ArrayField
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Flow, Stage
+
+
+class UserFields(models.TextChoices):
+ """Fields which the user can identify themselves with"""
+
+ E_MAIL = "email"
+ USERNAME = "username"
+
+
+class Templates(models.TextChoices):
+ """Templates to be used for the stage"""
+
+ DEFAULT_LOGIN = "stages/identification/login.html"
+ DEFAULT_RECOVERY = "stages/identification/recovery.html"
+
+
+class IdentificationStage(Stage):
+ """Allows the user to identify themselves for authentication."""
+
+ user_fields = ArrayField(
+ models.CharField(max_length=100, choices=UserFields.choices),
+ help_text=_(
+ (
+ "Fields of the user object to match against. "
+ "(Hold shift to select multiple options)"
+ )
+ ),
+ )
+ template = models.TextField(choices=Templates.choices)
+
+ case_insensitive_matching = models.BooleanField(
+ default=True,
+ help_text=_(
+ "When enabled, user fields are matched regardless of their casing."
+ ),
+ )
+
+ enrollment_flow = models.ForeignKey(
+ Flow,
+ on_delete=models.SET_DEFAULT,
+ null=True,
+ blank=True,
+ related_name="+",
+ default=None,
+ help_text=_(
+ "Optional enrollment flow, which is linked at the bottom of the page."
+ ),
+ )
+ recovery_flow = models.ForeignKey(
+ Flow,
+ on_delete=models.SET_DEFAULT,
+ null=True,
+ blank=True,
+ related_name="+",
+ default=None,
+ help_text=_(
+ "Optional recovery flow, which is linked at the bottom of the page."
+ ),
+ )
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.identification.api import IdentificationStageSerializer
+
+ return IdentificationStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.identification.stage import IdentificationStageView
+
+ return IdentificationStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.identification.forms import IdentificationStageForm
+
+ return IdentificationStageForm
+
+ def __str__(self):
+ return f"Identification Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Identification Stage")
+ verbose_name_plural = _("Identification Stages")
diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py
new file mode 100644
index 000000000..41a8a8952
--- /dev/null
+++ b/authentik/stages/identification/stage.py
@@ -0,0 +1,93 @@
+"""Identification stage logic"""
+from typing import List, Optional
+
+from django.contrib import messages
+from django.db.models import Q
+from django.http import HttpResponse
+from django.shortcuts import reverse
+from django.utils.translation import gettext as _
+from django.views.generic import FormView
+from structlog import get_logger
+
+from authentik.core.models import Source, User
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.flows.views import SESSION_KEY_APPLICATION_PRE
+from authentik.stages.identification.forms import IdentificationForm
+from authentik.stages.identification.models import IdentificationStage
+
+LOGGER = get_logger()
+
+
+class IdentificationStageView(FormView, StageView):
+ """Form to identify the user"""
+
+ form_class = IdentificationForm
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["stage"] = self.executor.current_stage
+ return kwargs
+
+ def get_template_names(self) -> List[str]:
+ current_stage: IdentificationStage = self.executor.current_stage
+ return [current_stage.template]
+
+ def get_context_data(self, **kwargs):
+ current_stage: IdentificationStage = self.executor.current_stage
+ # If the user has been redirected to us whilst trying to access an
+ # application, SESSION_KEY_APPLICATION_PRE is set in the session
+ if SESSION_KEY_APPLICATION_PRE in self.request.session:
+ kwargs["application_pre"] = self.request.session[
+ SESSION_KEY_APPLICATION_PRE
+ ]
+ # Check for related enrollment and recovery flow, add URL to view
+ if current_stage.enrollment_flow:
+ kwargs["enroll_url"] = reverse(
+ "authentik_flows:flow-executor-shell",
+ kwargs={"flow_slug": current_stage.enrollment_flow.slug},
+ )
+ if current_stage.recovery_flow:
+ kwargs["recovery_url"] = reverse(
+ "authentik_flows:flow-executor-shell",
+ kwargs={"flow_slug": current_stage.recovery_flow.slug},
+ )
+ kwargs["primary_action"] = _("Log in")
+
+ # Check all enabled source, add them if they have a UI Login button.
+ kwargs["sources"] = []
+ sources: List[Source] = (
+ Source.objects.filter(enabled=True).order_by("name").select_subclasses()
+ )
+ for source in sources:
+ ui_login_button = source.ui_login_button
+ if ui_login_button:
+ kwargs["sources"].append(ui_login_button)
+ return super().get_context_data(**kwargs)
+
+ def get_user(self, uid_value: str) -> Optional[User]:
+ """Find user instance. Returns None if no user was found."""
+ current_stage: IdentificationStage = self.executor.current_stage
+ query = Q()
+ for search_field in current_stage.user_fields:
+ model_field = search_field
+ if current_stage.case_insensitive_matching:
+ model_field += "__iexact"
+ else:
+ model_field += "__exact"
+ query |= Q(**{model_field: uid_value})
+ users = User.objects.filter(query)
+ if users.exists():
+ LOGGER.debug("Found user", user=users.first(), query=query)
+ return users.first()
+ return None
+
+ def form_valid(self, form: IdentificationForm) -> HttpResponse:
+ """Form data is valid"""
+ pre_user = self.get_user(form.cleaned_data.get("uid_field"))
+ if not pre_user:
+ LOGGER.debug("invalid_login")
+ messages.error(self.request, _("Failed to authenticate."))
+ return self.form_invalid(form)
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = pre_user
+ return self.executor.stage_ok()
diff --git a/passbook/stages/identification/templates/stages/identification/login.html b/authentik/stages/identification/templates/stages/identification/login.html
similarity index 100%
rename from passbook/stages/identification/templates/stages/identification/login.html
rename to authentik/stages/identification/templates/stages/identification/login.html
diff --git a/passbook/stages/identification/templates/stages/identification/recovery.html b/authentik/stages/identification/templates/stages/identification/recovery.html
similarity index 100%
rename from passbook/stages/identification/templates/stages/identification/recovery.html
rename to authentik/stages/identification/templates/stages/identification/recovery.html
diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py
new file mode 100644
index 000000000..326256e47
--- /dev/null
+++ b/authentik/stages/identification/tests.py
@@ -0,0 +1,131 @@
+"""identification tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import User
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.sources.oauth.models import OAuthSource
+from authentik.stages.identification.models import (
+ IdentificationStage,
+ Templates,
+ UserFields,
+)
+
+
+class TestIdentificationStage(TestCase):
+ """Identification tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-identification",
+ slug="test-identification",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = IdentificationStage.objects.create(
+ name="identification",
+ user_fields=[UserFields.E_MAIL],
+ template=Templates.DEFAULT_LOGIN,
+ )
+ FlowStageBinding.objects.create(
+ target=self.flow,
+ stage=self.stage,
+ order=0,
+ )
+
+ # OAuthSource for the login view
+ OAuthSource.objects.create(name="test", slug="test")
+
+ def test_valid_render(self):
+ """Test that View renders correctly"""
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_valid_with_email(self):
+ """Test with valid email, check that URL redirects back to itself"""
+ form_data = {"uid_field": self.user.email}
+ url = reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ response = self.client.post(url, form_data)
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+
+ def test_invalid_with_username(self):
+ """Test invalid with username (user exists but stage only allows email)"""
+ form_data = {"uid_field": self.user.username}
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ form_data,
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_invalid_with_invalid_email(self):
+ """Test with invalid email (user doesn't exist) -> Will return to login form"""
+ form_data = {"uid_field": self.user.email + "test"}
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ form_data,
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_enrollment_flow(self):
+ """Test that enrollment flow is linked correctly"""
+ flow = Flow.objects.create(
+ name="enroll-test",
+ slug="unique-enrollment-string",
+ designation=FlowDesignation.ENROLLMENT,
+ )
+ self.stage.enrollment_flow = flow
+ self.stage.save()
+ FlowStageBinding.objects.create(
+ target=flow,
+ stage=self.stage,
+ order=0,
+ )
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(flow.slug, force_str(response.content))
+
+ def test_recovery_flow(self):
+ """Test that recovery flow is linked correctly"""
+ flow = Flow.objects.create(
+ name="recovery-test",
+ slug="unique-recovery-string",
+ designation=FlowDesignation.RECOVERY,
+ )
+ self.stage.recovery_flow = flow
+ self.stage.save()
+ FlowStageBinding.objects.create(
+ target=flow,
+ stage=self.stage,
+ order=0,
+ )
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(flow.slug, force_str(response.content))
diff --git a/passbook/stages/invitation/__init__.py b/authentik/stages/invitation/__init__.py
similarity index 100%
rename from passbook/stages/invitation/__init__.py
rename to authentik/stages/invitation/__init__.py
diff --git a/authentik/stages/invitation/api.py b/authentik/stages/invitation/api.py
new file mode 100644
index 000000000..f1390abe2
--- /dev/null
+++ b/authentik/stages/invitation/api.py
@@ -0,0 +1,45 @@
+"""Invitation Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.invitation.models import Invitation, InvitationStage
+
+
+class InvitationStageSerializer(ModelSerializer):
+ """InvitationStage Serializer"""
+
+ class Meta:
+
+ model = InvitationStage
+ fields = [
+ "pk",
+ "name",
+ "continue_flow_without_invitation",
+ ]
+
+
+class InvitationStageViewSet(ModelViewSet):
+ """InvitationStage Viewset"""
+
+ queryset = InvitationStage.objects.all()
+ serializer_class = InvitationStageSerializer
+
+
+class InvitationSerializer(ModelSerializer):
+ """Invitation Serializer"""
+
+ class Meta:
+
+ model = Invitation
+ fields = [
+ "pk",
+ "expires",
+ "fixed_data",
+ ]
+
+
+class InvitationViewSet(ModelViewSet):
+ """Invitation Viewset"""
+
+ queryset = Invitation.objects.all()
+ serializer_class = InvitationSerializer
diff --git a/authentik/stages/invitation/apps.py b/authentik/stages/invitation/apps.py
new file mode 100644
index 000000000..efd799420
--- /dev/null
+++ b/authentik/stages/invitation/apps.py
@@ -0,0 +1,10 @@
+"""authentik invitation stage app config"""
+from django.apps import AppConfig
+
+
+class AuthentikStageUserInvitationConfig(AppConfig):
+ """authentik invitation stage config"""
+
+ name = "authentik.stages.invitation"
+ label = "authentik_stages_invitation"
+ verbose_name = "authentik Stages.User Invitation"
diff --git a/authentik/stages/invitation/forms.py b/authentik/stages/invitation/forms.py
new file mode 100644
index 000000000..0e3b2094f
--- /dev/null
+++ b/authentik/stages/invitation/forms.py
@@ -0,0 +1,32 @@
+"""authentik flows invitation forms"""
+from django import forms
+from django.utils.translation import gettext as _
+
+from authentik.admin.fields import CodeMirrorWidget, YAMLField
+from authentik.stages.invitation.models import Invitation, InvitationStage
+
+
+class InvitationStageForm(forms.ModelForm):
+ """Form to create/edit InvitationStage instances"""
+
+ class Meta:
+
+ model = InvitationStage
+ fields = ["name", "continue_flow_without_invitation"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
+
+
+class InvitationForm(forms.ModelForm):
+ """InvitationForm"""
+
+ class Meta:
+
+ model = Invitation
+ fields = ["expires", "fixed_data"]
+ labels = {
+ "fixed_data": _("Optional fixed data to enforce on user enrollment."),
+ }
+ widgets = {"fixed_data": CodeMirrorWidget()}
+ field_classes = {"fixed_data": YAMLField}
diff --git a/authentik/stages/invitation/migrations/0001_initial.py b/authentik/stages/invitation/migrations/0001_initial.py
new file mode 100644
index 000000000..162ba81b3
--- /dev/null
+++ b/authentik/stages/invitation/migrations/0001_initial.py
@@ -0,0 +1,78 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import uuid
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="InvitationStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ (
+ "continue_flow_without_invitation",
+ models.BooleanField(
+ default=False,
+ help_text="If this flag is set, this Stage will jump to the next Stage when no Invitation is given. By default this Stage will cancel the Flow when no invitation is given.",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Invitation Stage",
+ "verbose_name_plural": "Invitation Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ migrations.CreateModel(
+ name="Invitation",
+ fields=[
+ (
+ "invite_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("expires", models.DateTimeField(blank=True, default=None, null=True)),
+ (
+ "fixed_data",
+ models.JSONField(default=dict),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Invitation",
+ "verbose_name_plural": "Invitations",
+ },
+ ),
+ ]
diff --git a/passbook/stages/invitation/migrations/__init__.py b/authentik/stages/invitation/migrations/__init__.py
similarity index 100%
rename from passbook/stages/invitation/migrations/__init__.py
rename to authentik/stages/invitation/migrations/__init__.py
diff --git a/authentik/stages/invitation/models.py b/authentik/stages/invitation/models.py
new file mode 100644
index 000000000..f5f3bf510
--- /dev/null
+++ b/authentik/stages/invitation/models.py
@@ -0,0 +1,72 @@
+"""invitation stage models"""
+from typing import Type
+from uuid import uuid4
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.core.models import User
+from authentik.flows.models import Stage
+
+
+class InvitationStage(Stage):
+ """Simplify enrollment; allow users to use a single
+ link to create their user with pre-defined parameters."""
+
+ continue_flow_without_invitation = models.BooleanField(
+ default=False,
+ help_text=_(
+ (
+ "If this flag is set, this Stage will jump to the next Stage when "
+ "no Invitation is given. By default this Stage will cancel the "
+ "Flow when no invitation is given."
+ )
+ ),
+ )
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.invitation.api import InvitationStageSerializer
+
+ return InvitationStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.invitation.stage import InvitationStageView
+
+ return InvitationStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.invitation.forms import InvitationStageForm
+
+ return InvitationStageForm
+
+ def __str__(self):
+ return f"Invitation Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Invitation Stage")
+ verbose_name_plural = _("Invitation Stages")
+
+
+class Invitation(models.Model):
+ """Single-use invitation link"""
+
+ invite_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+
+ created_by = models.ForeignKey(User, on_delete=models.CASCADE)
+ expires = models.DateTimeField(default=None, blank=True, null=True)
+ fixed_data = models.JSONField(default=dict)
+
+ def __str__(self):
+ return f"Invitation {self.invite_uuid.hex} created by {self.created_by}"
+
+ class Meta:
+
+ verbose_name = _("Invitation")
+ verbose_name_plural = _("Invitations")
diff --git a/authentik/stages/invitation/signals.py b/authentik/stages/invitation/signals.py
new file mode 100644
index 000000000..e5402758c
--- /dev/null
+++ b/authentik/stages/invitation/signals.py
@@ -0,0 +1,7 @@
+"""authentik invitation signals"""
+from django.core.signals import Signal
+
+# Arguments: request: HttpRequest, invitation: Invitation
+invitation_created = Signal()
+# Arguments: request: HttpRequest, invitation: Invitation
+invitation_used = Signal()
diff --git a/authentik/stages/invitation/stage.py b/authentik/stages/invitation/stage.py
new file mode 100644
index 000000000..e3800c12b
--- /dev/null
+++ b/authentik/stages/invitation/stage.py
@@ -0,0 +1,30 @@
+"""invitation stage logic"""
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404
+
+from authentik.flows.stage import StageView
+from authentik.stages.invitation.models import Invitation, InvitationStage
+from authentik.stages.invitation.signals import invitation_used
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+INVITATION_TOKEN_KEY = "token"
+INVITATION_IN_EFFECT = "invitation_in_effect"
+
+
+class InvitationStageView(StageView):
+ """Finalise Authentication flow by logging the user in"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ stage: InvitationStage = self.executor.current_stage
+ if INVITATION_TOKEN_KEY not in request.GET:
+ # No Invitation was given, raise error or continue
+ if stage.continue_flow_without_invitation:
+ return self.executor.stage_ok()
+ return self.executor.stage_invalid()
+
+ token = request.GET[INVITATION_TOKEN_KEY]
+ invite: Invitation = get_object_or_404(Invitation, pk=token)
+ self.executor.plan.context[PLAN_CONTEXT_PROMPT] = invite.fixed_data
+ self.executor.plan.context[INVITATION_IN_EFFECT] = True
+ invitation_used.send(sender=self, request=request, invitation=invite)
+ return self.executor.stage_ok()
diff --git a/authentik/stages/invitation/tests.py b/authentik/stages/invitation/tests.py
new file mode 100644
index 000000000..94f6d3d61
--- /dev/null
+++ b/authentik/stages/invitation/tests.py
@@ -0,0 +1,132 @@
+"""invitation tests"""
+from unittest.mock import MagicMock, patch
+
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.core.models import User
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.policies.http import AccessDeniedResponse
+from authentik.stages.invitation.forms import InvitationStageForm
+from authentik.stages.invitation.models import Invitation, InvitationStage
+from authentik.stages.invitation.stage import INVITATION_TOKEN_KEY, PLAN_CONTEXT_PROMPT
+from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+
+
+class TestUserLoginStage(TestCase):
+ """Login tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-invitation",
+ slug="test-invitation",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = InvitationStage.objects.create(name="invitation")
+ FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test"}
+ self.assertEqual(InvitationStageForm(data).is_valid(), True)
+
+ @patch(
+ "authentik.flows.views.to_stage_response",
+ TO_STAGE_RESPONSE_MOCK,
+ )
+ def test_without_invitation_fail(self):
+ """Test without any invitation, continue_flow_without_invitation not set."""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response, AccessDeniedResponse)
+
+ def test_without_invitation_continue(self):
+ """Test without any invitation, continue_flow_without_invitation is set."""
+ self.stage.continue_flow_without_invitation = True
+ self.stage.save()
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+
+ self.stage.continue_flow_without_invitation = False
+ self.stage.save()
+
+ def test_with_invitation(self):
+ """Test with invitation, check data in session"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ data = {"foo": "bar"}
+ invite = Invitation.objects.create(
+ created_by=get_anonymous_user(), fixed_data=data
+ )
+
+ with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()):
+ base_url = reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ response = self.client.get(
+ base_url + f"?{INVITATION_TOKEN_KEY}={invite.pk.hex}"
+ )
+
+ session = self.client.session
+ plan: FlowPlan = session[SESSION_KEY_PLAN]
+ self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], data)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
diff --git a/passbook/stages/otp_static/__init__.py b/authentik/stages/otp_static/__init__.py
similarity index 100%
rename from passbook/stages/otp_static/__init__.py
rename to authentik/stages/otp_static/__init__.py
diff --git a/authentik/stages/otp_static/api.py b/authentik/stages/otp_static/api.py
new file mode 100644
index 000000000..120ab2f18
--- /dev/null
+++ b/authentik/stages/otp_static/api.py
@@ -0,0 +1,21 @@
+"""OTPStaticStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.otp_static.models import OTPStaticStage
+
+
+class OTPStaticStageSerializer(ModelSerializer):
+ """OTPStaticStage Serializer"""
+
+ class Meta:
+
+ model = OTPStaticStage
+ fields = ["pk", "name", "configure_flow", "token_count"]
+
+
+class OTPStaticStageViewSet(ModelViewSet):
+ """OTPStaticStage Viewset"""
+
+ queryset = OTPStaticStage.objects.all()
+ serializer_class = OTPStaticStageSerializer
diff --git a/authentik/stages/otp_static/apps.py b/authentik/stages/otp_static/apps.py
new file mode 100644
index 000000000..6792aae69
--- /dev/null
+++ b/authentik/stages/otp_static/apps.py
@@ -0,0 +1,11 @@
+"""OTP Static stage"""
+from django.apps import AppConfig
+
+
+class AuthentikStageOTPStaticConfig(AppConfig):
+ """OTP Static stage"""
+
+ name = "authentik.stages.otp_static"
+ label = "authentik_stages_otp_static"
+ verbose_name = "authentik OTP.Static"
+ mountpoint = "-/user/otp/static/"
diff --git a/authentik/stages/otp_static/forms.py b/authentik/stages/otp_static/forms.py
new file mode 100644
index 000000000..2864dfaea
--- /dev/null
+++ b/authentik/stages/otp_static/forms.py
@@ -0,0 +1,39 @@
+"""OTP Static forms"""
+from django import forms
+from django.utils.safestring import mark_safe
+
+from authentik.stages.otp_static.models import OTPStaticStage
+
+
+class StaticTokenWidget(forms.widgets.Widget):
+ """Widget to render tokens as multiple labels"""
+
+ def render(self, name, value, attrs=None, renderer=None):
+ final_string = ''
+ for token in value:
+ final_string += f"{token.token} "
+ final_string += " "
+ return mark_safe(final_string) # nosec
+
+
+class SetupForm(forms.Form):
+ """Form to setup Static OTP"""
+
+ tokens = forms.CharField(widget=StaticTokenWidget, disabled=True, required=False)
+
+ def __init__(self, tokens, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["tokens"].initial = tokens
+
+
+class OTPStaticStageForm(forms.ModelForm):
+ """OTP Static Stage setup form"""
+
+ class Meta:
+
+ model = OTPStaticStage
+ fields = ["name", "configure_flow", "token_count"]
+
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/authentik/stages/otp_static/migrations/0001_initial.py b/authentik/stages/otp_static/migrations/0001_initial.py
new file mode 100644
index 000000000..4494d16ae
--- /dev/null
+++ b/authentik/stages/otp_static/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.0.7 on 2020-06-30 11:43
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0006_auto_20200629_0857"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="OTPStaticStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ("token_count", models.IntegerField(default=6)),
+ ],
+ options={
+ "verbose_name": "OTP Static Setup Stage",
+ "verbose_name_plural": "OTP Static Setup Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/authentik/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py b/authentik/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py
new file mode 100644
index 000000000..e6f27b206
--- /dev/null
+++ b/authentik/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.1.1 on 2020-09-24 20:51
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0013_auto_20200924_1605"),
+ ("authentik_stages_otp_static", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="otpstaticstage",
+ name="configure_flow",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_flows.flow",
+ ),
+ ),
+ ]
diff --git a/authentik/stages/otp_static/migrations/0003_default_setup_flow.py b/authentik/stages/otp_static/migrations/0003_default_setup_flow.py
new file mode 100644
index 000000000..4bf6dac47
--- /dev/null
+++ b/authentik/stages/otp_static/migrations/0003_default_setup_flow.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.1.1 on 2020-09-25 14:32
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.flows.models import FlowDesignation
+
+
+def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ Flow = apps.get_model("authentik_flows", "Flow")
+ FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
+
+ OTPStaticStage = apps.get_model("authentik_stages_otp_static", "OTPStaticStage")
+
+ db_alias = schema_editor.connection.alias
+
+ flow, _ = Flow.objects.using(db_alias).update_or_create(
+ slug="default-otp-static-configure",
+ designation=FlowDesignation.STAGE_CONFIGURATION,
+ defaults={
+ "name": "default-otp-static-configure",
+ "title": "Setup Static OTP Tokens",
+ },
+ )
+
+ stage, _ = OTPStaticStage.objects.using(db_alias).update_or_create(
+ name="default-otp-static-configure", defaults={"token_count": 6}
+ )
+
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow, stage=stage, defaults={"order": 0}
+ )
+
+ for stage in OTPStaticStage.objects.using(db_alias).filter(configure_flow=None):
+ stage.configure_flow = flow
+ stage.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_otp_static", "0002_otpstaticstage_configure_flow"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_default_setup_flow),
+ ]
diff --git a/passbook/stages/otp_static/migrations/__init__.py b/authentik/stages/otp_static/migrations/__init__.py
similarity index 100%
rename from passbook/stages/otp_static/migrations/__init__.py
rename to authentik/stages/otp_static/migrations/__init__.py
diff --git a/authentik/stages/otp_static/models.py b/authentik/stages/otp_static/models.py
new file mode 100644
index 000000000..c91ba317e
--- /dev/null
+++ b/authentik/stages/otp_static/models.py
@@ -0,0 +1,50 @@
+"""OTP Static models"""
+from typing import Optional, Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.shortcuts import reverse
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import ConfigurableStage, Stage
+
+
+class OTPStaticStage(ConfigurableStage, Stage):
+ """Generate static tokens for the user as a backup."""
+
+ token_count = models.IntegerField(default=6)
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.otp_static.api import OTPStaticStageSerializer
+
+ return OTPStaticStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.otp_static.stage import OTPStaticStageView
+
+ return OTPStaticStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.otp_static.forms import OTPStaticStageForm
+
+ return OTPStaticStageForm
+
+ @property
+ def ui_user_settings(self) -> Optional[str]:
+ return reverse(
+ "authentik_stages_otp_static:user-settings",
+ kwargs={"stage_uuid": self.stage_uuid},
+ )
+
+ def __str__(self) -> str:
+ return f"OTP Static Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("OTP Static Setup Stage")
+ verbose_name_plural = _("OTP Static Setup Stages")
diff --git a/passbook/stages/otp_static/settings.py b/authentik/stages/otp_static/settings.py
similarity index 100%
rename from passbook/stages/otp_static/settings.py
rename to authentik/stages/otp_static/settings.py
diff --git a/authentik/stages/otp_static/stage.py b/authentik/stages/otp_static/stage.py
new file mode 100644
index 000000000..701244488
--- /dev/null
+++ b/authentik/stages/otp_static/stage.py
@@ -0,0 +1,62 @@
+"""Static OTP Setup stage"""
+from typing import Any, Dict
+
+from django.http import HttpRequest, HttpResponse
+from django.views.generic import FormView
+from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
+from structlog import get_logger
+
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.stages.otp_static.forms import SetupForm
+from authentik.stages.otp_static.models import OTPStaticStage
+
+LOGGER = get_logger()
+SESSION_STATIC_DEVICE = "static_device"
+SESSION_STATIC_TOKENS = "static_device_tokens"
+
+
+class OTPStaticStageView(FormView, StageView):
+ """Static OTP Setup stage"""
+
+ form_class = SetupForm
+
+ def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
+ kwargs = super().get_form_kwargs(**kwargs)
+ tokens = self.request.session[SESSION_STATIC_TOKENS]
+ kwargs["tokens"] = tokens
+ return kwargs
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
+ if not user:
+ LOGGER.debug("No pending user, continuing")
+ return self.executor.stage_ok()
+
+ # Currently, this stage only supports one device per user. If the user already
+ # has a device, just skip to the next stage
+ if StaticDevice.objects.filter(user=user).exists():
+ return self.executor.stage_ok()
+
+ stage: OTPStaticStage = self.executor.current_stage
+
+ if SESSION_STATIC_DEVICE not in self.request.session:
+ device = StaticDevice(user=user, confirmed=True)
+ tokens = []
+ for _ in range(0, stage.token_count):
+ tokens.append(
+ StaticToken(device=device, token=StaticToken.random_token())
+ )
+ self.request.session[SESSION_STATIC_DEVICE] = device
+ self.request.session[SESSION_STATIC_TOKENS] = tokens
+ return super().get(request, *args, **kwargs)
+
+ def form_valid(self, form: SetupForm) -> HttpResponse:
+ """Verify OTP Token"""
+ device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE]
+ device.save()
+ for token in self.request.session[SESSION_STATIC_TOKENS]:
+ token.save()
+ del self.request.session[SESSION_STATIC_DEVICE]
+ del self.request.session[SESSION_STATIC_TOKENS]
+ return self.executor.stage_ok()
diff --git a/authentik/stages/otp_static/templates/stages/otp_static/user_settings.html b/authentik/stages/otp_static/templates/stages/otp_static/user_settings.html
new file mode 100644
index 000000000..6953ebb5b
--- /dev/null
+++ b/authentik/stages/otp_static/templates/stages/otp_static/user_settings.html
@@ -0,0 +1,31 @@
+{% load i18n %}
+
+
+
+
+
+ {% blocktrans with state=state|yesno:"Enabled,Disabled" %}
+ Status: {{ state }}
+ {% endblocktrans %}
+ {% if state %}
+
+ {% else %}
+
+ {% endif %}
+
+
+ {% for token in tokens %}
+ {{ token.token }}
+ {% endfor %}
+
+ {% if not state %}
+ {% if stage.configure_flow %}
+
{% trans "Enable Static Tokens" %}
+ {% endif %}
+ {% else %}
+
{% trans "Disable Static Tokens" %}
+ {% endif %}
+
+
diff --git a/authentik/stages/otp_static/urls.py b/authentik/stages/otp_static/urls.py
new file mode 100644
index 000000000..55ee7807c
--- /dev/null
+++ b/authentik/stages/otp_static/urls.py
@@ -0,0 +1,11 @@
+"""OTP static urls"""
+from django.urls import path
+
+from authentik.stages.otp_static.views import DisableView, UserSettingsView
+
+urlpatterns = [
+ path(
+ "/settings/", UserSettingsView.as_view(), name="user-settings"
+ ),
+ path("/disable/", DisableView.as_view(), name="disable"),
+]
diff --git a/authentik/stages/otp_static/views.py b/authentik/stages/otp_static/views.py
new file mode 100644
index 000000000..0f561d696
--- /dev/null
+++ b/authentik/stages/otp_static/views.py
@@ -0,0 +1,44 @@
+"""otp Static view Tokens"""
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404, redirect
+from django.views import View
+from django.views.generic import TemplateView
+from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
+
+from authentik.audit.models import Event
+from authentik.stages.otp_static.models import OTPStaticStage
+
+
+class UserSettingsView(LoginRequiredMixin, TemplateView):
+ """View for user settings to control OTP"""
+
+ template_name = "stages/otp_static/user_settings.html"
+
+ def get_context_data(self, **kwargs):
+ kwargs = super().get_context_data(**kwargs)
+ stage = get_object_or_404(OTPStaticStage, pk=self.kwargs["stage_uuid"])
+ kwargs["stage"] = stage
+ static_devices = StaticDevice.objects.filter(
+ user=self.request.user, confirmed=True
+ )
+ kwargs["state"] = static_devices.exists()
+ if static_devices.exists():
+ kwargs["tokens"] = StaticToken.objects.filter(device=static_devices.first())
+ return kwargs
+
+
+class DisableView(LoginRequiredMixin, View):
+ """Disable Static Tokens for user"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ """Delete all the devices for user"""
+ devices = StaticDevice.objects.filter(user=request.user, confirmed=True)
+ devices.delete()
+ messages.success(request, "Successfully disabled Static OTP Tokens")
+ # Create event with email notification
+ Event.new(
+ "static_otp_disable", message="User disabled Static OTP Tokens."
+ ).from_http(request)
+ return redirect("authentik_stages_otp:otp-user-settings")
diff --git a/passbook/stages/otp_time/__init__.py b/authentik/stages/otp_time/__init__.py
similarity index 100%
rename from passbook/stages/otp_time/__init__.py
rename to authentik/stages/otp_time/__init__.py
diff --git a/authentik/stages/otp_time/api.py b/authentik/stages/otp_time/api.py
new file mode 100644
index 000000000..7670477d7
--- /dev/null
+++ b/authentik/stages/otp_time/api.py
@@ -0,0 +1,21 @@
+"""OTPTimeStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.otp_time.models import OTPTimeStage
+
+
+class OTPTimeStageSerializer(ModelSerializer):
+ """OTPTimeStage Serializer"""
+
+ class Meta:
+
+ model = OTPTimeStage
+ fields = ["pk", "name", "configure_flow", "digits"]
+
+
+class OTPTimeStageViewSet(ModelViewSet):
+ """OTPTimeStage Viewset"""
+
+ queryset = OTPTimeStage.objects.all()
+ serializer_class = OTPTimeStageSerializer
diff --git a/authentik/stages/otp_time/apps.py b/authentik/stages/otp_time/apps.py
new file mode 100644
index 000000000..2e1056bbf
--- /dev/null
+++ b/authentik/stages/otp_time/apps.py
@@ -0,0 +1,11 @@
+"""OTP Time"""
+from django.apps import AppConfig
+
+
+class AuthentikStageOTPTimeConfig(AppConfig):
+ """OTP time App config"""
+
+ name = "authentik.stages.otp_time"
+ label = "authentik_stages_otp_time"
+ verbose_name = "authentik OTP.Time"
+ mountpoint = "-/user/otp/time/"
diff --git a/authentik/stages/otp_time/forms.py b/authentik/stages/otp_time/forms.py
new file mode 100644
index 000000000..eb40521b8
--- /dev/null
+++ b/authentik/stages/otp_time/forms.py
@@ -0,0 +1,62 @@
+"""OTP Time forms"""
+from django import forms
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy as _
+from django_otp.models import Device
+
+from authentik.stages.otp_time.models import OTPTimeStage
+
+
+class PictureWidget(forms.widgets.Widget):
+ """Widget to render value as img-tag"""
+
+ def render(self, name, value, attrs=None, renderer=None):
+ return mark_safe(f" {value}") # nosec
+
+
+class SetupForm(forms.Form):
+ """Form to setup Time-based OTP"""
+
+ device: Device = None
+
+ qr_code = forms.CharField(
+ widget=PictureWidget,
+ disabled=True,
+ required=False,
+ label=_("Scan this Code with your OTP App."),
+ )
+ code = forms.CharField(
+ label=_("Please enter the Token on your device."),
+ widget=forms.TextInput(
+ attrs={
+ "autocomplete": "off",
+ "placeholder": "Code",
+ "autofocus": "autofocus",
+ }
+ ),
+ )
+
+ def __init__(self, device, qr_code, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.device = device
+ self.fields["qr_code"].initial = qr_code
+
+ def clean_code(self):
+ """Check code with new otp device"""
+ if self.device is not None:
+ if not self.device.verify_token(self.cleaned_data.get("code")):
+ raise forms.ValidationError(_("OTP Code does not match"))
+ return self.cleaned_data.get("code")
+
+
+class OTPTimeStageForm(forms.ModelForm):
+ """OTP Time-based Stage setup form"""
+
+ class Meta:
+
+ model = OTPTimeStage
+ fields = ["name", "configure_flow", "digits"]
+
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/authentik/stages/otp_time/migrations/0001_initial.py b/authentik/stages/otp_time/migrations/0001_initial.py
new file mode 100644
index 000000000..d6a3aa739
--- /dev/null
+++ b/authentik/stages/otp_time/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.0.7 on 2020-06-13 15:28
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0007_auto_20200703_2059"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="OTPTimeStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ("digits", models.IntegerField(choices=[(6, "Six"), (8, "Eight")])),
+ ],
+ options={
+ "verbose_name": "OTP Time (TOTP) Setup Stage",
+ "verbose_name_plural": "OTP Time (TOTP) Setup Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/authentik/stages/otp_time/migrations/0002_auto_20200701_1900.py b/authentik/stages/otp_time/migrations/0002_auto_20200701_1900.py
new file mode 100644
index 000000000..9dca4752f
--- /dev/null
+++ b/authentik/stages/otp_time/migrations/0002_auto_20200701_1900.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.7 on 2020-07-01 19:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_otp_time", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="otptimestage",
+ name="digits",
+ field=models.IntegerField(
+ choices=[
+ (6, "6 digits, widely compatible"),
+ (8, "8 digits, not compatible with apps like Google Authenticator"),
+ ]
+ ),
+ ),
+ ]
diff --git a/authentik/stages/otp_time/migrations/0003_otptimestage_configure_flow.py b/authentik/stages/otp_time/migrations/0003_otptimestage_configure_flow.py
new file mode 100644
index 000000000..64d09b8b8
--- /dev/null
+++ b/authentik/stages/otp_time/migrations/0003_otptimestage_configure_flow.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.1.1 on 2020-09-25 10:39
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0013_auto_20200924_1605"),
+ ("authentik_stages_otp_time", "0002_auto_20200701_1900"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="otptimestage",
+ name="configure_flow",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_flows.flow",
+ ),
+ ),
+ ]
diff --git a/authentik/stages/otp_time/migrations/0004_default_setup_flow.py b/authentik/stages/otp_time/migrations/0004_default_setup_flow.py
new file mode 100644
index 000000000..9b5df159b
--- /dev/null
+++ b/authentik/stages/otp_time/migrations/0004_default_setup_flow.py
@@ -0,0 +1,49 @@
+# Generated by Django 3.1.1 on 2020-09-25 15:36
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.flows.models import FlowDesignation
+from authentik.stages.otp_time.models import TOTPDigits
+
+
+def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ Flow = apps.get_model("authentik_flows", "Flow")
+ FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
+
+ OTPTimeStage = apps.get_model("authentik_stages_otp_time", "OTPTimeStage")
+
+ db_alias = schema_editor.connection.alias
+
+ flow, _ = Flow.objects.using(db_alias).update_or_create(
+ slug="default-otp-time-configure",
+ designation=FlowDesignation.STAGE_CONFIGURATION,
+ defaults={
+ "name": "default-otp-time-configure",
+ "title": "Setup Two-Factor authentication",
+ },
+ )
+
+ stage, _ = OTPTimeStage.objects.using(db_alias).update_or_create(
+ name="default-otp-time-configure", defaults={"digits": TOTPDigits.SIX}
+ )
+
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow, stage=stage, defaults={"order": 0}
+ )
+
+ for stage in OTPTimeStage.objects.using(db_alias).filter(configure_flow=None):
+ stage.configure_flow = flow
+ stage.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_otp_time", "0003_otptimestage_configure_flow"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_default_setup_flow),
+ ]
diff --git a/passbook/stages/otp_time/migrations/__init__.py b/authentik/stages/otp_time/migrations/__init__.py
similarity index 100%
rename from passbook/stages/otp_time/migrations/__init__.py
rename to authentik/stages/otp_time/migrations/__init__.py
diff --git a/authentik/stages/otp_time/models.py b/authentik/stages/otp_time/models.py
new file mode 100644
index 000000000..08a54e394
--- /dev/null
+++ b/authentik/stages/otp_time/models.py
@@ -0,0 +1,57 @@
+"""OTP Time-based models"""
+from typing import Optional, Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.shortcuts import reverse
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import ConfigurableStage, Stage
+
+
+class TOTPDigits(models.IntegerChoices):
+ """OTP Time Digits"""
+
+ SIX = 6, _("6 digits, widely compatible")
+ EIGHT = 8, _("8 digits, not compatible with apps like Google Authenticator")
+
+
+class OTPTimeStage(ConfigurableStage, Stage):
+ """Enroll a user's device into Time-based OTP."""
+
+ digits = models.IntegerField(choices=TOTPDigits.choices)
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.otp_time.api import OTPTimeStageSerializer
+
+ return OTPTimeStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.otp_time.stage import OTPTimeStageView
+
+ return OTPTimeStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.otp_time.forms import OTPTimeStageForm
+
+ return OTPTimeStageForm
+
+ @property
+ def ui_user_settings(self) -> Optional[str]:
+ return reverse(
+ "authentik_stages_otp_time:user-settings",
+ kwargs={"stage_uuid": self.stage_uuid},
+ )
+
+ def __str__(self) -> str:
+ return f"OTP Time (TOTP) Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("OTP Time (TOTP) Setup Stage")
+ verbose_name_plural = _("OTP Time (TOTP) Setup Stages")
diff --git a/authentik/stages/otp_time/settings.py b/authentik/stages/otp_time/settings.py
new file mode 100644
index 000000000..1ec213c66
--- /dev/null
+++ b/authentik/stages/otp_time/settings.py
@@ -0,0 +1,6 @@
+"""OTP Time"""
+
+INSTALLED_APPS = [
+ "django_otp.plugins.otp_totp",
+]
+OTP_TOTP_ISSUER = "authentik"
diff --git a/authentik/stages/otp_time/stage.py b/authentik/stages/otp_time/stage.py
new file mode 100644
index 000000000..ebdb5efd2
--- /dev/null
+++ b/authentik/stages/otp_time/stage.py
@@ -0,0 +1,66 @@
+"""TOTP Setup stage"""
+from typing import Any, Dict
+
+from django.http import HttpRequest, HttpResponse
+from django.utils.encoding import force_str
+from django.views.generic import FormView
+from django_otp.plugins.otp_totp.models import TOTPDevice
+from lxml.etree import tostring # nosec
+from qrcode import QRCode
+from qrcode.image.svg import SvgFillImage
+from structlog import get_logger
+
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.stages.otp_time.forms import SetupForm
+from authentik.stages.otp_time.models import OTPTimeStage
+
+LOGGER = get_logger()
+SESSION_TOTP_DEVICE = "totp_device"
+
+
+class OTPTimeStageView(FormView, StageView):
+ """OTP totp Setup stage"""
+
+ form_class = SetupForm
+
+ def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
+ kwargs = super().get_form_kwargs(**kwargs)
+ device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE]
+ kwargs["device"] = device
+ kwargs["qr_code"] = self._get_qr_code(device)
+ return kwargs
+
+ def _get_qr_code(self, device: TOTPDevice) -> str:
+ """Get QR Code SVG as string based on `device`"""
+ qr_code = QRCode(image_factory=SvgFillImage)
+ qr_code.add_data(device.config_url)
+ svg_image = tostring(qr_code.make_image().get_image())
+ sr_wrapper = f'{force_str(svg_image)}
'
+ return sr_wrapper
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
+ if not user:
+ LOGGER.debug("No pending user, continuing")
+ return self.executor.stage_ok()
+
+ # Currently, this stage only supports one device per user. If the user already
+ # has a device, just skip to the next stage
+ if TOTPDevice.objects.filter(user=user).exists():
+ return self.executor.stage_ok()
+
+ stage: OTPTimeStage = self.executor.current_stage
+
+ if SESSION_TOTP_DEVICE not in self.request.session:
+ device = TOTPDevice(user=user, confirmed=True, digits=stage.digits)
+
+ self.request.session[SESSION_TOTP_DEVICE] = device
+ return super().get(request, *args, **kwargs)
+
+ def form_valid(self, form: SetupForm) -> HttpResponse:
+ """Verify OTP Token"""
+ device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE]
+ device.save()
+ del self.request.session[SESSION_TOTP_DEVICE]
+ return self.executor.stage_ok()
diff --git a/authentik/stages/otp_time/templates/stages/otp_time/user_settings.html b/authentik/stages/otp_time/templates/stages/otp_time/user_settings.html
new file mode 100644
index 000000000..5a351f6a1
--- /dev/null
+++ b/authentik/stages/otp_time/templates/stages/otp_time/user_settings.html
@@ -0,0 +1,28 @@
+{% load i18n %}
+
+
diff --git a/authentik/stages/otp_time/urls.py b/authentik/stages/otp_time/urls.py
new file mode 100644
index 000000000..3570fc8d4
--- /dev/null
+++ b/authentik/stages/otp_time/urls.py
@@ -0,0 +1,11 @@
+"""OTP Time urls"""
+from django.urls import path
+
+from authentik.stages.otp_time.views import DisableView, UserSettingsView
+
+urlpatterns = [
+ path(
+ "/settings/", UserSettingsView.as_view(), name="user-settings"
+ ),
+ path("/disable/", DisableView.as_view(), name="disable"),
+]
diff --git a/authentik/stages/otp_time/views.py b/authentik/stages/otp_time/views.py
new file mode 100644
index 000000000..813f76508
--- /dev/null
+++ b/authentik/stages/otp_time/views.py
@@ -0,0 +1,41 @@
+"""otp time-based view"""
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404, redirect
+from django.views import View
+from django.views.generic import TemplateView
+from django_otp.plugins.otp_totp.models import TOTPDevice
+
+from authentik.audit.models import Event
+from authentik.stages.otp_time.models import OTPTimeStage
+
+
+class UserSettingsView(LoginRequiredMixin, TemplateView):
+ """View for user settings to control OTP"""
+
+ template_name = "stages/otp_time/user_settings.html"
+
+ def get_context_data(self, **kwargs):
+ kwargs = super().get_context_data(**kwargs)
+ stage = get_object_or_404(OTPTimeStage, pk=self.kwargs["stage_uuid"])
+ kwargs["stage"] = stage
+
+ totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True)
+ kwargs["state"] = totp_devices.exists()
+ return kwargs
+
+
+class DisableView(LoginRequiredMixin, View):
+ """Disable TOTP for user"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ """Delete all the devices for user"""
+ totp = TOTPDevice.objects.filter(user=request.user, confirmed=True)
+ totp.delete()
+ messages.success(request, "Successfully disabled Time-based OTP")
+ # Create event with email notification
+ Event.new("totp_disable", message="User disabled Time-based OTP.").from_http(
+ request
+ )
+ return redirect("authentik_stages_otp:otp-user-settings")
diff --git a/passbook/stages/otp_validate/__init__.py b/authentik/stages/otp_validate/__init__.py
similarity index 100%
rename from passbook/stages/otp_validate/__init__.py
rename to authentik/stages/otp_validate/__init__.py
diff --git a/authentik/stages/otp_validate/api.py b/authentik/stages/otp_validate/api.py
new file mode 100644
index 000000000..6d9222939
--- /dev/null
+++ b/authentik/stages/otp_validate/api.py
@@ -0,0 +1,24 @@
+"""OTPValidateStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.otp_validate.models import OTPValidateStage
+
+
+class OTPValidateStageSerializer(ModelSerializer):
+ """OTPValidateStage Serializer"""
+
+ class Meta:
+
+ model = OTPValidateStage
+ fields = [
+ "pk",
+ "name",
+ ]
+
+
+class OTPValidateStageViewSet(ModelViewSet):
+ """OTPValidateStage Viewset"""
+
+ queryset = OTPValidateStage.objects.all()
+ serializer_class = OTPValidateStageSerializer
diff --git a/authentik/stages/otp_validate/apps.py b/authentik/stages/otp_validate/apps.py
new file mode 100644
index 000000000..05847379a
--- /dev/null
+++ b/authentik/stages/otp_validate/apps.py
@@ -0,0 +1,10 @@
+"""OTP Validation Stage"""
+from django.apps import AppConfig
+
+
+class AuthentikStageOTPValidateConfig(AppConfig):
+ """OTP Validation Stage"""
+
+ name = "authentik.stages.otp_validate"
+ label = "authentik_stages_otp_validate"
+ verbose_name = "authentik OTP.Validate"
diff --git a/authentik/stages/otp_validate/forms.py b/authentik/stages/otp_validate/forms.py
new file mode 100644
index 000000000..a8d1f04b8
--- /dev/null
+++ b/authentik/stages/otp_validate/forms.py
@@ -0,0 +1,49 @@
+"""OTP Validate stage forms"""
+from django import forms
+from django.utils.translation import gettext_lazy as _
+from django_otp import match_token
+
+from authentik.core.models import User
+from authentik.stages.otp_validate.models import OTPValidateStage
+
+
+class ValidationForm(forms.Form):
+ """OTP Validate stage forms"""
+
+ user: User
+
+ code = forms.CharField(
+ label=_("Please enter the token from your device."),
+ widget=forms.TextInput(
+ attrs={
+ "autocomplete": "off",
+ "placeholder": "123456",
+ "autofocus": "autofocus",
+ }
+ ),
+ )
+
+ def __init__(self, user, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.user = user
+
+ def clean_code(self):
+ """Validate code against all confirmed devices"""
+ code = self.cleaned_data.get("code")
+ device = match_token(self.user, code)
+ if not device:
+ raise forms.ValidationError(_("Invalid Token"))
+ return code
+
+
+class OTPValidateStageForm(forms.ModelForm):
+ """OTP Validate stage forms"""
+
+ class Meta:
+
+ model = OTPValidateStage
+ fields = ["name"]
+
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/authentik/stages/otp_validate/migrations/0001_initial.py b/authentik/stages/otp_validate/migrations/0001_initial.py
new file mode 100644
index 000000000..140b208cd
--- /dev/null
+++ b/authentik/stages/otp_validate/migrations/0001_initial.py
@@ -0,0 +1,41 @@
+# Generated by Django 3.0.7 on 2020-06-13 15:28
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0007_auto_20200703_2059"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="OTPValidateStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ (
+ "not_configured_action",
+ models.TextField(choices=[("skip", "Skip")], default="skip"),
+ ),
+ ],
+ options={
+ "verbose_name": "OTP Validation Stage",
+ "verbose_name_plural": "OTP Validation Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/otp_validate/migrations/__init__.py b/authentik/stages/otp_validate/migrations/__init__.py
similarity index 100%
rename from passbook/stages/otp_validate/migrations/__init__.py
rename to authentik/stages/otp_validate/migrations/__init__.py
diff --git a/authentik/stages/otp_validate/models.py b/authentik/stages/otp_validate/models.py
new file mode 100644
index 000000000..33e58988f
--- /dev/null
+++ b/authentik/stages/otp_validate/models.py
@@ -0,0 +1,44 @@
+"""OTP Validation Stage"""
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import NotConfiguredAction, Stage
+
+
+class OTPValidateStage(Stage):
+ """Validate user's configured OTP Device."""
+
+ not_configured_action = models.TextField(
+ choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP
+ )
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.otp_validate.api import OTPValidateStageSerializer
+
+ return OTPValidateStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.otp_validate.stage import OTPValidateStageView
+
+ return OTPValidateStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.otp_validate.forms import OTPValidateStageForm
+
+ return OTPValidateStageForm
+
+ def __str__(self) -> str:
+ return f"OTP Validation Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("OTP Validation Stage")
+ verbose_name_plural = _("OTP Validation Stages")
diff --git a/passbook/stages/otp_validate/settings.py b/authentik/stages/otp_validate/settings.py
similarity index 100%
rename from passbook/stages/otp_validate/settings.py
rename to authentik/stages/otp_validate/settings.py
diff --git a/authentik/stages/otp_validate/stage.py b/authentik/stages/otp_validate/stage.py
new file mode 100644
index 000000000..c4dba0a02
--- /dev/null
+++ b/authentik/stages/otp_validate/stage.py
@@ -0,0 +1,46 @@
+"""OTP Validation"""
+from typing import Any, Dict
+
+from django.http import HttpRequest, HttpResponse
+from django.views.generic import FormView
+from django_otp import user_has_device
+from structlog import get_logger
+
+from authentik.flows.models import NotConfiguredAction
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.stages.otp_validate.forms import ValidationForm
+from authentik.stages.otp_validate.models import OTPValidateStage
+
+LOGGER = get_logger()
+
+
+class OTPValidateStageView(FormView, StageView):
+ """OTP Validation"""
+
+ form_class = ValidationForm
+
+ def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
+ kwargs = super().get_form_kwargs(**kwargs)
+ kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
+ return kwargs
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
+ if not user:
+ LOGGER.debug("No pending user, continuing")
+ return self.executor.stage_ok()
+ has_devices = user_has_device(user)
+ stage: OTPValidateStage = self.executor.current_stage
+
+ if not has_devices:
+ if stage.not_configured_action == NotConfiguredAction.SKIP:
+ LOGGER.debug("OTP not configured, skipping stage")
+ return self.executor.stage_ok()
+ return super().get(request, *args, **kwargs)
+
+ def form_valid(self, form: ValidationForm) -> HttpResponse:
+ """Verify OTP Token"""
+ # Since we do token checking in the form, we know the token is valid here
+ # so we can just continue
+ return self.executor.stage_ok()
diff --git a/passbook/stages/password/__init__.py b/authentik/stages/password/__init__.py
similarity index 100%
rename from passbook/stages/password/__init__.py
rename to authentik/stages/password/__init__.py
diff --git a/authentik/stages/password/api.py b/authentik/stages/password/api.py
new file mode 100644
index 000000000..edce69f3e
--- /dev/null
+++ b/authentik/stages/password/api.py
@@ -0,0 +1,27 @@
+"""PasswordStage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.password.models import PasswordStage
+
+
+class PasswordStageSerializer(ModelSerializer):
+ """PasswordStage Serializer"""
+
+ class Meta:
+
+ model = PasswordStage
+ fields = [
+ "pk",
+ "name",
+ "backends",
+ "configure_flow",
+ "failed_attempts_before_cancel",
+ ]
+
+
+class PasswordStageViewSet(ModelViewSet):
+ """PasswordStage Viewset"""
+
+ queryset = PasswordStage.objects.all()
+ serializer_class = PasswordStageSerializer
diff --git a/authentik/stages/password/apps.py b/authentik/stages/password/apps.py
new file mode 100644
index 000000000..2e933bc59
--- /dev/null
+++ b/authentik/stages/password/apps.py
@@ -0,0 +1,11 @@
+"""authentik core app config"""
+from django.apps import AppConfig
+
+
+class AuthentikStagePasswordConfig(AppConfig):
+ """authentik password stage config"""
+
+ name = "authentik.stages.password"
+ label = "authentik_stages_password"
+ verbose_name = "authentik Stages.Password"
+ mountpoint = "-/user/password/"
diff --git a/authentik/stages/password/forms.py b/authentik/stages/password/forms.py
new file mode 100644
index 000000000..9d8902cf5
--- /dev/null
+++ b/authentik/stages/password/forms.py
@@ -0,0 +1,57 @@
+"""authentik administration forms"""
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from authentik.flows.models import Flow, FlowDesignation
+from authentik.stages.password.models import PasswordStage
+
+
+def get_authentication_backends():
+ """Return all available authentication backends as tuple set"""
+ return [
+ (
+ "django.contrib.auth.backends.ModelBackend",
+ _("authentik-internal Userdatabase"),
+ ),
+ (
+ "authentik.sources.ldap.auth.LDAPBackend",
+ _("authentik LDAP"),
+ ),
+ ]
+
+
+class PasswordForm(forms.Form):
+ """Password authentication form"""
+
+ username = forms.CharField(
+ widget=forms.HiddenInput(attrs={"autocomplete": "username"}), required=False
+ )
+ password = forms.CharField(
+ label=_("Please enter your password."),
+ widget=forms.PasswordInput(
+ attrs={
+ "placeholder": _("Password"),
+ "autofocus": "autofocus",
+ "autocomplete": "current-password",
+ }
+ ),
+ )
+
+
+class PasswordStageForm(forms.ModelForm):
+ """Form to create/edit Password Stages"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["configure_flow"].queryset = Flow.objects.filter(
+ designation=FlowDesignation.STAGE_CONFIGURATION
+ )
+
+ class Meta:
+
+ model = PasswordStage
+ fields = ["name", "backends", "configure_flow", "failed_attempts_before_cancel"]
+ widgets = {
+ "name": forms.TextInput(),
+ "backends": forms.SelectMultiple(get_authentication_backends()),
+ }
diff --git a/authentik/stages/password/migrations/0001_initial.py b/authentik/stages/password/migrations/0001_initial.py
new file mode 100644
index 000000000..45b852720
--- /dev/null
+++ b/authentik/stages/password/migrations/0001_initial.py
@@ -0,0 +1,46 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.contrib.postgres.fields
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="PasswordStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ (
+ "backends",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.TextField(),
+ help_text="Selection of backends to test the password against.",
+ size=None,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Password Stage",
+ "verbose_name_plural": "Password Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/authentik/stages/password/migrations/0002_passwordstage_change_flow.py b/authentik/stages/password/migrations/0002_passwordstage_change_flow.py
new file mode 100644
index 000000000..025aa1d3b
--- /dev/null
+++ b/authentik/stages/password/migrations/0002_passwordstage_change_flow.py
@@ -0,0 +1,109 @@
+# Generated by Django 3.0.7 on 2020-06-29 08:51
+
+import django.db.models.deletion
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.flows.models import FlowDesignation
+from authentik.stages.prompt.models import FieldTypes
+
+
+def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ Flow = apps.get_model("authentik_flows", "Flow")
+ FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
+
+ PromptStage = apps.get_model("authentik_stages_prompt", "PromptStage")
+ Prompt = apps.get_model("authentik_stages_prompt", "Prompt")
+
+ UserWriteStage = apps.get_model("authentik_stages_user_write", "UserWriteStage")
+
+ db_alias = schema_editor.connection.alias
+
+ flow, _ = Flow.objects.using(db_alias).update_or_create(
+ slug="default-password-change",
+ designation=FlowDesignation.STAGE_CONFIGURATION,
+ defaults={"name": "Change Password"},
+ )
+
+ prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create(
+ name="Change your password",
+ )
+ password_prompt, _ = Prompt.objects.using(db_alias).update_or_create(
+ field_key="password",
+ defaults={
+ "label": "Password",
+ "type": FieldTypes.PASSWORD,
+ "required": True,
+ "placeholder": "Password",
+ "order": 0,
+ },
+ )
+ password_rep_prompt, _ = Prompt.objects.using(db_alias).update_or_create(
+ field_key="password_repeat",
+ defaults={
+ "label": "Password (repeat)",
+ "type": FieldTypes.PASSWORD,
+ "required": True,
+ "placeholder": "Password (repeat)",
+ "order": 1,
+ },
+ )
+
+ prompt_stage.fields.add(password_prompt)
+ prompt_stage.fields.add(password_rep_prompt)
+ prompt_stage.save()
+
+ user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create(
+ name="default-password-change-write"
+ )
+
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow, stage=prompt_stage, defaults={"order": 0}
+ )
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow, stage=user_write, defaults={"order": 1}
+ )
+
+
+def update_default_stage_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ PasswordStage = apps.get_model("authentik_stages_password", "PasswordStage")
+ Flow = apps.get_model("authentik_flows", "Flow")
+
+ flow = Flow.objects.get(
+ slug="default-password-change",
+ designation=FlowDesignation.STAGE_CONFIGURATION,
+ )
+
+ stages = PasswordStage.objects.filter(name="default-authentication-password")
+ if not stages.exists():
+ return
+ stage = stages.first()
+ stage.change_flow = flow
+ stage.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0006_auto_20200629_0857"),
+ ("authentik_stages_password", "0001_initial"),
+ ("authentik_stages_prompt", "0001_initial"),
+ ("authentik_stages_user_write", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="passwordstage",
+ name="change_flow",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Flow used by an authenticated user to change their password. If empty, user will be unable to change their password.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_flows.Flow",
+ ),
+ ),
+ migrations.RunPython(create_default_password_change),
+ migrations.RunPython(update_default_stage_change),
+ ]
diff --git a/authentik/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py b/authentik/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py
new file mode 100644
index 000000000..c5321f99a
--- /dev/null
+++ b/authentik/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1.1 on 2020-09-18 23:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_password", "0002_passwordstage_change_flow"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="passwordstage",
+ name="failed_attempts_before_cancel",
+ field=models.IntegerField(
+ default=5,
+ help_text="How many attempts a user has before the flow is canceled. To lock the user out, use a reputation policy and a user_write stage.",
+ ),
+ ),
+ ]
diff --git a/authentik/stages/password/migrations/0004_auto_20200925_1057.py b/authentik/stages/password/migrations/0004_auto_20200925_1057.py
new file mode 100644
index 000000000..2ac29f24a
--- /dev/null
+++ b/authentik/stages/password/migrations/0004_auto_20200925_1057.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.1.1 on 2020-09-25 10:57
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_flows", "0013_auto_20200924_1605"),
+ (
+ "authentik_stages_password",
+ "0003_passwordstage_failed_attempts_before_cancel",
+ ),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="passwordstage",
+ old_name="change_flow",
+ new_name="configure_flow",
+ ),
+ migrations.AlterField(
+ model_name="passwordstage",
+ name="configure_flow",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_flows.flow",
+ ),
+ ),
+ ]
diff --git a/passbook/stages/password/migrations/__init__.py b/authentik/stages/password/migrations/__init__.py
similarity index 100%
rename from passbook/stages/password/migrations/__init__.py
rename to authentik/stages/password/migrations/__init__.py
diff --git a/authentik/stages/password/models.py b/authentik/stages/password/models.py
new file mode 100644
index 000000000..43e4a6277
--- /dev/null
+++ b/authentik/stages/password/models.py
@@ -0,0 +1,64 @@
+"""password stage models"""
+from typing import Optional, Type
+
+from django.contrib.postgres.fields import ArrayField
+from django.db import models
+from django.forms import ModelForm
+from django.shortcuts import reverse
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import ConfigurableStage, Stage
+
+
+class PasswordStage(ConfigurableStage, Stage):
+ """Prompts the user for their password, and validates it against the configured backends."""
+
+ backends = ArrayField(
+ models.TextField(),
+ help_text=_("Selection of backends to test the password against."),
+ )
+ failed_attempts_before_cancel = models.IntegerField(
+ default=5,
+ help_text=_(
+ (
+ "How many attempts a user has before the flow is canceled. "
+ "To lock the user out, use a reputation policy and a user_write stage."
+ )
+ ),
+ )
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.password.api import PasswordStageSerializer
+
+ return PasswordStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.password.stage import PasswordStageView
+
+ return PasswordStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.password.forms import PasswordStageForm
+
+ return PasswordStageForm
+
+ @property
+ def ui_user_settings(self) -> Optional[str]:
+ if not self.configure_flow:
+ return None
+ return reverse(
+ "authentik_stages_password:user-settings", kwargs={"stage_uuid": self.pk}
+ )
+
+ def __str__(self):
+ return f"Password Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Password Stage")
+ verbose_name_plural = _("Password Stages")
diff --git a/authentik/stages/password/stage.py b/authentik/stages/password/stage.py
new file mode 100644
index 000000000..315d274f7
--- /dev/null
+++ b/authentik/stages/password/stage.py
@@ -0,0 +1,123 @@
+"""authentik password stage"""
+from typing import Any, Dict, List, Optional
+
+from django.contrib.auth import _clean_credentials
+from django.contrib.auth.backends import BaseBackend
+from django.contrib.auth.signals import user_login_failed
+from django.core.exceptions import PermissionDenied
+from django.forms.utils import ErrorList
+from django.http import HttpRequest, HttpResponse
+from django.utils.translation import gettext as _
+from django.views.generic import FormView
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.flows.models import Flow, FlowDesignation
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.lib.utils.reflection import path_to_class
+from authentik.stages.password.forms import PasswordForm
+from authentik.stages.password.models import PasswordStage
+
+LOGGER = get_logger()
+PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend"
+SESSION_INVALID_TRIES = "user_invalid_tries"
+
+
+def authenticate(
+ request: HttpRequest, backends: List[str], **credentials: Dict[str, Any]
+) -> Optional[User]:
+ """If the given credentials are valid, return a User object.
+
+ Customized version of django's authenticate, which accepts a list of backends"""
+ for backend_path in backends:
+ backend: BaseBackend = path_to_class(backend_path)()
+ LOGGER.debug("Attempting authentication...", backend=backend)
+ user = backend.authenticate(request, **credentials)
+ if user is None:
+ LOGGER.debug("Backend returned nothing, continuing")
+ continue
+ # Annotate the user object with the path of the backend.
+ user.backend = backend_path
+ LOGGER.debug("Successful authentication", user=user, backend=backend)
+ return user
+
+ # The credentials supplied are invalid to all backends, fire signal
+ user_login_failed.send(
+ sender=__name__, credentials=_clean_credentials(credentials), request=request
+ )
+
+
+class PasswordStageView(FormView, StageView):
+ """Authentication stage which authenticates against django's AuthBackend"""
+
+ form_class = PasswordForm
+ template_name = "stages/password/flow-form.html"
+
+ def get_form(self, form_class=None) -> PasswordForm:
+ form = super().get_form(form_class=form_class)
+
+ # If there's a pending user, update the `username` field
+ # this field is only used by password managers.
+ # If there's no user set, an error is raised later.
+ if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
+ pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ form.fields["username"].initial = pending_user.username
+
+ return form
+
+ def get_context_data(self, **kwargs):
+ kwargs = super().get_context_data(**kwargs)
+ recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY)
+ if recovery_flow.exists():
+ kwargs["recovery_flow"] = recovery_flow.first()
+ return kwargs
+
+ def form_invalid(self, form: PasswordForm) -> HttpResponse:
+ if SESSION_INVALID_TRIES not in self.request.session:
+ self.request.session[SESSION_INVALID_TRIES] = 0
+ self.request.session[SESSION_INVALID_TRIES] += 1
+ current_stage: PasswordStage = self.executor.current_stage
+ if (
+ self.request.session[SESSION_INVALID_TRIES]
+ > current_stage.failed_attempts_before_cancel
+ ):
+ LOGGER.debug("User has exceeded maximum tries")
+ del self.request.session[SESSION_INVALID_TRIES]
+ return self.executor.stage_invalid()
+ return super().form_invalid(form)
+
+ def form_valid(self, form: PasswordForm) -> HttpResponse:
+ """Authenticate against django's authentication backend"""
+ if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ return self.executor.stage_invalid()
+ # Get the pending user's username, which is used as
+ # an Identifier by most authentication backends
+ pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ auth_kwargs = {
+ "password": form.cleaned_data.get("password", None),
+ "username": pending_user.username,
+ }
+ try:
+ user = authenticate(
+ self.request, self.executor.current_stage.backends, **auth_kwargs
+ )
+ except PermissionDenied:
+ del auth_kwargs["password"]
+ # User was found, but permission was denied (i.e. user is not active)
+ LOGGER.debug("Denied access", **auth_kwargs)
+ return self.executor.stage_invalid()
+ else:
+ if not user:
+ # No user was found -> invalid credentials
+ LOGGER.debug("Invalid credentials")
+ # Manually inject error into form
+ errors = form._errors.setdefault("password", ErrorList())
+ errors.append(_("Invalid password"))
+ return self.form_invalid(form)
+ # User instance returned from authenticate() has .backend property set
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user
+ self.executor.plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = user.backend
+ return self.executor.stage_ok()
diff --git a/authentik/stages/password/templates/stages/password/flow-form.html b/authentik/stages/password/templates/stages/password/flow-form.html
new file mode 100644
index 000000000..b0cece8d6
--- /dev/null
+++ b/authentik/stages/password/templates/stages/password/flow-form.html
@@ -0,0 +1,10 @@
+{% extends 'login/form_with_user.html' %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block beneath_form %}
+{% if recovery_flow %}
+{% trans 'Forgot password?' %}
+{% endif %}
+{% endblock %}
diff --git a/authentik/stages/password/templates/stages/password/user-settings-card.html b/authentik/stages/password/templates/stages/password/user-settings-card.html
new file mode 100644
index 000000000..85815bf1b
--- /dev/null
+++ b/authentik/stages/password/templates/stages/password/user-settings-card.html
@@ -0,0 +1,17 @@
+{% extends "base/page.html" %}
+
+{% load i18n %}
+{% load authentik_utils %}
+
+{% block body %}
+
+{% endblock %}
diff --git a/authentik/stages/password/tests.py b/authentik/stages/password/tests.py
new file mode 100644
index 000000000..ed604f45c
--- /dev/null
+++ b/authentik/stages/password/tests.py
@@ -0,0 +1,195 @@
+"""password tests"""
+import string
+from random import SystemRandom
+from unittest.mock import MagicMock, patch
+
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import User
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.policies.http import AccessDeniedResponse
+from authentik.stages.password.models import PasswordStage
+
+MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test"))
+
+
+class TestPasswordStage(TestCase):
+ """Password tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.password = "".join(
+ SystemRandom().choice(string.ascii_uppercase + string.digits)
+ for _ in range(8)
+ )
+ self.user = User.objects.create_user(
+ username="unittest", email="test@beryju.org", password=self.password
+ )
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-password",
+ slug="test-password",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = PasswordStage.objects.create(
+ name="password", backends=["django.contrib.auth.backends.ModelBackend"]
+ )
+ FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
+
+ @patch(
+ "authentik.flows.views.to_stage_response",
+ TO_STAGE_RESPONSE_MOCK,
+ )
+ def test_without_user(self):
+ """Test without user"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ # Still have to send the password so the form is valid
+ {"password": self.password},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response, AccessDeniedResponse)
+
+ def test_recovery_flow_link(self):
+ """Test link to the default recovery flow"""
+ flow = Flow.objects.create(
+ designation=FlowDesignation.RECOVERY, slug="qewrqerqr"
+ )
+
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(flow.slug, force_str(response.content))
+
+ def test_valid_password(self):
+ """Test with a valid pending user and valid password"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ # Form data
+ {"password": self.password},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+
+ def test_invalid_password(self):
+ """Test with a valid pending user and invalid password"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ # Form data
+ {"password": self.password + "test"},
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_invalid_password_lockout(self):
+ """Test with a valid pending user and invalid password (trigger logout counter)"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ for _ in range(self.stage.failed_attempts_before_cancel):
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor",
+ kwargs={"flow_slug": self.flow.slug},
+ ),
+ # Form data
+ {"password": self.password + "test"},
+ )
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ # Form data
+ {"password": self.password + "test"},
+ )
+ self.assertEqual(response.status_code, 200)
+ # To ensure the plan has been cancelled, check SESSION_KEY_PLAN
+ self.assertNotIn(SESSION_KEY_PLAN, self.client.session)
+
+ @patch(
+ "authentik.flows.views.to_stage_response",
+ TO_STAGE_RESPONSE_MOCK,
+ )
+ @patch(
+ "django.contrib.auth.backends.ModelBackend.authenticate",
+ MOCK_BACKEND_AUTHENTICATE,
+ )
+ def test_permission_denied(self):
+ """Test with a valid pending user and valid password.
+ Backend is patched to return PermissionError"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ # Form data
+ {"password": self.password + "test"},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response, AccessDeniedResponse)
diff --git a/authentik/stages/password/urls.py b/authentik/stages/password/urls.py
new file mode 100644
index 000000000..f6c254e90
--- /dev/null
+++ b/authentik/stages/password/urls.py
@@ -0,0 +1,12 @@
+"""Password stage urls"""
+from django.urls import path
+
+from authentik.stages.password.views import UserSettingsCardView
+
+urlpatterns = [
+ path(
+ "/change-card/",
+ UserSettingsCardView.as_view(),
+ name="user-settings",
+ ),
+]
diff --git a/authentik/stages/password/views.py b/authentik/stages/password/views.py
new file mode 100644
index 000000000..1808781a7
--- /dev/null
+++ b/authentik/stages/password/views.py
@@ -0,0 +1,26 @@
+"""password stage user settings card"""
+from typing import Any
+
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.shortcuts import reverse
+from django.utils.http import urlencode
+from django.views.generic import TemplateView
+
+from authentik.flows.views import NEXT_ARG_NAME
+
+
+class UserSettingsCardView(LoginRequiredMixin, TemplateView):
+ """Card shown on user settings page to allow user to change their password"""
+
+ template_name = "stages/password/user-settings-card.html"
+
+ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
+ base_url = reverse(
+ "authentik_flows:configure",
+ kwargs={"stage_uuid": self.kwargs["stage_uuid"]},
+ )
+ args = urlencode({NEXT_ARG_NAME: reverse("authentik_core:user-settings")})
+
+ kwargs = super().get_context_data(**kwargs)
+ kwargs["url"] = f"{base_url}?{args}"
+ return kwargs
diff --git a/passbook/stages/prompt/__init__.py b/authentik/stages/prompt/__init__.py
similarity index 100%
rename from passbook/stages/prompt/__init__.py
rename to authentik/stages/prompt/__init__.py
diff --git a/authentik/stages/prompt/api.py b/authentik/stages/prompt/api.py
new file mode 100644
index 000000000..d5618c9b1
--- /dev/null
+++ b/authentik/stages/prompt/api.py
@@ -0,0 +1,53 @@
+"""Prompt Stage API Views"""
+from rest_framework.serializers import CharField, ModelSerializer
+from rest_framework.validators import UniqueValidator
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.prompt.models import Prompt, PromptStage
+
+
+class PromptStageSerializer(ModelSerializer):
+ """PromptStage Serializer"""
+
+ name = CharField(validators=[UniqueValidator(queryset=PromptStage.objects.all())])
+
+ class Meta:
+
+ model = PromptStage
+ fields = [
+ "pk",
+ "name",
+ "fields",
+ "validation_policies",
+ ]
+
+
+class PromptStageViewSet(ModelViewSet):
+ """PromptStage Viewset"""
+
+ queryset = PromptStage.objects.all()
+ serializer_class = PromptStageSerializer
+
+
+class PromptSerializer(ModelSerializer):
+ """Prompt Serializer"""
+
+ class Meta:
+
+ model = Prompt
+ fields = [
+ "pk",
+ "field_key",
+ "label",
+ "type",
+ "required",
+ "placeholder",
+ "order",
+ ]
+
+
+class PromptViewSet(ModelViewSet):
+ """Prompt Viewset"""
+
+ queryset = Prompt.objects.all()
+ serializer_class = PromptSerializer
diff --git a/authentik/stages/prompt/apps.py b/authentik/stages/prompt/apps.py
new file mode 100644
index 000000000..9280f6ac5
--- /dev/null
+++ b/authentik/stages/prompt/apps.py
@@ -0,0 +1,10 @@
+"""authentik prompt stage app config"""
+from django.apps import AppConfig
+
+
+class AuthentikStagPromptConfig(AppConfig):
+ """authentik prompt stage config"""
+
+ name = "authentik.stages.prompt"
+ label = "authentik_stages_prompt"
+ verbose_name = "authentik Stages.Prompt"
diff --git a/authentik/stages/prompt/forms.py b/authentik/stages/prompt/forms.py
new file mode 100644
index 000000000..588599150
--- /dev/null
+++ b/authentik/stages/prompt/forms.py
@@ -0,0 +1,157 @@
+"""Prompt forms"""
+from email.policy import Policy
+from types import MethodType
+from typing import Any, Callable, Iterator, List
+
+from django import forms
+from django.db.models.query import QuerySet
+from django.http import HttpRequest
+from django.utils.translation import gettext_lazy as _
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.core.models import User
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from authentik.policies.engine import PolicyEngine
+from authentik.policies.models import PolicyBinding, PolicyBindingModel
+from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
+from authentik.stages.prompt.signals import password_validate
+
+
+class PromptStageForm(forms.ModelForm):
+ """Form to create/edit Prompt Stage instances"""
+
+ class Meta:
+
+ model = PromptStage
+ fields = ["name", "fields", "validation_policies"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
+
+
+class PromptAdminForm(forms.ModelForm):
+ """Form to edit Prompt instances for admins"""
+
+ class Meta:
+
+ model = Prompt
+ fields = [
+ "field_key",
+ "label",
+ "type",
+ "required",
+ "placeholder",
+ "order",
+ ]
+ widgets = {
+ "label": forms.TextInput(),
+ "placeholder": forms.TextInput(),
+ }
+
+
+class ListPolicyEngine(PolicyEngine):
+ """Slightly modified policy engine, which uses a list instead of a PolicyBindingModel"""
+
+ __list: List[Policy]
+
+ def __init__(
+ self, policies: List[Policy], user: User, request: HttpRequest = None
+ ) -> None:
+ super().__init__(PolicyBindingModel(), user, request)
+ self.__list = policies
+ self.use_cache = False
+
+ def _iter_bindings(self) -> Iterator[PolicyBinding]:
+ for policy in self.__list:
+ yield PolicyBinding(
+ policy=policy,
+ )
+
+
+class PromptForm(forms.Form):
+ """Dynamically created form based on PromptStage"""
+
+ stage: PromptStage
+ plan: FlowPlan
+
+ def __init__(self, stage: PromptStage, plan: FlowPlan, *args, **kwargs):
+ self.stage = stage
+ self.plan = plan
+ super().__init__(*args, **kwargs)
+ # list() is called so we only load the fields once
+ fields = list(self.stage.fields.all())
+ for field in fields:
+ field: Prompt
+ self.fields[field.field_key] = field.field
+ # Special handling for fields with username type
+ # these check for existing users with the same username
+ if field.type == FieldTypes.USERNAME:
+ setattr(
+ self,
+ f"clean_{field.field_key}",
+ MethodType(username_field_cleaner_factory(field), self),
+ )
+ # Check if we have a password field, add a handler that sends a signal
+ # to validate it
+ if field.type == FieldTypes.PASSWORD:
+ setattr(
+ self,
+ f"clean_{field.field_key}",
+ MethodType(password_single_cleaner_factory(field), self),
+ )
+
+ self.field_order = sorted(fields, key=lambda x: x.order)
+
+ def _clean_password_fields(self, *field_names):
+ """Check if the value of all password fields match by merging them into a set
+ and checking the length"""
+ all_passwords = {self.cleaned_data[x] for x in field_names}
+ if len(all_passwords) > 1:
+ raise forms.ValidationError(_("Passwords don't match."))
+
+ def clean(self):
+ cleaned_data = super().clean()
+ if cleaned_data == {}:
+ return {}
+ # Check if we have two password fields, and make sure they are the same
+ password_fields: QuerySet[Prompt] = self.stage.fields.filter(
+ type=FieldTypes.PASSWORD
+ )
+ if password_fields.exists() and password_fields.count() == 2:
+ self._clean_password_fields(*[field.field_key for field in password_fields])
+
+ user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user())
+ engine = ListPolicyEngine(self.stage.validation_policies.all(), user)
+ engine.request.context = cleaned_data
+ engine.build()
+ result = engine.result
+ if not result.passing:
+ raise forms.ValidationError(list(result.messages))
+ return cleaned_data
+
+
+def username_field_cleaner_factory(field: Prompt) -> Callable:
+ """Return a `clean_` method for `field`. Clean method checks if username is taken already."""
+
+ def username_field_cleaner(self: PromptForm) -> Any:
+ """Check for duplicate usernames"""
+ username = self.cleaned_data.get(field.field_key)
+ if User.objects.filter(username=username).exists():
+ raise forms.ValidationError("Username is already taken.")
+ return username
+
+ return username_field_cleaner
+
+
+def password_single_cleaner_factory(field: Prompt) -> Callable[[PromptForm], Any]:
+ """Return a `clean_` method for `field`. Clean method checks if username is taken already."""
+
+ def password_single_clean(self: PromptForm) -> Any:
+ """Send password validation signals for e.g. LDAP Source"""
+ password = self.cleaned_data[field.field_key]
+ password_validate.send(
+ sender=self, password=password, plan_context=self.plan.context
+ )
+ return password
+
+ return password_single_clean
diff --git a/authentik/stages/prompt/migrations/0001_initial.py b/authentik/stages/prompt/migrations/0001_initial.py
new file mode 100644
index 000000000..91201c001
--- /dev/null
+++ b/authentik/stages/prompt/migrations/0001_initial.py
@@ -0,0 +1,98 @@
+# Generated by Django 3.1.1 on 2020-09-09 08:40
+
+import uuid
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0007_auto_20200703_2059"),
+ ("authentik_policies", "0003_auto_20200908_1542"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Prompt",
+ fields=[
+ (
+ "prompt_uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ (
+ "field_key",
+ models.SlugField(
+ help_text="Name of the form field, also used to store the value"
+ ),
+ ),
+ ("label", models.TextField()),
+ (
+ "type",
+ models.CharField(
+ choices=[
+ ("text", "Text: Simple Text input"),
+ (
+ "username",
+ "Username: Same as Text input, but checks for and prevents duplicate usernames.",
+ ),
+ ("email", "Email: Text field with Email type."),
+ ("password", "Password"),
+ ("number", "Number"),
+ ("checkbox", "Checkbox"),
+ ("data", "Date"),
+ ("data-time", "Date Time"),
+ ("separator", "Separator: Static Separator Line"),
+ (
+ "hidden",
+ "Hidden: Hidden field, can be used to insert data into form.",
+ ),
+ ("static", "Static: Static value, displayed as-is."),
+ ],
+ max_length=100,
+ ),
+ ),
+ ("required", models.BooleanField(default=True)),
+ ("placeholder", models.TextField(blank=True)),
+ ("order", models.IntegerField(default=0)),
+ ],
+ options={
+ "verbose_name": "Prompt",
+ "verbose_name_plural": "Prompts",
+ },
+ ),
+ migrations.CreateModel(
+ name="PromptStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.stage",
+ ),
+ ),
+ ("fields", models.ManyToManyField(to="authentik_stages_prompt.Prompt")),
+ (
+ "validation_policies",
+ models.ManyToManyField(blank=True, to="authentik_policies.Policy"),
+ ),
+ ],
+ options={
+ "verbose_name": "Prompt Stage",
+ "verbose_name_plural": "Prompt Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/authentik/stages/prompt/migrations/0002_auto_20200920_1859.py b/authentik/stages/prompt/migrations/0002_auto_20200920_1859.py
new file mode 100644
index 000000000..2ded1bbb4
--- /dev/null
+++ b/authentik/stages/prompt/migrations/0002_auto_20200920_1859.py
@@ -0,0 +1,42 @@
+# Generated by Django 3.1.1 on 2020-09-20 18:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_prompt", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="prompt",
+ name="type",
+ field=models.CharField(
+ choices=[
+ ("text", "Text: Simple Text input"),
+ (
+ "username",
+ "Username: Same as Text input, but checks for and prevents duplicate usernames.",
+ ),
+ ("email", "Email: Text field with Email type."),
+ (
+ "password",
+ "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.",
+ ),
+ ("number", "Number"),
+ ("checkbox", "Checkbox"),
+ ("data", "Date"),
+ ("data-time", "Date Time"),
+ ("separator", "Separator: Static Separator Line"),
+ (
+ "hidden",
+ "Hidden: Hidden field, can be used to insert data into form.",
+ ),
+ ("static", "Static: Static value, displayed as-is."),
+ ],
+ max_length=100,
+ ),
+ ),
+ ]
diff --git a/passbook/stages/prompt/migrations/__init__.py b/authentik/stages/prompt/migrations/__init__.py
similarity index 100%
rename from passbook/stages/prompt/migrations/__init__.py
rename to authentik/stages/prompt/migrations/__init__.py
diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py
new file mode 100644
index 000000000..602e8b64e
--- /dev/null
+++ b/authentik/stages/prompt/models.py
@@ -0,0 +1,166 @@
+"""prompt models"""
+from typing import Type
+from uuid import uuid4
+
+from django import forms
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Stage
+from authentik.lib.models import SerializerModel
+from authentik.policies.models import Policy
+from authentik.stages.prompt.widgets import HorizontalRuleWidget, StaticTextWidget
+
+
+class FieldTypes(models.TextChoices):
+ """Field types an Prompt can be"""
+
+ # Simple text field
+ TEXT = "text", _("Text: Simple Text input")
+ # Same as text, but has autocomplete for password managers
+ USERNAME = (
+ "username",
+ _(
+ (
+ "Username: Same as Text input, but checks for "
+ "and prevents duplicate usernames."
+ )
+ ),
+ )
+ EMAIL = "email", _("Email: Text field with Email type.")
+ PASSWORD = (
+ "password", # noqa # nosec
+ _(
+ (
+ "Password: Masked input, password is validated against sources. Policies still "
+ "have to be applied to this Stage. If two of these are used in the same stage, "
+ "they are ensured to be identical."
+ )
+ ),
+ )
+ NUMBER = "number"
+ CHECKBOX = "checkbox"
+ DATE = "data"
+ DATE_TIME = "data-time"
+
+ SEPARATOR = "separator", _("Separator: Static Separator Line")
+ HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.")
+ STATIC = "static", _("Static: Static value, displayed as-is.")
+
+
+class Prompt(SerializerModel):
+ """Single Prompt, part of a prompt stage."""
+
+ prompt_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
+
+ field_key = models.SlugField(
+ help_text=_("Name of the form field, also used to store the value")
+ )
+ label = models.TextField()
+ type = models.CharField(max_length=100, choices=FieldTypes.choices)
+ required = models.BooleanField(default=True)
+ placeholder = models.TextField(blank=True)
+
+ order = models.IntegerField(default=0)
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.prompt.api import PromptSerializer
+
+ return PromptSerializer
+
+ @property
+ def field(self):
+ """Return instantiated form input field"""
+ attrs = {"placeholder": _(self.placeholder)}
+ field_class = forms.CharField
+ widget = forms.TextInput(attrs=attrs)
+ kwargs = {
+ "label": _(self.label),
+ "required": self.required,
+ }
+ if self.type == FieldTypes.EMAIL:
+ field_class = forms.EmailField
+ if self.type == FieldTypes.USERNAME:
+ attrs["autocomplete"] = "username"
+ if self.type == FieldTypes.PASSWORD:
+ widget = forms.PasswordInput(attrs=attrs)
+ attrs["autocomplete"] = "new-password"
+ if self.type == FieldTypes.NUMBER:
+ field_class = forms.IntegerField
+ widget = forms.NumberInput(attrs=attrs)
+ if self.type == FieldTypes.HIDDEN:
+ widget = forms.HiddenInput(attrs=attrs)
+ kwargs["required"] = False
+ kwargs["initial"] = self.placeholder
+ if self.type == FieldTypes.CHECKBOX:
+ field_class = forms.BooleanField
+ kwargs["required"] = False
+ if self.type == FieldTypes.DATE:
+ attrs["type"] = "date"
+ widget = forms.DateInput(attrs=attrs)
+ if self.type == FieldTypes.DATE_TIME:
+ attrs["type"] = "datetime-local"
+ widget = forms.DateTimeInput(attrs=attrs)
+ if self.type == FieldTypes.STATIC:
+ widget = StaticTextWidget(attrs=attrs)
+ kwargs["initial"] = self.placeholder
+ kwargs["required"] = False
+ kwargs["label"] = ""
+ if self.type == FieldTypes.SEPARATOR:
+ widget = HorizontalRuleWidget(attrs=attrs)
+ kwargs["required"] = False
+ kwargs["label"] = ""
+
+ kwargs["widget"] = widget
+ return field_class(**kwargs)
+
+ def save(self, *args, **kwargs):
+ if self.type not in FieldTypes:
+ raise ValueError
+ return super().save(*args, **kwargs)
+
+ def __str__(self):
+ return f"Prompt '{self.field_key}' type={self.type}"
+
+ class Meta:
+
+ verbose_name = _("Prompt")
+ verbose_name_plural = _("Prompts")
+
+
+class PromptStage(Stage):
+ """Define arbitrary prompts for the user."""
+
+ fields = models.ManyToManyField(Prompt)
+
+ validation_policies = models.ManyToManyField(Policy, blank=True)
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.prompt.api import PromptStageSerializer
+
+ return PromptStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.prompt.stage import PromptStageView
+
+ return PromptStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.prompt.forms import PromptStageForm
+
+ return PromptStageForm
+
+ def __str__(self):
+ return f"Prompt Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Prompt Stage")
+ verbose_name_plural = _("Prompt Stages")
diff --git a/authentik/stages/prompt/signals.py b/authentik/stages/prompt/signals.py
new file mode 100644
index 000000000..24e4f332b
--- /dev/null
+++ b/authentik/stages/prompt/signals.py
@@ -0,0 +1,5 @@
+"""authentik prompt stage signals"""
+from django.core.signals import Signal
+
+# Arguments: password: str, plan_context: Dict[str, Any]
+password_validate = Signal()
diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py
new file mode 100644
index 000000000..7b1db0222
--- /dev/null
+++ b/authentik/stages/prompt/stage.py
@@ -0,0 +1,36 @@
+"""Prompt Stage Logic"""
+from django.http import HttpResponse
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import FormView
+from structlog import get_logger
+
+from authentik.flows.stage import StageView
+from authentik.stages.prompt.forms import PromptForm
+
+LOGGER = get_logger()
+PLAN_CONTEXT_PROMPT = "prompt_data"
+
+
+class PromptStageView(FormView, StageView):
+ """Prompt Stage, save form data in plan context."""
+
+ template_name = "login/form.html"
+ form_class = PromptForm
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx["title"] = _(self.executor.current_stage.name)
+ return ctx
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["stage"] = self.executor.current_stage
+ kwargs["plan"] = self.executor.plan
+ return kwargs
+
+ def form_valid(self, form: PromptForm) -> HttpResponse:
+ """Form data is valid"""
+ if PLAN_CONTEXT_PROMPT not in self.executor.plan.context:
+ self.executor.plan.context[PLAN_CONTEXT_PROMPT] = {}
+ self.executor.plan.context[PLAN_CONTEXT_PROMPT].update(form.cleaned_data)
+ return self.executor.stage_ok()
diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py
new file mode 100644
index 000000000..bbacd8c26
--- /dev/null
+++ b/authentik/stages/prompt/tests.py
@@ -0,0 +1,178 @@
+"""Prompt tests"""
+from unittest.mock import MagicMock, patch
+
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import User
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import FlowPlan
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.policies.expression.models import ExpressionPolicy
+from authentik.stages.prompt.forms import PromptForm
+from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+
+class TestPromptStage(TestCase):
+ """Prompt tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-prompt",
+ slug="test-prompt",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ text_prompt = Prompt.objects.create(
+ field_key="text_prompt",
+ label="TEXT_LABEL",
+ type=FieldTypes.TEXT,
+ required=True,
+ placeholder="TEXT_PLACEHOLDER",
+ )
+ email_prompt = Prompt.objects.create(
+ field_key="email_prompt",
+ label="EMAIL_LABEL",
+ type=FieldTypes.EMAIL,
+ required=True,
+ placeholder="EMAIL_PLACEHOLDER",
+ )
+ password_prompt = Prompt.objects.create(
+ field_key="password_prompt",
+ label="PASSWORD_LABEL",
+ type=FieldTypes.PASSWORD,
+ required=True,
+ placeholder="PASSWORD_PLACEHOLDER",
+ )
+ password2_prompt = Prompt.objects.create(
+ field_key="password2_prompt",
+ label="PASSWORD_LABEL",
+ type=FieldTypes.PASSWORD,
+ required=True,
+ placeholder="PASSWORD_PLACEHOLDER",
+ )
+ number_prompt = Prompt.objects.create(
+ field_key="number_prompt",
+ label="NUMBER_LABEL",
+ type=FieldTypes.NUMBER,
+ required=True,
+ placeholder="NUMBER_PLACEHOLDER",
+ )
+ hidden_prompt = Prompt.objects.create(
+ field_key="hidden_prompt",
+ type=FieldTypes.HIDDEN,
+ required=True,
+ placeholder="HIDDEN_PLACEHOLDER",
+ )
+ self.stage = PromptStage.objects.create(name="prompt-stage")
+ self.stage.fields.set(
+ [
+ text_prompt,
+ email_prompt,
+ password_prompt,
+ password2_prompt,
+ number_prompt,
+ hidden_prompt,
+ ]
+ )
+ self.stage.save()
+
+ self.prompt_data = {
+ text_prompt.field_key: "test-input",
+ email_prompt.field_key: "test@test.test",
+ password_prompt.field_key: "test",
+ password2_prompt.field_key: "test",
+ number_prompt.field_key: 3,
+ hidden_prompt.field_key: hidden_prompt.placeholder,
+ }
+
+ FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
+
+ def test_render(self):
+ """Test render of form, check if all prompts are rendered correctly"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+ for prompt in self.stage.fields.all():
+ self.assertIn(prompt.field_key, force_str(response.content))
+ self.assertIn(prompt.label, force_str(response.content))
+ self.assertIn(prompt.placeholder, force_str(response.content))
+
+ def test_valid_form_with_policy(self) -> PromptForm:
+ """Test form validation"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ expr = "return request.context['password_prompt'] == request.context['password2_prompt']"
+ expr_policy = ExpressionPolicy.objects.create(
+ name="validate-form", expression=expr
+ )
+ self.stage.validation_policies.set([expr_policy])
+ self.stage.save()
+ form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data)
+ self.assertEqual(form.is_valid(), True)
+ return form
+
+ def test_invalid_form(self) -> PromptForm:
+ """Test form validation"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ expr = "False"
+ expr_policy = ExpressionPolicy.objects.create(
+ name="validate-form", expression=expr
+ )
+ self.stage.validation_policies.set([expr_policy])
+ self.stage.save()
+ form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data)
+ self.assertEqual(form.is_valid(), False)
+ return form
+
+ def test_valid_form_request(self):
+ """Test a request with valid form data"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ form = self.test_valid_form_with_policy()
+
+ with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()):
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor",
+ kwargs={"flow_slug": self.flow.slug},
+ ),
+ form.cleaned_data,
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+
+ # Check that valid data has been saved
+ session = self.client.session
+ plan: FlowPlan = session[SESSION_KEY_PLAN]
+ data = plan.context[PLAN_CONTEXT_PROMPT]
+ for prompt in self.stage.fields.all():
+ prompt: Prompt
+ self.assertEqual(data[prompt.field_key], self.prompt_data[prompt.field_key])
diff --git a/passbook/stages/prompt/widgets.py b/authentik/stages/prompt/widgets.py
similarity index 100%
rename from passbook/stages/prompt/widgets.py
rename to authentik/stages/prompt/widgets.py
diff --git a/passbook/stages/user_delete/__init__.py b/authentik/stages/user_delete/__init__.py
similarity index 100%
rename from passbook/stages/user_delete/__init__.py
rename to authentik/stages/user_delete/__init__.py
diff --git a/authentik/stages/user_delete/api.py b/authentik/stages/user_delete/api.py
new file mode 100644
index 000000000..ef283ae38
--- /dev/null
+++ b/authentik/stages/user_delete/api.py
@@ -0,0 +1,24 @@
+"""User Delete Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.user_delete.models import UserDeleteStage
+
+
+class UserDeleteStageSerializer(ModelSerializer):
+ """UserDeleteStage Serializer"""
+
+ class Meta:
+
+ model = UserDeleteStage
+ fields = [
+ "pk",
+ "name",
+ ]
+
+
+class UserDeleteStageViewSet(ModelViewSet):
+ """UserDeleteStage Viewset"""
+
+ queryset = UserDeleteStage.objects.all()
+ serializer_class = UserDeleteStageSerializer
diff --git a/authentik/stages/user_delete/apps.py b/authentik/stages/user_delete/apps.py
new file mode 100644
index 000000000..b1ca8455c
--- /dev/null
+++ b/authentik/stages/user_delete/apps.py
@@ -0,0 +1,10 @@
+"""authentik delete stage app config"""
+from django.apps import AppConfig
+
+
+class AuthentikStageUserDeleteConfig(AppConfig):
+ """authentik delete stage config"""
+
+ name = "authentik.stages.user_delete"
+ label = "authentik_stages_user_delete"
+ verbose_name = "authentik Stages.User Delete"
diff --git a/authentik/stages/user_delete/forms.py b/authentik/stages/user_delete/forms.py
new file mode 100644
index 000000000..daf2e6fe0
--- /dev/null
+++ b/authentik/stages/user_delete/forms.py
@@ -0,0 +1,20 @@
+"""authentik flows delete forms"""
+from django import forms
+
+from authentik.stages.user_delete.models import UserDeleteStage
+
+
+class UserDeleteStageForm(forms.ModelForm):
+ """Form to delete/edit UserDeleteStage instances"""
+
+ class Meta:
+
+ model = UserDeleteStage
+ fields = ["name"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
+
+
+class UserDeleteForm(forms.Form):
+ """Confirmation form to ensure user knows they are deleting their profile"""
diff --git a/authentik/stages/user_delete/migrations/0001_initial.py b/authentik/stages/user_delete/migrations/0001_initial.py
new file mode 100644
index 000000000..6ae6061a2
--- /dev/null
+++ b/authentik/stages/user_delete/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserDeleteStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User Delete Stage",
+ "verbose_name_plural": "User Delete Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/user_delete/migrations/__init__.py b/authentik/stages/user_delete/migrations/__init__.py
similarity index 100%
rename from passbook/stages/user_delete/migrations/__init__.py
rename to authentik/stages/user_delete/migrations/__init__.py
diff --git a/authentik/stages/user_delete/models.py b/authentik/stages/user_delete/models.py
new file mode 100644
index 000000000..f9ef53e00
--- /dev/null
+++ b/authentik/stages/user_delete/models.py
@@ -0,0 +1,40 @@
+"""delete stage models"""
+from typing import Type
+
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Stage
+
+
+class UserDeleteStage(Stage):
+ """Deletes the currently pending user without confirmation.
+ Use with caution."""
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.user_delete.api import UserDeleteStageSerializer
+
+ return UserDeleteStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.user_delete.stage import UserDeleteStageView
+
+ return UserDeleteStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.user_delete.forms import UserDeleteStageForm
+
+ return UserDeleteStageForm
+
+ def __str__(self):
+ return f"User Delete Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("User Delete Stage")
+ verbose_name_plural = _("User Delete Stages")
diff --git a/authentik/stages/user_delete/stage.py b/authentik/stages/user_delete/stage.py
new file mode 100644
index 000000000..7504c150f
--- /dev/null
+++ b/authentik/stages/user_delete/stage.py
@@ -0,0 +1,34 @@
+"""Delete stage logic"""
+from django.contrib import messages
+from django.http import HttpRequest, HttpResponse
+from django.utils.translation import gettext as _
+from django.views.generic import FormView
+from structlog import get_logger
+
+from authentik.core.models import User
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.stages.user_delete.forms import UserDeleteForm
+
+LOGGER = get_logger()
+
+
+class UserDeleteStageView(FormView, StageView):
+ """Finalise unenrollment flow by deleting the user object."""
+
+ form_class = UserDeleteForm
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ message = _("No Pending User.")
+ messages.error(request, message)
+ LOGGER.debug(message)
+ return self.executor.stage_invalid()
+ return super().get(request)
+
+ def form_valid(self, form: UserDeleteForm) -> HttpResponse:
+ user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ user.delete()
+ LOGGER.debug("Deleted user", user=user)
+ del self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ return self.executor.stage_ok()
diff --git a/authentik/stages/user_delete/tests.py b/authentik/stages/user_delete/tests.py
new file mode 100644
index 000000000..fb87118bc
--- /dev/null
+++ b/authentik/stages/user_delete/tests.py
@@ -0,0 +1,95 @@
+"""delete tests"""
+from unittest.mock import patch
+
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import User
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.policies.http import AccessDeniedResponse
+from authentik.stages.user_delete.models import UserDeleteStage
+
+
+class TestUserDeleteStage(TestCase):
+ """Delete tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.username = "qerqwerqrwqwerwq"
+ self.user = User.objects.create(username=self.username, email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-delete",
+ slug="test-delete",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = UserDeleteStage.objects.create(name="delete")
+ FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
+
+ @patch(
+ "authentik.flows.views.to_stage_response",
+ TO_STAGE_RESPONSE_MOCK,
+ )
+ def test_no_user(self):
+ """Test without user set"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response, AccessDeniedResponse)
+
+ def test_user_delete_get(self):
+ """Test Form render"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_user_delete_post(self):
+ """Test User delete (actual)"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.post(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ ),
+ {},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+
+ self.assertFalse(User.objects.filter(username=self.username).exists())
diff --git a/passbook/stages/user_login/__init__.py b/authentik/stages/user_login/__init__.py
similarity index 100%
rename from passbook/stages/user_login/__init__.py
rename to authentik/stages/user_login/__init__.py
diff --git a/authentik/stages/user_login/api.py b/authentik/stages/user_login/api.py
new file mode 100644
index 000000000..43e29f105
--- /dev/null
+++ b/authentik/stages/user_login/api.py
@@ -0,0 +1,25 @@
+"""Login Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.user_login.models import UserLoginStage
+
+
+class UserLoginStageSerializer(ModelSerializer):
+ """UserLoginStage Serializer"""
+
+ class Meta:
+
+ model = UserLoginStage
+ fields = [
+ "pk",
+ "name",
+ "session_duration",
+ ]
+
+
+class UserLoginStageViewSet(ModelViewSet):
+ """UserLoginStage Viewset"""
+
+ queryset = UserLoginStage.objects.all()
+ serializer_class = UserLoginStageSerializer
diff --git a/authentik/stages/user_login/apps.py b/authentik/stages/user_login/apps.py
new file mode 100644
index 000000000..1fcc87d43
--- /dev/null
+++ b/authentik/stages/user_login/apps.py
@@ -0,0 +1,10 @@
+"""authentik login stage app config"""
+from django.apps import AppConfig
+
+
+class AuthentikStageUserLoginConfig(AppConfig):
+ """authentik login stage config"""
+
+ name = "authentik.stages.user_login"
+ label = "authentik_stages_user_login"
+ verbose_name = "authentik Stages.User Login"
diff --git a/authentik/stages/user_login/forms.py b/authentik/stages/user_login/forms.py
new file mode 100644
index 000000000..8693a7892
--- /dev/null
+++ b/authentik/stages/user_login/forms.py
@@ -0,0 +1,17 @@
+"""authentik flows login forms"""
+from django import forms
+
+from authentik.stages.user_login.models import UserLoginStage
+
+
+class UserLoginStageForm(forms.ModelForm):
+ """Form to create/edit UserLoginStage instances"""
+
+ class Meta:
+
+ model = UserLoginStage
+ fields = ["name", "session_duration"]
+ widgets = {
+ "name": forms.TextInput(),
+ "session_duration": forms.TextInput(),
+ }
diff --git a/authentik/stages/user_login/migrations/0001_initial.py b/authentik/stages/user_login/migrations/0001_initial.py
new file mode 100644
index 000000000..ed82b2fe3
--- /dev/null
+++ b/authentik/stages/user_login/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserLoginStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User Login Stage",
+ "verbose_name_plural": "User Login Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/authentik/stages/user_login/migrations/0002_userloginstage_session_duration.py b/authentik/stages/user_login/migrations/0002_userloginstage_session_duration.py
new file mode 100644
index 000000000..53bc43fda
--- /dev/null
+++ b/authentik/stages/user_login/migrations/0002_userloginstage_session_duration.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.0.7 on 2020-07-04 13:05
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_user_login", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="userloginstage",
+ name="session_duration",
+ field=models.PositiveIntegerField(
+ default=0,
+ help_text="Determines how long a session lasts, in seconds. Default of 0 means that the sessions lasts until the browser is closed.",
+ ),
+ ),
+ ]
diff --git a/authentik/stages/user_login/migrations/0003_session_duration_delta.py b/authentik/stages/user_login/migrations/0003_session_duration_delta.py
new file mode 100644
index 000000000..ebc8f09f9
--- /dev/null
+++ b/authentik/stages/user_login/migrations/0003_session_duration_delta.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.1.2 on 2020-10-26 20:21
+
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+import authentik.lib.utils.time
+
+
+def update_duration(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ UserLoginStage = apps.get_model("authentik_stages_user_login", "userloginstage")
+
+ db_alias = schema_editor.connection.alias
+
+ for stage in UserLoginStage.objects.using(db_alias).all():
+ if stage.session_duration.isdigit():
+ stage.session_duration = f"seconds={stage.session_duration}"
+ stage.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_user_login", "0002_userloginstage_session_duration"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="userloginstage",
+ name="session_duration",
+ field=models.TextField(
+ default="seconds=0",
+ help_text="Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)",
+ validators=[authentik.lib.utils.time.timedelta_string_validator],
+ ),
+ ),
+ migrations.RunPython(update_duration),
+ ]
diff --git a/passbook/stages/user_login/migrations/__init__.py b/authentik/stages/user_login/migrations/__init__.py
similarity index 100%
rename from passbook/stages/user_login/migrations/__init__.py
rename to authentik/stages/user_login/migrations/__init__.py
diff --git a/authentik/stages/user_login/models.py b/authentik/stages/user_login/models.py
new file mode 100644
index 000000000..d25018f79
--- /dev/null
+++ b/authentik/stages/user_login/models.py
@@ -0,0 +1,51 @@
+"""login stage models"""
+from typing import Type
+
+from django.db import models
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Stage
+from authentik.lib.utils.time import timedelta_string_validator
+
+
+class UserLoginStage(Stage):
+ """Attaches the currently pending user to the current session."""
+
+ session_duration = models.TextField(
+ default="seconds=0",
+ validators=[timedelta_string_validator],
+ help_text=_(
+ "Determines how long a session lasts. Default of 0 means "
+ "that the sessions lasts until the browser is closed. "
+ "(Format: hours=-1;minutes=-2;seconds=-3)"
+ ),
+ )
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.user_login.api import UserLoginStageSerializer
+
+ return UserLoginStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.user_login.stage import UserLoginStageView
+
+ return UserLoginStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.user_login.forms import UserLoginStageForm
+
+ return UserLoginStageForm
+
+ def __str__(self):
+ return f"User Login Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("User Login Stage")
+ verbose_name_plural = _("User Login Stages")
diff --git a/authentik/stages/user_login/stage.py b/authentik/stages/user_login/stage.py
new file mode 100644
index 000000000..47df6990a
--- /dev/null
+++ b/authentik/stages/user_login/stage.py
@@ -0,0 +1,48 @@
+"""Login stage logic"""
+from django.contrib import messages
+from django.contrib.auth import login
+from django.http import HttpRequest, HttpResponse
+from django.utils.translation import gettext as _
+from structlog import get_logger
+
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.lib.utils.time import timedelta_from_string
+from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+
+LOGGER = get_logger()
+
+
+class UserLoginStageView(StageView):
+ """Finalise Authentication flow by logging the user in"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ message = _("No Pending user to login.")
+ messages.error(request, message)
+ LOGGER.debug(message)
+ return self.executor.stage_invalid()
+ if PLAN_CONTEXT_AUTHENTICATION_BACKEND not in self.executor.plan.context:
+ message = _("Pending user has no backend.")
+ messages.error(request, message)
+ LOGGER.debug(message)
+ return self.executor.stage_invalid()
+ backend = self.executor.plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND]
+ login(
+ self.request,
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER],
+ backend=backend,
+ )
+ delta = timedelta_from_string(self.executor.current_stage.session_duration)
+ if delta.seconds == 0:
+ self.request.session.set_expiry(0)
+ else:
+ self.request.session.set_expiry(delta)
+ LOGGER.debug(
+ "Logged in",
+ user=self.executor.plan.context[PLAN_CONTEXT_PENDING_USER],
+ flow_slug=self.executor.flow.slug,
+ session_duration=self.executor.current_stage.session_duration,
+ )
+ messages.success(self.request, _("Successfully logged in!"))
+ return self.executor.stage_ok()
diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py
new file mode 100644
index 000000000..059793f8b
--- /dev/null
+++ b/authentik/stages/user_login/tests.py
@@ -0,0 +1,111 @@
+"""login tests"""
+from unittest.mock import patch
+
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import User
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.policies.http import AccessDeniedResponse
+from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+from authentik.stages.user_login.forms import UserLoginStageForm
+from authentik.stages.user_login.models import UserLoginStage
+
+
+class TestUserLoginStage(TestCase):
+ """Login tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-login",
+ slug="test-login",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = UserLoginStage.objects.create(name="login")
+ FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
+
+ def test_valid_password(self):
+ """Test with a valid pending user and backend"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+
+ @patch(
+ "authentik.flows.views.to_stage_response",
+ TO_STAGE_RESPONSE_MOCK,
+ )
+ def test_without_user(self):
+ """Test a plan without any pending user, resulting in a denied"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response, AccessDeniedResponse)
+
+ @patch(
+ "authentik.flows.views.to_stage_response",
+ TO_STAGE_RESPONSE_MOCK,
+ )
+ def test_without_backend(self):
+ """Test a plan with pending user, without backend, resulting in a denied"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response, AccessDeniedResponse)
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test", "session_duration": "seconds=0"}
+ self.assertEqual(UserLoginStageForm(data).is_valid(), True)
+ data = {"name": "test", "session_duration": "123"}
+ self.assertEqual(UserLoginStageForm(data).is_valid(), False)
diff --git a/passbook/stages/user_logout/__init__.py b/authentik/stages/user_logout/__init__.py
similarity index 100%
rename from passbook/stages/user_logout/__init__.py
rename to authentik/stages/user_logout/__init__.py
diff --git a/authentik/stages/user_logout/api.py b/authentik/stages/user_logout/api.py
new file mode 100644
index 000000000..8467b7a70
--- /dev/null
+++ b/authentik/stages/user_logout/api.py
@@ -0,0 +1,24 @@
+"""Logout Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.user_logout.models import UserLogoutStage
+
+
+class UserLogoutStageSerializer(ModelSerializer):
+ """UserLogoutStage Serializer"""
+
+ class Meta:
+
+ model = UserLogoutStage
+ fields = [
+ "pk",
+ "name",
+ ]
+
+
+class UserLogoutStageViewSet(ModelViewSet):
+ """UserLogoutStage Viewset"""
+
+ queryset = UserLogoutStage.objects.all()
+ serializer_class = UserLogoutStageSerializer
diff --git a/authentik/stages/user_logout/apps.py b/authentik/stages/user_logout/apps.py
new file mode 100644
index 000000000..467b4a189
--- /dev/null
+++ b/authentik/stages/user_logout/apps.py
@@ -0,0 +1,10 @@
+"""authentik logout stage app config"""
+from django.apps import AppConfig
+
+
+class AuthentikStageUserLogoutConfig(AppConfig):
+ """authentik logout stage config"""
+
+ name = "authentik.stages.user_logout"
+ label = "authentik_stages_user_logout"
+ verbose_name = "authentik Stages.User Logout"
diff --git a/authentik/stages/user_logout/forms.py b/authentik/stages/user_logout/forms.py
new file mode 100644
index 000000000..44d3cba4c
--- /dev/null
+++ b/authentik/stages/user_logout/forms.py
@@ -0,0 +1,16 @@
+"""authentik flows logout forms"""
+from django import forms
+
+from authentik.stages.user_logout.models import UserLogoutStage
+
+
+class UserLogoutStageForm(forms.ModelForm):
+ """Form to create/edit UserLogoutStage instances"""
+
+ class Meta:
+
+ model = UserLogoutStage
+ fields = ["name"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/authentik/stages/user_logout/migrations/0001_initial.py b/authentik/stages/user_logout/migrations/0001_initial.py
new file mode 100644
index 000000000..1b1ba2860
--- /dev/null
+++ b/authentik/stages/user_logout/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserLogoutStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User Logout Stage",
+ "verbose_name_plural": "User Logout Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/passbook/stages/user_logout/migrations/__init__.py b/authentik/stages/user_logout/migrations/__init__.py
similarity index 100%
rename from passbook/stages/user_logout/migrations/__init__.py
rename to authentik/stages/user_logout/migrations/__init__.py
diff --git a/authentik/stages/user_logout/models.py b/authentik/stages/user_logout/models.py
new file mode 100644
index 000000000..c59ff085c
--- /dev/null
+++ b/authentik/stages/user_logout/models.py
@@ -0,0 +1,39 @@
+"""logout stage models"""
+from typing import Type
+
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Stage
+
+
+class UserLogoutStage(Stage):
+ """Resets the users current session."""
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.user_logout.api import UserLogoutStageSerializer
+
+ return UserLogoutStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.user_logout.stage import UserLogoutStageView
+
+ return UserLogoutStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.user_logout.forms import UserLogoutStageForm
+
+ return UserLogoutStageForm
+
+ def __str__(self):
+ return f"User Logout Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("User Logout Stage")
+ verbose_name_plural = _("User Logout Stages")
diff --git a/authentik/stages/user_logout/stage.py b/authentik/stages/user_logout/stage.py
new file mode 100644
index 000000000..d658f38ca
--- /dev/null
+++ b/authentik/stages/user_logout/stage.py
@@ -0,0 +1,21 @@
+"""Logout stage logic"""
+from django.contrib.auth import logout
+from django.http import HttpRequest, HttpResponse
+from structlog import get_logger
+
+from authentik.flows.stage import StageView
+
+LOGGER = get_logger()
+
+
+class UserLogoutStageView(StageView):
+ """Finalise Authentication flow by logging the user in"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ LOGGER.debug(
+ "Logged out",
+ user=request.user,
+ flow_slug=self.executor.flow.slug,
+ )
+ logout(self.request)
+ return self.executor.stage_ok()
diff --git a/authentik/stages/user_logout/tests.py b/authentik/stages/user_logout/tests.py
new file mode 100644
index 000000000..dd3b9367d
--- /dev/null
+++ b/authentik/stages/user_logout/tests.py
@@ -0,0 +1,60 @@
+"""logout tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import User
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+from authentik.stages.user_logout.forms import UserLogoutStageForm
+from authentik.stages.user_logout.models import UserLogoutStage
+
+
+class TestUserLogoutStage(TestCase):
+ """Logout tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-logout",
+ slug="test-logout",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = UserLogoutStage.objects.create(name="logout")
+ FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
+
+ def test_valid_password(self):
+ """Test with a valid pending user and backend"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test"}
+ self.assertEqual(UserLogoutStageForm(data).is_valid(), True)
diff --git a/passbook/stages/user_write/__init__.py b/authentik/stages/user_write/__init__.py
similarity index 100%
rename from passbook/stages/user_write/__init__.py
rename to authentik/stages/user_write/__init__.py
diff --git a/authentik/stages/user_write/api.py b/authentik/stages/user_write/api.py
new file mode 100644
index 000000000..0e2833e02
--- /dev/null
+++ b/authentik/stages/user_write/api.py
@@ -0,0 +1,24 @@
+"""User Write Stage API Views"""
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import ModelViewSet
+
+from authentik.stages.user_write.models import UserWriteStage
+
+
+class UserWriteStageSerializer(ModelSerializer):
+ """UserWriteStage Serializer"""
+
+ class Meta:
+
+ model = UserWriteStage
+ fields = [
+ "pk",
+ "name",
+ ]
+
+
+class UserWriteStageViewSet(ModelViewSet):
+ """UserWriteStage Viewset"""
+
+ queryset = UserWriteStage.objects.all()
+ serializer_class = UserWriteStageSerializer
diff --git a/authentik/stages/user_write/apps.py b/authentik/stages/user_write/apps.py
new file mode 100644
index 000000000..62e933dae
--- /dev/null
+++ b/authentik/stages/user_write/apps.py
@@ -0,0 +1,10 @@
+"""authentik write stage app config"""
+from django.apps import AppConfig
+
+
+class AuthentikStageUserWriteConfig(AppConfig):
+ """authentik write stage config"""
+
+ name = "authentik.stages.user_write"
+ label = "authentik_stages_user_write"
+ verbose_name = "authentik Stages.User Write"
diff --git a/authentik/stages/user_write/forms.py b/authentik/stages/user_write/forms.py
new file mode 100644
index 000000000..685afc57d
--- /dev/null
+++ b/authentik/stages/user_write/forms.py
@@ -0,0 +1,16 @@
+"""authentik flows write forms"""
+from django import forms
+
+from authentik.stages.user_write.models import UserWriteStage
+
+
+class UserWriteStageForm(forms.ModelForm):
+ """Form to write/edit UserWriteStage instances"""
+
+ class Meta:
+
+ model = UserWriteStage
+ fields = ["name"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
diff --git a/authentik/stages/user_write/migrations/0001_initial.py b/authentik/stages/user_write/migrations/0001_initial.py
new file mode 100644
index 000000000..94583d7ec
--- /dev/null
+++ b/authentik/stages/user_write/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.6 on 2020-05-19 22:08
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserWriteStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.Stage",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User Write Stage",
+ "verbose_name_plural": "User Write Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/authentik/stages/user_write/migrations/0002_auto_20200918_1653.py b/authentik/stages/user_write/migrations/0002_auto_20200918_1653.py
new file mode 100644
index 000000000..ddf6d96dd
--- /dev/null
+++ b/authentik/stages/user_write/migrations/0002_auto_20200918_1653.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.1.1 on 2020-09-18 16:53
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def remove_unintended_attributes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ db_alias = schema_editor.connection.alias
+ User = apps.get_model("authentik_core", "User")
+ for user in User.objects.using(db_alias).all():
+ if "password_repeat" in user.attributes:
+ del user.attributes["password_repeat"]
+ if "password" in user.attributes:
+ del user.attributes["password"]
+ user.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_user_write", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RunPython(remove_unintended_attributes),
+ ]
diff --git a/passbook/stages/user_write/migrations/__init__.py b/authentik/stages/user_write/migrations/__init__.py
similarity index 100%
rename from passbook/stages/user_write/migrations/__init__.py
rename to authentik/stages/user_write/migrations/__init__.py
diff --git a/authentik/stages/user_write/models.py b/authentik/stages/user_write/models.py
new file mode 100644
index 000000000..820b5bd92
--- /dev/null
+++ b/authentik/stages/user_write/models.py
@@ -0,0 +1,40 @@
+"""write stage models"""
+from typing import Type
+
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Stage
+
+
+class UserWriteStage(Stage):
+ """Writes currently pending data into the pending user, or if no user exists,
+ creates a new user with the data."""
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.user_write.api import UserWriteStageSerializer
+
+ return UserWriteStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.user_write.stage import UserWriteStageView
+
+ return UserWriteStageView
+
+ @property
+ def form(self) -> Type[ModelForm]:
+ from authentik.stages.user_write.forms import UserWriteStageForm
+
+ return UserWriteStageForm
+
+ def __str__(self):
+ return f"User Write Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("User Write Stage")
+ verbose_name_plural = _("User Write Stages")
diff --git a/authentik/stages/user_write/signals.py b/authentik/stages/user_write/signals.py
new file mode 100644
index 000000000..7a4c3811a
--- /dev/null
+++ b/authentik/stages/user_write/signals.py
@@ -0,0 +1,5 @@
+"""authentik user_write signals"""
+from django.core.signals import Signal
+
+# Arguments: request: HttpRequest, user: User, data: Dict[str, Any], created: bool
+user_write = Signal()
diff --git a/authentik/stages/user_write/stage.py b/authentik/stages/user_write/stage.py
new file mode 100644
index 000000000..94595aedd
--- /dev/null
+++ b/authentik/stages/user_write/stage.py
@@ -0,0 +1,83 @@
+"""Write stage logic"""
+from django.contrib import messages
+from django.contrib.auth import update_session_auth_hash
+from django.contrib.auth.backends import ModelBackend
+from django.http import HttpRequest, HttpResponse
+from django.utils.translation import gettext as _
+from structlog import get_logger
+
+from authentik.core.middleware import SESSION_IMPERSONATE_USER
+from authentik.core.models import User
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import StageView
+from authentik.lib.utils.reflection import class_to_path
+from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+from authentik.stages.user_write.signals import user_write
+
+LOGGER = get_logger()
+
+
+class UserWriteStageView(StageView):
+ """Finalise Enrollment flow by creating a user object."""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ if PLAN_CONTEXT_PROMPT not in self.executor.plan.context:
+ message = _("No Pending data.")
+ messages.error(request, message)
+ LOGGER.debug(message)
+ return self.executor.stage_invalid()
+ data = self.executor.plan.context[PLAN_CONTEXT_PROMPT]
+ user_created = False
+ if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User()
+ self.executor.plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = class_to_path(ModelBackend)
+ LOGGER.debug(
+ "Created new user",
+ flow_slug=self.executor.flow.slug,
+ )
+ user_created = True
+ user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ # Before we change anything, check if the user is the same as in the request
+ # and we're updating a password. In that case we need to update the session hash
+ # Also check that we're not currently impersonating, so we don't update the session
+ should_update_seesion = False
+ if (
+ any(["password" in x for x in data.keys()])
+ and self.request.user.pk == user.pk
+ and SESSION_IMPERSONATE_USER not in self.request.session
+ ):
+ should_update_seesion = True
+ for key, value in data.items():
+ setter_name = f"set_{key}"
+ # Check if user has a setter for this key, like set_password
+ if hasattr(user, setter_name):
+ setter = getattr(user, setter_name)
+ if callable(setter):
+ setter(value)
+ # User has this key already
+ elif hasattr(user, key):
+ setattr(user, key, value)
+ # Otherwise we just save it as custom attribute, but only if the value is prefixed with
+ # `attribute_`, to prevent accidentally saving values
+ else:
+ if not key.startswith("attribute_"):
+ LOGGER.debug("discarding key", key=key)
+ continue
+ user.attributes[key.replace("attribute_", "", 1)] = value
+ user.save()
+ user_write.send(
+ sender=self, request=request, user=user, data=data, created=user_created
+ )
+ # Check if the password has been updated, and update the session auth hash
+ if should_update_seesion:
+ update_session_auth_hash(self.request, user)
+ LOGGER.debug("Updated session hash", user=user)
+ LOGGER.debug(
+ "Updated existing user",
+ user=user,
+ flow_slug=self.executor.flow.slug,
+ )
+ return self.executor.stage_ok()
diff --git a/authentik/stages/user_write/tests.py b/authentik/stages/user_write/tests.py
new file mode 100644
index 000000000..43f1bd1fe
--- /dev/null
+++ b/authentik/stages/user_write/tests.py
@@ -0,0 +1,138 @@
+"""write tests"""
+import string
+from random import SystemRandom
+from unittest.mock import patch
+
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+from django.utils.encoding import force_str
+
+from authentik.core.models import User
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
+from authentik.flows.views import SESSION_KEY_PLAN
+from authentik.policies.http import AccessDeniedResponse
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+from authentik.stages.user_write.forms import UserWriteStageForm
+from authentik.stages.user_write.models import UserWriteStage
+
+
+class TestUserWriteStage(TestCase):
+ """Write tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-write",
+ slug="test-write",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = UserWriteStage.objects.create(name="write")
+ FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
+
+ def test_user_create(self):
+ """Test creation of user"""
+ password = "".join(
+ SystemRandom().choice(string.ascii_uppercase + string.digits)
+ for _ in range(8)
+ )
+
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PROMPT] = {
+ "username": "test-user",
+ "name": "name",
+ "email": "test@beryju.org",
+ "password": password,
+ }
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+ user_qs = User.objects.filter(
+ username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
+ )
+ self.assertTrue(user_qs.exists())
+ self.assertTrue(user_qs.first().check_password(password))
+
+ def test_user_update(self):
+ """Test update of existing user"""
+ new_password = "".join(
+ SystemRandom().choice(string.ascii_uppercase + string.digits)
+ for _ in range(8)
+ )
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ plan.context[PLAN_CONTEXT_PENDING_USER] = User.objects.create(
+ username="unittest", email="test@beryju.org"
+ )
+ plan.context[PLAN_CONTEXT_PROMPT] = {
+ "username": "test-user-new",
+ "password": new_password,
+ "attribute_some-custom-attribute": "test",
+ }
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ force_str(response.content),
+ {"type": "redirect", "to": reverse("authentik_core:shell")},
+ )
+ user_qs = User.objects.filter(
+ username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
+ )
+ self.assertTrue(user_qs.exists())
+ self.assertTrue(user_qs.first().check_password(new_password))
+ self.assertEqual(user_qs.first().attributes["some-custom-attribute"], "test")
+
+ @patch(
+ "authentik.flows.views.to_stage_response",
+ TO_STAGE_RESPONSE_MOCK,
+ )
+ def test_without_data(self):
+ """Test without data results in error"""
+ plan = FlowPlan(
+ flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
+ )
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response, AccessDeniedResponse)
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test"}
+ self.assertEqual(UserWriteStageForm(data).is_valid(), True)
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index e2f72918a..4ffdd7813 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -5,8 +5,8 @@ resources:
- repo: self
variables:
- POSTGRES_DB: passbook
- POSTGRES_USER: passbook
+ POSTGRES_DB: authentik
+ POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
${{ if startsWith(variables['Build.SourceBranch'], 'refs/heads/') }}:
branchName: ${{ replace(variables['Build.SourceBranchName'], 'refs/heads/', '') }}
@@ -31,7 +31,7 @@ stages:
pipenv install --dev
- task: CmdLine@2
inputs:
- script: pipenv run pylint passbook tests lifecycle
+ script: pipenv run pylint authentik tests lifecycle
- job: black
pool:
vmImage: 'ubuntu-latest'
@@ -47,7 +47,7 @@ stages:
pipenv install --dev
- task: CmdLine@2
inputs:
- script: pipenv run black --check passbook tests lifecycle
+ script: pipenv run black --check authentik tests lifecycle
- job: prospector
pool:
vmImage: 'ubuntu-latest'
@@ -80,7 +80,7 @@ stages:
pipenv install --dev
- task: CmdLine@2
inputs:
- script: pipenv run bandit -r passbook tests lifecycle
+ script: pipenv run bandit -r authentik tests lifecycle
- job: pyright
pool:
vmImage: ubuntu-latest
@@ -147,6 +147,8 @@ stages:
displayName: Prepare Last tagged release
inputs:
script: |
+ # Copy current, latest config to local
+ cp authentik/lib/default.yml local.env.yml
git checkout $(git describe --abbrev=0 --match 'version/*')
sudo apt install -y libxmlsec1-dev pkg-config
sudo pip install -U wheel pipenv
@@ -154,7 +156,8 @@ stages:
- task: CmdLine@2
displayName: Migrate to last tagged release
inputs:
- script: pipenv run ./manage.py migrate
+ script:
+ pipenv run ./manage.py migrate
- task: CmdLine@2
displayName: Install current branch
inputs:
@@ -165,7 +168,9 @@ stages:
- task: CmdLine@2
displayName: Migrate to current branch
inputs:
- script: pipenv run ./manage.py migrate
+ script: |
+ pipenv run python -m lifecycle.migrate
+ pipenv run ./manage.py migrate
- job: coverage_unittest
pool:
vmImage: 'ubuntu-latest'
@@ -367,7 +372,7 @@ stages:
- task: Docker@2
inputs:
containerRegistry: 'dockerhub'
- repository: 'beryju/passbook'
+ repository: 'beryju/authentik'
command: 'buildAndPush'
Dockerfile: 'Dockerfile'
tags: "gh-${{ variables.branchName }}"
diff --git a/docker-compose.yml b/docker-compose.yml
index 075b3ce4e..860b18903 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,8 +10,8 @@ services:
- internal
environment:
- POSTGRES_PASSWORD=${PG_PASS:-thisisnotagoodpassword}
- - POSTGRES_USER=passbook
- - POSTGRES_DB=passbook
+ - POSTGRES_USER=authentik
+ - POSTGRES_DB=authentik
env_file:
- .env
redis:
@@ -19,12 +19,12 @@ services:
networks:
- internal
server:
- image: beryju/passbook:${PASSBOOK_TAG:-0.12.11-stable}
+ image: beryju/authentik:${AUTHENTIK_TAG:-0.12.11-stable}
command: server
environment:
- PASSBOOK_REDIS__HOST: redis
- PASSBOOK_POSTGRESQL__HOST: postgresql
- PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS}
+ AUTHENTIK_REDIS__HOST: redis
+ AUTHENTIK_POSTGRESQL__HOST: postgresql
+ AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
volumes:
- ./media:/media
ports:
@@ -37,26 +37,26 @@ services:
traefik.http.routers.app-router.rule: PathPrefix(`/`)
traefik.http.routers.app-router.service: app-service
traefik.http.routers.app-router.tls: 'true'
- traefik.http.services.app-service.loadbalancer.healthcheck.hostname: passbook-healthcheck-host
+ traefik.http.services.app-service.loadbalancer.healthcheck.hostname: authentik-healthcheck-host
traefik.http.services.app-service.loadbalancer.server.port: '8000'
env_file:
- .env
worker:
- image: beryju/passbook:${PASSBOOK_TAG:-0.12.11-stable}
+ image: beryju/authentik:${AUTHENTIK_TAG:-0.12.11-stable}
command: worker
networks:
- internal
environment:
- PASSBOOK_REDIS__HOST: redis
- PASSBOOK_POSTGRESQL__HOST: postgresql
- PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS}
+ AUTHENTIK_REDIS__HOST: redis
+ AUTHENTIK_POSTGRESQL__HOST: postgresql
+ AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
volumes:
- ./backups:/backups
- /var/run/docker.sock:/var/run/docker.sock
env_file:
- .env
static:
- image: beryju/passbook-static:${PASSBOOK_TAG:-0.12.11-stable}
+ image: beryju/authentik-static:${AUTHENTIK_TAG:-0.12.11-stable}
networks:
- internal
labels:
diff --git a/helm/Chart.yaml b/helm/Chart.yaml
index a52c43328..136dce6d1 100644
--- a/helm/Chart.yaml
+++ b/helm/Chart.yaml
@@ -1,11 +1,11 @@
apiVersion: v2
-description: passbook is an open-source Identity Provider focused on flexibility and versatility. You can use passbook in an existing environment to add support for new protocols. passbook is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it.
-name: passbook
-home: https://passbook.beryju.org
+description: authentik is an open-source Identity Provider focused on flexibility and versatility. You can use authentik in an existing environment to add support for new protocols. authentik is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it.
+name: authentik
+home: https://goauthentik.io
sources:
- - https://github.com/BeryJu/passbook
+ - https://github.com/BeryJu/authentik
version: "0.12.11-stable"
-icon: https://raw.githubusercontent.com/BeryJu/passbook/master/website/static/img/logo.svg
+icon: https://raw.githubusercontent.com/BeryJu/authentik/master/icons/icon.svg
dependencies:
- name: postgresql
version: 9.4.1
diff --git a/helm/README.md b/helm/README.md
index b7118770c..68a2c7d27 100644
--- a/helm/README.md
+++ b/helm/README.md
@@ -1,28 +1,28 @@
-# passbook Helm Chart
+# authentik Helm Chart
| Name | Default | Description |
|-----------------------------------|-------------------------|-------------|
-| image.name | beryju/passbook | Image used to run the passbook server and worker |
-| image.name_static | beryju/passbook-static | Image used to run the passbook static server (CSS and JS Files) |
+| image.name | beryju/authentik | Image used to run the authentik server and worker |
+| image.name_static | beryju/authentik-static | Image used to run the authentik static server (CSS and JS Files) |
| image.tag | 0.12.5-stable | Image tag |
| serverReplicas | 1 | Replicas for the Server deployment |
| workerReplicas | 1 | Replicas for the Worker deployment |
-| kubernetesIntegration | true | Enable/disable the Kubernetes integration for passbook. This will create a service account for passbook to create and update outposts in passbook |
+| kubernetesIntegration | true | Enable/disable the Kubernetes integration for authentik. This will create a service account for authentik to create and update outposts in authentik |
| config.secretKey | | Secret key used to sign session cookies, generate with `pwgen 50 1` for example. |
| config.errorReporting.enabled | false | Enable/disable error reporting |
| config.errorReporting.environment | customer | Environment sent with the error reporting |
| config.errorReporting.sendPii | false | Whether to send Personally-identifiable data with the error reporting |
-| config.logLevel | warning | Log level of passbook |
+| config.logLevel | warning | Log level of authentik |
| backup.accessKey | | Optionally enable S3 Backup, Access Key |
| backup.secretKey | | Optionally enable S3 Backup, Secret Key |
| backup.bucket | | Optionally enable S3 Backup, Bucket |
| backup.region | | Optionally enable S3 Backup, Region |
| backup.host | | Optionally enable S3 Backup, to custom Endpoint like minio |
| ingress.annotations | {} | Annotations for the ingress object |
-| ingress.hosts | [passbook.k8s.local] | Hosts which the ingress will match |
+| ingress.hosts | [authentik.k8s.local] | Hosts which the ingress will match |
| ingress.tls | [] | TLS Configuration, same as Ingress objects |
| install.postgresql | true | Enables/disables the packaged PostgreSQL Chart
| install.redis | true | Enables/disables the packaged Redis Chart
| postgresql.postgresqlPassword | | Password used for PostgreSQL, generated automatically.
-For more info, see https://passbook.beryju.org/ and https://passbook.beryju.org/docs/installation/kubernetes/
+For more info, see https://goauthentik.io/ and https://goauthentik.io/docs/installation/kubernetes/
diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt
index 2af4fed6f..5f47f8c69 100644
--- a/helm/templates/NOTES.txt
+++ b/helm/templates/NOTES.txt
@@ -1,5 +1,5 @@
-1. Access passbook using the following URL:
+1. Access authentik using the following URL:
{{- range .Values.ingress.hosts }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ . }}{{ $.Values.ingress.path }}
{{- end }}
-2. Login to passbook using the user "pbadmin" and the password "pbadmin".
+2. Login to authentik using the user "akadmin" and the password "akadmin".
diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl
index 51fc9e667..fcb35da00 100644
--- a/helm/templates/_helpers.tpl
+++ b/helm/templates/_helpers.tpl
@@ -2,7 +2,7 @@
{{/*
Expand the name of the chart.
*/}}
-{{- define "passbook.name" -}}
+{{- define "authentik.name" -}}
{{- default .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
@@ -11,7 +11,7 @@ Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
-{{- define "passbook.fullname" -}}
+{{- define "authentik.fullname" -}}
{{- $name := default .Chart.Name -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
@@ -23,6 +23,6 @@ If release name contains chart name it will be used as a full name.
{{/*
Create chart name and version as used by the chart label.
*/}}
-{{- define "passbook.chart" -}}
+{{- define "authentik.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml
index d52565469..61af2bf65 100644
--- a/helm/templates/configmap.yaml
+++ b/helm/templates/configmap.yaml
@@ -1,7 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
- name: {{ include "passbook.fullname" . }}-config
+ name: {{ include "authentik.fullname" . }}-config
data:
POSTGRESQL__HOST: "{{ .Release.Name }}-postgresql"
POSTGRESQL__NAME: "{{ .Values.postgresql.postgresqlDatabase }}"
diff --git a/helm/templates/ingress.yaml b/helm/templates/ingress.yaml
index eee9f3d04..94de6bb6e 100644
--- a/helm/templates/ingress.yaml
+++ b/helm/templates/ingress.yaml
@@ -1,11 +1,11 @@
-{{- $fullName := include "passbook.fullname" . -}}
+{{- $fullName := include "authentik.fullname" . -}}
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
- helm.sh/chart: {{ include "passbook.chart" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
+ helm.sh/chart: {{ include "authentik.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- with .Values.ingress.annotations }}
diff --git a/helm/templates/pvc.yaml b/helm/templates/pvc.yaml
index dd42cb5c7..45c665ac4 100644
--- a/helm/templates/pvc.yaml
+++ b/helm/templates/pvc.yaml
@@ -1,10 +1,10 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
- name: {{ include "passbook.fullname" . }}-uploads
+ name: {{ include "authentik.fullname" . }}-uploads
labels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
- helm.sh/chart: {{ include "passbook.chart" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
+ helm.sh/chart: {{ include "authentik.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
diff --git a/helm/templates/secret.yaml b/helm/templates/secret.yaml
index 819c764c8..bbe9ff8d5 100644
--- a/helm/templates/secret.yaml
+++ b/helm/templates/secret.yaml
@@ -2,7 +2,7 @@ apiVersion: v1
kind: Secret
type: Opaque
metadata:
- name: {{ include "passbook.fullname" . }}-secret-key
+ name: {{ include "authentik.fullname" . }}-secret-key
data:
monitoring_username: bW9uaXRvcg== # monitor in base64
{{- if .Values.config.secretKey }}
diff --git a/helm/templates/service-account.yaml b/helm/templates/service-account.yaml
index 1c7f9d070..947375828 100644
--- a/helm/templates/service-account.yaml
+++ b/helm/templates/service-account.yaml
@@ -2,7 +2,7 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
- name: {{ include "passbook.fullname" . }}-sa-role
+ name: {{ include "authentik.fullname" . }}-sa-role
rules:
- apiGroups:
- ""
@@ -47,18 +47,18 @@ rules:
apiVersion: v1
kind: ServiceAccount
metadata:
- name: {{ include "passbook.fullname" . }}-sa
+ name: {{ include "authentik.fullname" . }}-sa
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
- name: {{ include "passbook.fullname" . }}-sa-role-binding
+ name: {{ include "authentik.fullname" . }}-sa-role-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
- name: {{ include "passbook.fullname" . }}-sa-role
+ name: {{ include "authentik.fullname" . }}-sa-role
subjects:
- kind: ServiceAccount
- name: {{ include "passbook.fullname" . }}-sa
+ name: {{ include "authentik.fullname" . }}-sa
namespace: {{ .Release.Namespace }}
{{- end }}
diff --git a/helm/templates/static-deployment.yaml b/helm/templates/static-deployment.yaml
index 08108b235..e35f268b9 100644
--- a/helm/templates/static-deployment.yaml
+++ b/helm/templates/static-deployment.yaml
@@ -1,25 +1,25 @@
apiVersion: apps/v1
kind: Deployment
metadata:
- name: {{ include "passbook.fullname" . }}-static
+ name: {{ include "authentik.fullname" . }}-static
labels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
- helm.sh/chart: {{ include "passbook.chart" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
+ helm.sh/chart: {{ include "authentik.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
- k8s.passbook.beryju.org/component: static
+ k8s.goauthentik.io/component: static
spec:
selector:
matchLabels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
- k8s.passbook.beryju.org/component: static
+ k8s.goauthentik.io/component: static
template:
metadata:
labels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
- k8s.passbook.beryju.org/component: static
+ k8s.goauthentik.io/component: static
spec:
containers:
- name: {{ .Chart.Name }}-static
@@ -49,9 +49,9 @@ spec:
cpu: 20m
memory: 20M
volumeMounts:
- - name: passbook-uploads
+ - name: authentik-uploads
mountPath: /usr/share/nginx/html/media
volumes:
- - name: passbook-uploads
+ - name: authentik-uploads
persistentVolumeClaim:
- claimName: {{ include "passbook.fullname" . }}-uploads
+ claimName: {{ include "authentik.fullname" . }}-uploads
diff --git a/helm/templates/static-service.yaml b/helm/templates/static-service.yaml
index 76c1d5b91..7e9482619 100644
--- a/helm/templates/static-service.yaml
+++ b/helm/templates/static-service.yaml
@@ -1,13 +1,13 @@
apiVersion: v1
kind: Service
metadata:
- name: {{ include "passbook.fullname" . }}-static
+ name: {{ include "authentik.fullname" . }}-static
labels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
- helm.sh/chart: {{ include "passbook.chart" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
+ helm.sh/chart: {{ include "authentik.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
- k8s.passbook.beryju.org/component: static
+ k8s.goauthentik.io/component: static
spec:
type: ClusterIP
ports:
@@ -16,6 +16,6 @@ spec:
protocol: TCP
name: http
selector:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
- k8s.passbook.beryju.org/component: static
+ k8s.goauthentik.io/component: static
diff --git a/helm/templates/web-deployment.yaml b/helm/templates/web-deployment.yaml
index d87b2d1a0..498b6a7a7 100644
--- a/helm/templates/web-deployment.yaml
+++ b/helm/templates/web-deployment.yaml
@@ -1,26 +1,26 @@
apiVersion: apps/v1
kind: Deployment
metadata:
- name: {{ include "passbook.fullname" . }}-web
+ name: {{ include "authentik.fullname" . }}-web
labels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
- helm.sh/chart: {{ include "passbook.chart" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
+ helm.sh/chart: {{ include "authentik.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
- k8s.passbook.beryju.org/component: web
+ k8s.goauthentik.io/component: web
spec:
replicas: {{ .Values.serverReplicas }}
selector:
matchLabels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
- k8s.passbook.beryju.org/component: web
+ k8s.goauthentik.io/component: web
template:
metadata:
labels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
- k8s.passbook.beryju.org/component: web
+ k8s.goauthentik.io/component: web
spec:
affinity:
podAntiAffinity:
@@ -32,36 +32,36 @@ spec:
- key: app.kubernetes.io/name
operator: In
values:
- - {{ include "passbook.name" . }}
+ - {{ include "authentik.name" . }}
- key: app.kubernetes.io/instance
operator: In
values:
- {{ .Release.Name }}
- - key: k8s.passbook.beryju.org/component
+ - key: k8s.goauthentik.io/component
operator: In
values:
- web
topologyKey: "kubernetes.io/hostname"
initContainers:
- - name: passbook-database-migrations
+ - name: authentik-database-migrations
image: "{{ .Values.image.name }}:{{ .Values.image.tag }}"
args: [migrate]
envFrom:
- configMapRef:
- name: {{ include "passbook.fullname" . }}-config
- prefix: PASSBOOK_
+ name: {{ include "authentik.fullname" . }}-config
+ prefix: AUTHENTIK_
env:
- - name: PASSBOOK_SECRET_KEY
+ - name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
- name: {{ include "passbook.fullname" . }}-secret-key
+ name: {{ include "authentik.fullname" . }}-secret-key
key: secret_key
- - name: PASSBOOK_REDIS__PASSWORD
+ - name: AUTHENTIK_REDIS__PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-redis"
key: redis-password
- - name: PASSBOOK_POSTGRESQL__PASSWORD
+ - name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-postgresql"
@@ -72,26 +72,26 @@ spec:
args: [server]
envFrom:
- configMapRef:
- name: {{ include "passbook.fullname" . }}-config
- prefix: PASSBOOK_
+ name: {{ include "authentik.fullname" . }}-config
+ prefix: AUTHENTIK_
env:
- - name: PASSBOOK_SECRET_KEY
+ - name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
- name: "{{ include "passbook.fullname" . }}-secret-key"
+ name: "{{ include "authentik.fullname" . }}-secret-key"
key: "secret_key"
- - name: PASSBOOK_REDIS__PASSWORD
+ - name: AUTHENTIK_REDIS__PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-redis"
key: "redis-password"
- - name: PASSBOOK_POSTGRESQL__PASSWORD
+ - name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-postgresql"
key: "postgresql-password"
volumeMounts:
- - name: passbook-uploads
+ - name: authentik-uploads
mountPath: /media
ports:
- name: http
@@ -103,14 +103,14 @@ spec:
port: http
httpHeaders:
- name: Host
- value: passbook-healthcheck-host
+ value: authentik-healthcheck-host
readinessProbe:
httpGet:
path: /
port: http
httpHeaders:
- name: Host
- value: passbook-healthcheck-host
+ value: authentik-healthcheck-host
resources:
requests:
cpu: 100m
@@ -119,6 +119,6 @@ spec:
cpu: 300m
memory: 500M
volumes:
- - name: passbook-uploads
+ - name: authentik-uploads
persistentVolumeClaim:
- claimName: {{ include "passbook.fullname" . }}-uploads
+ claimName: {{ include "authentik.fullname" . }}-uploads
diff --git a/helm/templates/web-service.yaml b/helm/templates/web-service.yaml
index 6e35bf9e8..0fcbbf9b6 100644
--- a/helm/templates/web-service.yaml
+++ b/helm/templates/web-service.yaml
@@ -1,13 +1,13 @@
apiVersion: v1
kind: Service
metadata:
- name: {{ include "passbook.fullname" . }}-web
+ name: {{ include "authentik.fullname" . }}-web
labels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
- helm.sh/chart: {{ include "passbook.chart" . }}
- k8s.passbook.beryju.org/component: web
+ helm.sh/chart: {{ include "authentik.chart" . }}
+ k8s.goauthentik.io/component: web
spec:
type: ClusterIP
ports:
@@ -16,6 +16,6 @@ spec:
protocol: TCP
name: http
selector:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
- k8s.passbook.beryju.org/component: web
+ k8s.goauthentik.io/component: web
diff --git a/helm/templates/worker-deployment.yaml b/helm/templates/worker-deployment.yaml
index b7bd41014..e5c2b659d 100644
--- a/helm/templates/worker-deployment.yaml
+++ b/helm/templates/worker-deployment.yaml
@@ -1,29 +1,29 @@
apiVersion: apps/v1
kind: Deployment
metadata:
- name: {{ include "passbook.fullname" . }}-worker
+ name: {{ include "authentik.fullname" . }}-worker
labels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
- helm.sh/chart: {{ include "passbook.chart" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
+ helm.sh/chart: {{ include "authentik.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
- k8s.passbook.beryju.org/component: worker
+ k8s.goauthentik.io/component: worker
spec:
replicas: {{ .Values.workerReplicas }}
selector:
matchLabels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
- k8s.passbook.beryju.org/component: worker
+ k8s.goauthentik.io/component: worker
template:
metadata:
labels:
- app.kubernetes.io/name: {{ include "passbook.name" . }}
+ app.kubernetes.io/name: {{ include "authentik.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
- k8s.passbook.beryju.org/component: worker
+ k8s.goauthentik.io/component: worker
spec:
{{- if .Values.kubernetesIntegration }}
- serviceAccountName: {{ include "passbook.fullname" . }}-sa
+ serviceAccountName: {{ include "authentik.fullname" . }}-sa
{{- end }}
affinity:
podAntiAffinity:
@@ -35,12 +35,12 @@ spec:
- key: app.kubernetes.io/name
operator: In
values:
- - {{ include "passbook.name" . }}
+ - {{ include "authentik.name" . }}
- key: app.kubernetes.io/instance
operator: In
values:
- {{ .Release.Name }}
- - key: k8s.passbook.beryju.org/component
+ - key: k8s.goauthentik.io/component
operator: In
values:
- worker
@@ -52,20 +52,20 @@ spec:
args: [worker]
envFrom:
- configMapRef:
- name: "{{ include "passbook.fullname" . }}-config"
- prefix: "PASSBOOK_"
+ name: "{{ include "authentik.fullname" . }}-config"
+ prefix: "AUTHENTIK_"
env:
- - name: PASSBOOK_SECRET_KEY
+ - name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
- name: "{{ include "passbook.fullname" . }}-secret-key"
+ name: "{{ include "authentik.fullname" . }}-secret-key"
key: secret_key
- - name: PASSBOOK_REDIS__PASSWORD
+ - name: AUTHENTIK_REDIS__PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-redis"
key: "redis-password"
- - name: PASSBOOK_POSTGRESQL__PASSWORD
+ - name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-postgresql"
diff --git a/helm/values.test.yaml b/helm/values.test.yaml
index e953b68b5..7ea90c543 100644
--- a/helm/values.test.yaml
+++ b/helm/values.test.yaml
@@ -11,9 +11,9 @@ config:
ingress:
hosts:
- - passbook.127.0.0.1.nip.io
+ - authentik.127.0.0.1.nip.io
-# These values influence the bundled postgresql and redis charts, but are also used by passbook to connect
+# These values influence the bundled postgresql and redis charts, but are also used by authentik to connect
postgresql:
postgresqlPassword: EK-5jnKfjrGRm<77
diff --git a/helm/values.yaml b/helm/values.yaml
index 95bd92e25..825f13e51 100644
--- a/helm/values.yaml
+++ b/helm/values.yaml
@@ -1,16 +1,16 @@
###################################
-# Values directly affecting passbook
+# Values directly affecting authentik
###################################
image:
- name: beryju/passbook
- name_static: beryju/passbook-static
- name_outposts: beryju/passbook # Prefix used for Outpost deployments, Outpost type and version is appended
+ name: beryju/authentik
+ name_static: beryju/authentik-static
+ name_outposts: beryju/authentik # Prefix used for Outpost deployments, Outpost type and version is appended
tag: 0.12.11-stable
serverReplicas: 1
workerReplicas: 1
-# Enable the Kubernetes integration which lets passbook deploy outposts into kubernetes
+# Enable the Kubernetes integration which lets authentik deploy outposts into kubernetes
kubernetesIntegration: true
config:
@@ -38,11 +38,11 @@ ingress:
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- - passbook.k8s.local
+ - authentik.k8s.local
tls: []
# - secretName: chart-example-tls
# hosts:
- # - passbook.k8s.local
+ # - authentik.k8s.local
###################################
# Values controlling dependencies
@@ -52,9 +52,9 @@ install:
postgresql: true
redis: true
-# These values influence the bundled postgresql and redis charts, but are also used by passbook to connect
+# These values influence the bundled postgresql and redis charts, but are also used by authentik to connect
postgresql:
- postgresqlDatabase: passbook
+ postgresqlDatabase: authentik
redis:
cluster:
diff --git a/icons/authentik-working.ai b/icons/authentik-working.ai
new file mode 100644
index 000000000..5e803e4ca
--- /dev/null
+++ b/icons/authentik-working.ai
@@ -0,0 +1,1836 @@
+%PDF-1.6
%âãÏÓ
+1 0 obj
<>/OCGs[25 0 R]>>/Pages 3 0 R/Type/Catalog>>
endobj
2 0 obj
<>stream
+
+
+
+
+ application/pdf
+
+
+ authentik-working
+
+
+ 2020-12-05T18:45:06+01:00
+ 2020-12-05T18:45:06+01:00
+ 2020-12-05T18:45:05+02:00
+ Adobe Illustrator 25.0 (Windows)
+
+
+
+ 256
+ 212
+ JPEG
+ /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA
AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK
DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f
Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgA1AEAAwER
AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA
AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB
UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE
1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ
qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy
obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp
0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo
+DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FWA+f/AMzY
tDd9M0tVm1Sn72Rt44aivT9p6dug7+Ga3W6/w/TH6vuel7G7AOoAyZNsfTvl+ofjzePaprusatKZ
dRvJbpqkgSMSq1/lX7K/QM0WTNOZuRt7vT6PFhFY4iPu/X1QOVuS7FXYq7FXYq7FXYq4Eg1BoR0O
KGV+WfzJ8yaJKivO19Yige1uGLfCP99uasm3Tt7ZmYNfkxnnY7i6XX9hafUA0OCfeP0jr9/m9x0D
XtO13TI9QsH5wvsynZ0cfaRx2YV/j0zosOaOSPFF871mjyafIccxv947wmOWuK7FXYq7FXYq7FXY
q7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqlXmnWf0L5evtTAq9vGfSBFR6jkJHX25sK5TqMvhwMu5ze
ztL+Yzwx/wA47+7mfsfNU00s80k0zmSaVi8jsalmY1JJ9znJEkmy+tQgIgAbALMWTsVdirsVdirs
VdirsVdirsVZv+UnmCTTvMyWLvS01Mek6noJVBMTD3r8P05sOzc3Bk4eknnfaTRDLpzMfVj3+HX9
fwe7Z0b5w7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqwf845nj8mlV6S3MSP8hyb9
ajNd2of3Xxei9l4g6u+6J/U8KznX0d2KuxV2KuxV2KuxV2KuxV2KuxVNvKP/AClejf8AMdbf8nly
7Tf3sf6w+9we0v8AFsn9SX+5L6XzrXyR2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Kux
Vgf5z/8AKIJ/zFxf8RfNb2p/dfF6T2W/xo/1D+h4dnPPorsVdiqZ+V4YpvMukwzIskMl7bpJG4DK
ytKoKsDsQRlunAOSIP8AOH3uH2hIx0+Qg0RCX3F6h+bWg6HY+VkmstOtrWY3UamWGGONuJV6jkoB
ptm47SwwjjsADfueP9m9ZmyampzlIcJ5yJ7kf+W3l3y/eeStOuLvTLS4uH9bnNLBG7tSeQCrMpJo
BTLNDghLCCYgnfp5uN27rs8NXOMZzjEcOwkQPpCV+XPy0vbXzfcXupWVrLo7tOYoGCSKA7Ex/uyK
Cg+7KcGgIykyA4d3M13b0J6UQxykMvps7j37ov8ANPQNCsvKMs9nptrbTiaICWGGON6FtxyVQcs7
QwwjisRAPuaPZ7W5smqEZzlIUdjIl4tmgfQHYq7FU18pf8pXov8AzHW3/J5cu0397H+sPvcHtL/F
sn/C5f7kvpjOtfJHYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqknmrzdpXluxFxesXlkqLe2S
nORh4V6KO5zH1GpjiFl2HZ3ZuTVz4YchzPQPJtT/ADh83XUpNm8VhFX4UjjWRqf5TShq/QBmkydp
5SdtnttP7MaWA9dzPma+6lXRPzj8yWtwv6U4ahbE/vPgWKUDxUoFX71yWLtTID6vUGGr9l9POP7u
4S+Y+3dkv5ma5puteQIL/T5fVge7jBHRlYI9VcdmGZevyxyYBKPK3U9g6TJp9cYZBRED943DxzNE
947FXYqmvlL/AJSvRf8AmOtv+Ty5dpv72P8AWH3uD2l/i2T/AIXL/cl9I3un2F9D6N7bRXUIIYRT
Isi8h0PFgRXfOrnCMhRFvlGLNPGbhIxPkaXWtpaWdutvaQx29uleEMShEWpJNFUACpNcYxERQFBG
TLKcuKRMpHqdyq5Jgwr83/8AlC5v+M0P/Es1/af9yfeHoPZn/Gx/VLwfOcfSXYq7FU18pf8AKV6L
/wAx1t/yeXLtN/ex/rD73B7S/wAWyf8AC5f7kvpjOtfJGF335ueT7S7ltuc9wYmKmWGMNGSOvEsy
1+fTwzXz7SxRNbl6DD7NaqcRKoxvoTuoD85vKBIBW7APcxLt9z4P5UxebYfZbVf0Pn+xmlhf2eoW
cN7Zyia1nXnFKtaEfI0I9wcz4TEhY5F0GbDPFMwmKkOavkmp2KuxVSjvLSSeS3jnje4ioZYVZS61
6clBqMiJAmr3ZyxSERIg8J5FbaahYXgc2dzFciJjHKYXVwrjqrcSaH2xjOMuRtOTDPHXHExvvFK+
Sa3Yq7FXYqh49QsJRMY7mJxb1+sFXUiOla86H4eh65ETib35NssExVxPq5bc/cpfpvRhYLqBvrcW
Dmi3RlQRE1pTmTxrXbI+LDh4rFd7P8pl4/D4JcfdRv5IxHR0V0YMjAFWBqCDuCCMsBaCCDRfPHmD
U73zZ5vYQgSm4nFtYKS1Fi5cU6HYftHOXzZDmy7dTQfUdFp4aLS77cMeKXv6/qD2jyz5H0HQbRI4
bdJrun768kUM7N3pyrxX2Gb7BpIYxsN+94HX9rZtTIkkiPSI5ftQPnX8vdL120eW1hjttVQExTKO
KyH+STjSoPj1H4ZXqtFHILAqTkdldt5NNICRMsZ5ju8w8QDXVvLLpF2xsoXmVbxGDNweMleRWvVO
R6Zz249B233fQiIyAyx9Z4fT5g+fmzxfyP1FlDLqsDKwqrCNiCD3G+bL+SZfzg84fa3GP8nL5t/8
qN1P/q6Q/wDIt/64/wAkS/nBH+i7H/qcvmHf8qN1P/q6Q/8AIt/64/yRL+cF/wBF2P8A1OXzCM0b
8m9Q0/WLG/fUoXW0uIp2QIwLCNw5ANe9MsxdlyjMS4uRaNV7UY8uKUBAjiiRz7xT1TNy8Y7FXYqw
z83E5eSbk1+xLCf+HA/jmB2kP3J+Dv8A2aNayPuP3PBc5t9KdirsVTXyl/ylei/8x1t/yeXLtN/e
x/rD73B7S/xbJ/wuX+5L3fWfNF1ZX89va2IuoLCBbrUpWlWJkiflT01YfGaRsTuPDOjy6gxkQBfC
LL5xpez45ICUpcJnLhjtdnbn3cw8o8v/AJXeZL26tJL20MWmXCh2uBJFVUdKq3APz7jamaXD2fkk
RY9Je11vtDp8cZCEryR6UeYPfVNfmX5Q0zy3c2Mdh6hS5SRpGkYMeSsNhQCgFcdfpo4iOHqvYPae
TVxmZ16SOTOfKevwaB+VFpqcqep6ImEcQNC7tcyKq1+Z39s2OmzDHphI+f3vO9paI6ntOWMbXW/c
OAJbefmB+YthoyaveaTaJY3XE2svxHiHoV5oJeVGXpWm/wB2VS1ueMOMxFH8d7l4uxdBly+FDJPj
jzHu7jw9Pin+vec9U0+DyxJDFAx1poluuauQocRk+nRxT+8PWuZObVSiIVXr5/Y6zR9lY8pzgmX7
q65dL57eXki7jzTqEf5gW3l1Y4jZTWxnaQhvVDBXNAeXGnwD9nJy1EhnGPpTRDs+B0MtRZ4xKvLp
5efexfybL5jP5l6v9Zht1ZwP0nwLfAoT916VWO5bjyrXvmHpTk/MSuvP9FO47Ujp/wCT8fCZf0Pn
vf20lHlvXvNum2uvtodjDPbWl1LdXtxOSaKP2VUMlfhQk9cowZssBPgAIBsudr9HpcssXjSkJSgI
xA/Tse9m8v5hQw+RbfzNLb/vbn91HahqAzB2Qjlv8P7tm+WbE60DCMhHPp5vPR7EMtadODtHe/Kg
f0gMcg/NPXbOa2uNXXTZ9PuWUPHYzq88IYVqyrJIdh1qPaozEHaE4kGXDwnuO4+12s/Z7DkEo4vE
E4/z41E/7EJ55x86a/pXmLT9J0e0gvTfxBkWTlyLszKtGDqoUUBNR9OZOq1U4ZBGABt13ZfZWDNg
nlyylDgP2beXNvyr5v8AMd7rGp6Bq9pBDq9lCZ4jFX0z9niG+Jtj6ikEHpjp9TklOUJgcQC9o9ma
fHihnxSkcUzRvn18vIsV8jyavTzh9Zt7d7Vo7htRQlv7+klEWjA+mfjr398w9IZfvLAre/fu7nte
OL/B+EyErjw8vp235c+X6lt9LFL+TMDxwR26m63ji5laiVhWsjO1T88EyDpBtW/6U4YkdrEEmXp6
13DuAZLoXnLVtX1W10vy/BC+m2UMQ1LUJ1cgEAArFxZN9qCvXr0GZeHVSnIRgBwgbkup1nZeLBjl
kzk+JMnhiK+3Y/jzeY+TJU0nzvYfXf3Yt7kwzE9FY1i39gxzUaU8GYX0L1/akTm0c+DfijY+99F5
1L5W07pGjO7BEQFmZjQADckk4k0kAk0Hzt5z1OLzF5vuJtMhqs8iQW4QfFMy0RX+bnp7UzltVkGX
KTEc/tfUuy9OdLpQMh5Ak+XWvg990SxksNHsbGV/UktYI4XfxKIFJ/DOlxQ4YAHoHzTV5hkyymBQ
lIn5lG5Y47sVdirsVdirsVYd+bX/ACg95/xkh/5OrmD2l/cn4O99m/8AHI+6X3PA85p9MdirsVTX
yl/ylei/8x1t/wAnly7Tf3sf6w+9we0v8Wyf8Ll/uS+h9S8u6JqdxFcX9nHcTQiiM4P2QeXFgDRl
rvRqjOoyYITNyFvl2DXZsMTGEjEH8fD4PFdC8j69eeZG0bUZLmxCK5NxxdkPDoVJKghuxrnP4dJO
WTglYfQNZ2tgx6fxsYjPltsivO/5b3WiW9pLbXU+qSTyGP0xESV2r+yX65PV6E4wCCZW09k9ux1E
pCUY4wBfP+xm1l5Kvbz8r7fQLmltfhWmQP0SQzNKqvSvZqHwzYQ0plpxA7S/bbz+XtWGPtE54+qH
L4cIG36Ei1PRfzU1by/HoV1Y26W1rwHreqnqTiLZAT6jD/KNQPvzHyYtTOHAQKH2ux0+q7Nw5zmj
KRlK9qNRvn0+HVkHmryjrF95c0Q2AU6voohdIGK8XZVUMvIkLsyA7mmZOo005Y48P1Qp1nZ3aWLH
qMvH/dZb37tz+tT8t6D5qvvN58zeYbeKxeCD0La2iZXrUEV+Fn2HI9TgwYcksviZBVBlrtZpsel/
L4CZ3KyT/YEx0LQNTtPO+u6rPGFsr5IxbSBlJYqFBqoNR075bhwyjmlI8i4ur1mOejxYgfXC7Qnl
jyxrFhoHmKzuYlSfUJbl7VQ6kMJY+K1IO2/jkNPp5xhMHnK6b+0O0MWTPhnE+mAje3cUJ/gHUbz8
tLTQbnjb6naSPPGpYMnP1ZCqllrsySfRkPycpacQO0h+1u/lnHj7Qlnj6scgB8Kj+kJfpPlfzNPf
WsF95W0i0tYyBeXTojl1HUqscjGpHTalfuyrHp8hIBxwA6/i3K1PaGnjCRhnzSkfpFnb5j8BkWs+
XNUuPP2iatbxKdOsYTHO/JQVP7ygCk1P2h0zKy4JHPGQ+kB1el12OOiy4pH1zNj7Hab5d1WD8ytW
1ySIDTbq1WGGXkpJcLACONeQ/u2xx4JDUSn/AAkfqRn12KXZ+PCD+8jOz/sv1hCeXfKutWVv5sS4
hVW1VpTZUdTyDiUCtD8P2x1yGDTziMl/xcvtb9d2jhyS05if7uuLb+r+pL5fJXmFvyyi0IQL+kku
DI0XqJTj6jN9qvHocqOkyfl+CvVblR7VwDtE5r/d8NXR7kVpXlTzF5X1eG50SMXWk3iJ+ktOaRVM
cgADPGXIB36fd4HJ49NkwyuG8TzDRqO0cGsxGOY8OSJ9Mq5juNfjqx781/I1zDeya/p8RktZ/ivo
0FTHJ3kp/K3fwPzzF7R0hB448jzdr7OdrxlAYMhqQ+nzHd7whPLv5xatptmlpqFsNRSIBY5i5jlC
jYBm4uGp8q5DB2nKAqQ4m7XezGLLMyxy4L6VY/RSA81/mdrnmCE2UUYsbGTZ4ImLvJ7PJRaj2AHv
XK9Tr55RQ2Dk9ndgYdMeMnjmOp5D3BG6ToGp+UNAPm66t4zqQdI7G0uVYiNJKgyOqsh5kdBXb59J
48MsEPFI9XQFx9TrMeuz/lYk+HR4jHrXQc9vvXf8rt81/wDLJY/8i5v+quH+VsvdH7f1o/0J6b+d
k+cf+Jd/yu3zX/yyWP8AyLm/6q4/ytl7o/b+tf8AQnpv52T5x/4l3/K7fNf/ACyWP/Iub/qrj/K2
Xuj9v61/0J6b+dk+cf8AiXf8rt81/wDLJY/8i5v+quP8rZe6P2/rX/Qnpv52T5x/4l3/ACu3zX/y
yWP/ACLm/wCquP8AK2Xuj9v61/0J6b+dk+cf+Jd/yu3zX/yyWP8AyLm/6q4/ytl7o/b+tf8AQnpv
52T5x/4l3/K7fNf/ACyWP/Iub/qrj/K2Xuj9v61/0J6b+dk+cf8AiUt8xfmdr+vaVJpl5b2scEpV
maFJA9UYMKFpGHbwynPr55I8JAr8ebl6HsDBpsoyQMzId5H6gxDMJ3rsVdiqa+Uv+Ur0X/mOtv8A
k8uXab+9j/WH3uD2l/i2T/hcv9yX0xnWvkjsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVcQCK
HcHqMVYvqf5Z+TdRmM0liIJWNWa3Zogf9gp4fhmHk0GKRuq9zuNP29q8QoTsee/281bRPy/8qaNM
Li0sg1ypqk8xMrL/AKvL4VPuBXJYtFixmwN2Gr7a1OccMpenuGyV/nB/yhkn/GeL9ZyntP8AuviH
M9mP8bH9UvCM5x9IdirsVdirsVdirsVdirsVdirsVTXyl/ylei/8x1t/yeXLtN/ex/rD73B7S/xb
J/wuX+5L6YzrXyR2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVhX5vqx8lzECoWeEn
2HKn8c1/af8Acn3h6D2ZP+Fj+qXg+c4+kuxV2Ksj0b8v/NGs6el/YWySWshYI5lRTVTxOzEHqMys
WiyZI8URs6nVdtabBMwnKpDyKN/5VL54/wCWOP8A5HRf81ZZ/Jubu+1x/wDRJo/5x/0pd/yqXzx/
yxx/8jov+asf5Nzd32r/AKJNH/OP+lLv+VS+eP8Aljj/AOR0X/NWP8m5u77V/wBEmj/nH/Sl3/Kp
fPH/ACxx/wDI6L/mrH+Tc3d9q/6JNH/OP+lLHtb0PUdEvjY6jGIrlVVyoZXFG6bqSMxcuGWOXDLm
7TSavHqIceM3FAZW5TsVTbyl/wApXo3/ADHW3/J5cu0397H+sPvcHtL/ABbJ/Ul/uS+l8618kdir
sVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVSbzlo7ax5Z1DT4xWaWPlCPGSMh0H0stMx9
Vi8TGYuf2Xqhg1EJnkDv7jsXzYysrFWBVlNGU7EEdjnKPrINtYpdir3z8pf+UHs/+Mk3/J1s6Xs3
+5HxfM/aT/HJe6P3MxzOdE7FXYq7FXhH5wf8pnJ/xgi/Uc5ztP8AvfgH0j2Y/wAUH9YsJzXvQuxV
mP5U6JJqPm2Cfj/o+ng3ErduQ2jHzLb/AEHM7s7FxZQekd3Q+0WrGLSmP8U9h+n7HvmdK+aOxV2K
uxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV5f+Y35Yz3lxLrOhoGnk+O7shsXbvJH2qe
69+2+afXaAyPHD4h7DsPt+MIjFmOw+mX6D+t5LPBPBK8M8bRTIaPG4Ksp8CDuM0pBBovbQmJC4mw
VmBm98/KX/lB7P8A4yTf8nWzpezf7kfF8z9pP8cl7o/czHM50TsVdirsVeEfnB/ymcn/ABgi/Uc5
ztP+9+AfSPZj/FB/WLCc170Kb+XvKut6/ciHT7cslaSXLVWJB4s/8Bvl2DTzyGohwdb2jh00byHf
u6n4Pe/KXlWx8t6Utlbn1JXPO5uCKNI9OvsB2GdLptOMUaD5p2l2jPV5OOWw6DuH45p3mQ692Kux
V2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVB3+j6TqIAv7KC6pspmjVyPkWBplc8U
ZfUAW/DqsuL6JSj7iQgP8EeUP+rPaf8AIpf6ZX+UxfzQ5P8AK2q/1SfzTSxsLKwtltrKBLe3QkrF
GAqgk1Ow98uhARFAUHEzZp5JcUyZS7yr5JqdirsVdiqW3/lny/qFwbm+06C5nICmWWNWag6Cpyme
CEjcgCXLw6/PijwwnKMfIqEfkzylG4ddHtOS7isKH8CMiNLiH8I+TYe1dURXiT/0xTeKKKKNY4kW
ONdlRQAAPYDLwAOTgykZGzuV2FDsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdi
rsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdir
sVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirs
VdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsV
dirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVd
irsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdi
rsVdirsVdirsVdirsVdirsVeM+f/AMzdVm1OfTdGuDaWVs5ie4iNJJXU0Yhx9lQR8PHr19hodbr5
GRjA0A992N2BijjGTMOKct6PIfDv77Yb/i3zX/1er7/pJm/5qzA/M5f50vmXe/ybpv8AU8f+lj+p
3+LfNf8A1er7/pJm/wCasfzOX+dL5lf5N03+p4/9LH9Tv8W+a/8Aq9X3/STN/wA1Y/mcv86XzK/y
bpv9Tx/6WP6m/wDFvmv/AKvN9/0kzf8ANWP5nL/Ol8yv8m6b/U4f6WP6no35ZfmPqF/fpomsyevJ
KD9TuzQOWRamN6bNVRs3WvjXNroNdKUuCfwLyvb/AGHDHDxsQoD6h+kfq+56jm4eOdirsVdirsVd
irsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdir5TckuxPWpzjC+0Dk1illKfl1
rba5a6MJ7b61d2gvY3LSemIySKMeFeXw+H05mDQz4xCxZFumPbmEYZZqlwxnw9Lv58lg/L3zANGv
9WlEcEGnu6SxTeokj+mAS0YKUKnlsajB+SnwGR2EWX8tYPFhiFkzA5UQL6HfmxnMR26b+TiR5s0Y
g0/023H3yqMv0v8Aex/rBwO1P8Vyf1Jfc+ls6x8ldirsVdirsVdirsVdirsVdirsVdirsVdirsVd
irsVdirsVdirsVdirsVdirBPOXmTX59eg8q+WnWK+kUSXd0w3jUitASCFAXcnruKb5rdVnmZjHj5
9XpOy9BgjgOp1AuA+kd/4P7UrvX8++SpYdSvNROtaOxC3iPyJSpptXkV9mrSvUdMqmc2nPETxx6u
ZiGi14OOEPCy/wAPn+v3fJ57a+SfNV7bpd2mnSzW0w5RSoVIYH6c1cdJkkLA2enydrabHIxlMCQV
v+VeedP+rTN/wv8AXJfks380sP5b0n+qRe/2FhAkNrLLAgvIoEi9UqpkUBd159aVr3zpYQFA1vT5
nmzSJkATwGRNdPklvn1S3k3VwNz9Wc/dvlWs/upe5yuxzWrx/wBYPnDOVfV048nKW82aMBufrtuf
oEinL9L/AHsf6wcDtQ/4Lk/qS+59K51j5K7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7F
XYq7FXYq7FXYq7FXmnmtrryr57TzWbZrjS72JYLxk3KMFCd9hsikV67jNTqbw5vFq4nm9b2cI6zR
HS8XDkgbj59f0m+5R8z+fh5oth5d8sW0s9xf/DcSyLxCR1BIG5/2THYD8I6jWeMPDxizJs7P7G/J
y8fUyEYw5Ad/45BV8vfmT5N0DRrbR1+tyfVFKPJ6S/E5Ys5Hx9C7GmHDrsWOAhvsw1vYWr1OWWX0
Di8+nTp3Jj/yufyh/Jd/8il/5ry3+VMXm4v+hbVd8Pn+x3/K5/KH8l3/AMil/wCa8f5Uxea/6FtV
3w+f7Et8yfmv5Y1HQNQsLdLkT3MDxRl41C8mWgqQ5yrP2jjlAxF2Q5eh9nNTizwnLhqMgef7Hj+a
N7pO/JP/ACl+j/8AMXF/xIZkaT+9j73Xdrf4rk/qF9JZ1b5O7FXYq7FXYqpXV1bWlvJc3MqwwRDl
JK5CqoHck4JSERZ5M8eOU5CMRcixqH80fI8ryKNRCemGbk8cqghevGq7nwHU5hjtDCert5ez+sAB
4OfmP1pvbeZdFudFfWobnnpkau73HCQUEZIY8Cofanhl8c8DDjB9LgZNBmhmGEj94a2sdeW/J1t5
l0W50V9ahueemRq7vccJBQRkhjwKh9qeGMc8DDjB9K5NBmhmGEj94a2sdeW/JLNc83aS3ku91ixv
CIZYZobO5VJFb6wVZEABUMp5jqRTKs2pj4JmD0Ne9zNJ2blGrjinHcEGQsfTsT1rkx7yP5qk1DyV
qcM1/LPrNrBczOzF/UROJ9NhIRTr0odsxdJqOLDIE3MAu07W7OGLVwIgBilKI6V57IbyH+Ymlaf5
c/52DVJJr17iTiJDJcS+mFWlT8RArWlcho9bGOP1y3v3tvbHYmXLqP3EAIcI5VEXv7no2mapYapZ
R3thMs9rKKpIte2xBBoQR4HNrjyRmLibDyuo088MzCYqQSLUfzJ8m2F29pPfhpozxk9JHkVSOoLI
CKj2zGnrsUTRLscHYWryx4ow2PeQPvT7TtTsNSs0vLCdbi2k+xIh226g9wR4HMmGSMxcTYddnwTx
SMJjhkGPy/md5IjjdzqQJjbgyCKbnX2UoNtuvTMY6/CP4vvdnHsDWEgcHPzj+tNbDzPoV/pE2r2t
0JLC3V3uJAGrGI15PySnKoXelPll0NRCUeIHYOFm7PzY8oxSjU5VXnfLfkwb8sfOTXuuanY6hqMt
xJdyA6VFL6jAonqvJxqKJ8AXrTNdoNVxTkJG75fa9H2/2X4eGE8cBERHrIrrwge/e00/LiW6k1HX
fV106uqSqPSIn/dEs+59ZECk8acY6jb5ZdoSTKVy4vn+n9Dh9uRiMeKsXhWOfp35fzSfnLf7UD+V
muXktvr1xqt/LNBaSI3qXMrOI0ActTmTQbZX2fmJEzI7Dvcj2h0kYyxRxQAMh/CKs7dzM9D8zaJr
qSvpU5uI4SFkf0pY1BO9KyKgJ+WZ+HUQyfSbdBq9Bm0xAyjhJ8wfuJTTLnDdirsVadEdCjqGRhRl
YVBHuDiQkEg2FC007T7Pl9Utorfn9v0kVK08eIGRjCMeQpsyZ5z+qRl7zb5fu/8Aeub/AIyN+s5x
8uZfYMX0j3Kmm6bfanex2NjEZ7qbl6cQIBPFSx3YgfZU4ceMzNR5sc+eGGBnM1EJ7/yrXzx/1apP
+Di/5rzJ/IZv5rrv5e0f+qD5H9Tv+Va+eP8Aq1Sf8HF/zXj+QzfzV/l7R/6oPkf1Mdurae1uZrW4
T054HaKVDQlXQ8WG3gRmLKJBo8w7THkjOIlHeJFhN/JAJ836PQV/0uI/cwy/Sf3sfe4Pa3+K5P6h
fSWdW+TuxV2KuxV2KvP/AM6pblPK9ukZIhku0E9OhAR2UH25CuaztUnwx73p/ZSMTqSTzEDXzChr
fl3yLH+Xct1bRW4C24e2vV4mZrjiOKl/tVZtmX8MjlwYRgsVy5+bZpNbrTrxGRl9W8enD7vLoVPy
2CfyZvABU/V7w7eAZycGD/FD7iy15/12j/Wh+hQ8vXlon5NXytMgZI7mJlqKh5GPBSPFuQpkcMh+
UPxbNbike1o7dYn4DmifLVpDL+Tdz9YhWRRbX00QdQ1GT1OLivQqw2OSwRB0hvul+lq1+Qx7Wjwm
vVAH/Y7LPy/tLRfy11K6WGNbmSG7SScKBIyqhopalSBjooj8vI1vuy7ayyPaEI2eEGG3Tml/5d+V
9DvfJOqX17apcXLmaNJJFBMaxxArwP7J5GtRlWi08JYZSIs7uT232hmx6zHCEjGPpPvs9e9Efl7P
dxflfr0tszCeJ7owkdVItozVfl1yWiJGmnXPf7g19twie0cQlyIjf+mLH/JGja/eaTNLpmnaVexF
2SeS9UPKpoDx+I/CvcUzG0mKcokxET73Z9rarBDKBknlga24dgzT8qvL2taOL5rl4H0674vb/V5h
MgdSQeNCexp17Zn9nYJwu64T3F0HtFrcOfh4RLjjzsUaSL8qPLujanca1PqNrHdtE6xxLKodVDly
xAPfYb5jdnYITMjIW7H2j12XDHFHHIxsXt5UjfyetofV8zae6iW0WSKMxSAMrKTMjBgevJVocs7M
iLnHp/a0e08zWCY2lRN/6UrfyhsbJta8wytbxGW1mjFrIUXlEGadWEZpVarsaYOzIDjma5H9afab
NPwcIs1KJvfn9PPvVvyg/wCOr5o/4zxf8TnyXZn1T94/S1+0391g/qn7oPOEfUxZXqD1V0R7yP8A
SLwgE1q3DluO3KgO1fozVAyo/wAy93qyMfHE7eNwHhv4X+OdPfvK0WiR6DaLohVtN4VhderH9ov0
+Ov2q986bTiAgOD6XzPtCWY5peN/eXv+zy7k1y5wnYq7FXYq7FXi35hfltqttqk+paTbvd2N0xle
KIFpInY1YcBuVruCOnT56DW6GQkZRFgvoHYvbuKeMY8p4Zx2s8iPf3pf+WOm6jB55015rWaJF9fk
zxsoH+jyDckeOVaCEhmjYPX7i5Pb+fHLRzAkD9PX+kHvOdI+bOxV85eaNJ1WbzVq5isp5A99clCk
TtyBmalKDfOV1GORySoH6j976r2fqcUdNjuURUI9R3BnP5Yfl3qNnfpresQm3aIH6nav9vkwoXdf
2aA7A75sez9FKMuOe3c877Qdt48kPBxHiv6j09weqZuXjHYq7FXYq7FULqml2Oq2EthfRCa2mFHQ
/eCD2IPQ5DJjjOPDLk3afUTwzE4GpBhEP5K+WVeT1Lq7kiYERx80XiSKVqE3IzXjsrH3l6GXtXqC
BUYA/H9bLdC8uafo2jLpFuXmtF519cqzMJCSwbiqim/hmbhwRxw4RydJrNdPPl8WVCW3LyYlN+Sv
lp7sypc3UduTX6urIaewYqTT51OYR7Kx3dmndx9q9QI0YxMu/f8AWzJtD0/9ByaJEhgsXt3tQsez
LG6lCQSG+LetT3zO8KPBwDYVToRq5+MMx3mJcW/eDaE0jynp2laBNodvJM9pMJVZ5GUyUmFGoQqr
8tshi00YQ4BdN+p7SyZs4zSA4hXLlt8XaD5T07RNFm0i1kme2nLs7yspcGRQpoVVR0Hhjh00ccDE
XS6ztLJqMwyyA4hXLlt8W/K/lXTvLmnS2Fk8ssEsrTOZyrNyZVQj4VQUog7YdPp44o8IR2h2jk1W
QTmACBW3xPee9jmo/k75cubp5rW4uLFJTWS3iKmOh6hQwqB9OYk+zMZNgkO1we0+eEQJCM66nmyn
y95b0ry/YCy06MrHXlJI55SO3Tk52/pmZgwRxRqLp9brsmpnx5Dv9g9yG8seT9M8ufW/qMs0n1x1
eX12RqFa048VT+bI6fTRxXV7tvaHaeTVcPGIjh7r/WXeW/J+meX7i/ns5ZpH1F1ecTMjAFS5HDiq
f78PWuODTRxEkX6l13aeTUxhGYiODlV+XPc9zvLfk/TPL9xfz2cs0j6i6vOJmRgCpcjhxVP9+HrX
HBpo4iSL9S67tPJqYwjMRHByq/Lnue53lvyfpnl+4v57OWaR9RdXnEzIwBUuRw4qn+/D1rjg00cR
JF+pdd2nk1MYRmIjg5Vflz3Pco6J5D0PSbXULVPVurfUyDdRXJRhtXYcVSn2sji0cIAjmJd7Zq+2
M2eUJGoyx8uG/wBZVPLHk6x8t+ulhdXT2055G1ndHjVv5koisDTbrv3w6fSjFfCTXcw7Q7UnqqM4
x4h1AN/HdrzH558u+XpEhv5yblxyFvEvNwp/aPQAfM459XjxbSO6dD2Rn1QuA9PedghrT8yvJ93P
aQQXhae9dYoovTfkHZgoVtvh3PyyEdfikQAdy25ewdVASlKO0BZ3HLyTPV/M+j6ReWdnfStHPftw
tlCMwY8gu5ANN2HXLsuohAgS5lxNN2flzwlOAuMOe7tb8z6Pos1pDqErRyXzMlsFRn5FSoNeINPt
jrjl1EMZAl1XSdn5dQJHGLEOe/v/AFMG/ObzA9oLCws7uW3vd55BEzpWJqotWWgPxIds13ambhqI
NF6L2W0QnxznEShy3o78/wBKr+YGvw6h+XtrqOmXEgje5jT1RyjYlVdWHY9Rh1uYSwCUT1Ydi6I4
tdLHkAvhO3PuR2m/ml5StLPT7K4upHmW3hSedUZkV/THLk3U79aA5bDtDFEAE9A4+f2e1U5znGIr
iNC96tmxurUWpuzMgtQnqmcsOHp05c+XTjTeuZ/EKvo88McuLho8V1XW+5iL/m75LW69D15WStDc
CJvT/wCa/wDhcwj2lhurd4PZrVmPFQ917/q+1lcWo2U1gNQhmWWzMZlWZPiBQCpIp8szBMGPEOTp
ZYJxnwEVK6pi035teSo4BKt1JKSxURJE/PYA1IbiKb+OYZ7SwgXbuY+zerMq4QPiKTu081aHd6HJ
rdvcc9PhVmmcK3JOAqyslOVRmRHUQlDjB9Lr8vZ2aGYYZD1nl535vPvyl80iXV9QsL68mnuL5kOn
pKzyCkQleShNQvw0zWdm6i5mMibPL7Xp/aTs6sUJwiBGF8VUOfCB705/LqGKPX9fCaxJqLrLSSF0
kXi3NhyYvsW24/Dl+hFTn6uJwO3JE4MV4xDbnt3Du+e6XflbqEp1nzK93csYIGDcpXPFFEklT8Ro
BQZX2fM8c7Ow/a5PtDhHhYRGO57hz2DNdB836Hr000WlyvP6ArK/puqCpoPiYAVPbM/DqYZCRHo6
DWdmZtMAcgAvluLTrMh17sVdirsVdirsVdirsVdirsVdirsVdirsVdirsVeS+WrTTNQ/NPXF1pFn
uEkm+pQzgMrcZOK/C2zFYqcR4b9s0mCMZamXHz3r8e57fX5MmLs3F4JqNR4iPd/xXNT84WGiWf5m
aAmmpHDI1xatdwwgKqv644kquysV6j6cGphCOohw94v5suzM2bJ2flOQkjhnwk/1Uw/NiRIfMnle
eU8IY5izuegCyxFj9Ay3tE1kgT3/AKQ4vs5Ey0+cDmR+iTX5uTRSa35YgjYPMJncxrueMkkIQ0H8
xU0x7SIM4D8dF9mokYc5PKh9glbvzxhh+oabP6a+sZWQy0HIqFqF5daVPTHtYDhiU+yUzxzF7Uiv
zUtre38g2sVvEkMYuISEjUKtSjkmgp1yXaMQMAA7w0+zs5S1sjI2eGXP3hL/ADT5Z0Wy/Ky0uIbW
NbtI7aY3QUCRnm48yW6kHl0J8PDK9Rp4R0wIG+27ldna/Nk7SlEyPDchXShdKvmma6T8ntLEJbg8
dqk5H+++NaH25BcOoJ/Kxrya+z4xPas76Gde/wDstKNJ0TzPceU0Fvp+jPpU0RZrqUAS9CGd5CwK
uu+/bKMeLIcWwhw97nanV6aOpPFPN4gPIcvcB3H7WWeSND1nRvJ+pWmotG0bLLLaGKQSLweLehHa
ormdpMU8eKQl8HS9ravFn1UJY7vYGxXVJfyk8s6LqXlzUbi9tY7iaad7bnKocoixI3wV3U1kO49s
x+zcEJ4ySL3pz/aXX5sWohGEjECPFt1Nnn8lf8k445tE1WGZRJC06honAZSClDVTtvkuyhcJA97X
7VyMc2MjY8P6UP8Akta2zX+uytEjSwPCIJCoLJyMwbgeq1Gxpkeyojime6v0tvtVkkIYgCaIlfn9
PNFflb/ylfmr/jP/AMzpcl2f/e5Pf+ktPtD/AItg/q/72Lzq4bWB+nRacxYNcAaiY/5fUf0+Xfjy
r7Vpmrlx+uvpvd6qAxfuuKuPh9PyF/F7j5ETy+nlq1/QR5WZFXZv7wy7c/V/y/8AMbUzodGIeGOD
k+ddsHOdRLxvq+yuleX4O7IMynWOxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV5P+av8Agv8A
S0fq/WP07RfW+p8OlBw9Xl+3SnGm9OvbNL2j4PF14/L9L2vs7+b8I1w+D04r+NeXf/akeg/4D/TW
h/V/0l+kvrcXP1PSp63rLx9Sv7Nf5d/pzHw+Dxxri4r/AEux1n53wcvF4fh8B5Xy4en7XoP5p/4X
/Qcf6c9Xn6h+o/V6etzp8XHl8PGn2q/rpmz7Q8Pg9fwrm8x7PfmfGPg1y9V8q/X3fqtgf5d/4G/x
Jbev9a+u81+ofWOHo+r+zXhvyr9mu1fozW6LwfEF3fS3pO2/zn5eVcHB/Fw3ddefTvZl+bn6D/R2
n/pb6z6frP6X1T0+XLjvy9Ttmf2lwcI4r59HQ+zXjeJPwuG6/iv9CI/Mr9D/AOELf9JfWPqnrQ8f
q3D1OXBuNefw0p1yWv4PCHFdWOTV2D4v5o+Hw8VH6rrmO53m39D/APKuIfrf1j9HejacfS4evx+D
hXl8FelcdTwflxd8ND3r2b4v588PD4lz53XW/NMbD/D/APgG2+u/8cT6knqfWacvS4inLj+10px7
9MthweAL+jh6uLm8f87Lg/vuM/T3/Hp7+nN41P8A4N9eX6v+lf0T6g5U9L+O1fCu+aE+Fe3Hw/B7
2H5vhHF4Xi15vadF/wAO/wCD/wDcN/xyfQl48Pt9Dzry351rWub/ABeH4Xo+mnz/AFXj/mv3397x
D3eXwSr8pv0P/hy5/RX1j6v9cfn9a4c+fpR1p6e3GlMp7N4PDPDdX1+Dm+0ni+PHxeHi4B9N1Vy7
0P8AlH+g/wBHah+ifrPp+snq/W/T5cuO3H0+2R7N4OE8N8+rb7S+N4kPF4br+G/0qX5UfoH6xrn6
K+tcucP1j616dK1l48PT+mtcj2dwXLhvpz+LP2j8bhxeLw8pVw3/AEedqv5efoP/ABH5j/R/1n6x
63+lfWPT4cvVk/u+G9K165LRcHiT4bu97+LDtvxvAw+Jw8NbVd8hztCeQP8AC317zJ6Pr+lQ/pH6
76XpcOUnKnH9nrXl2yGj8PinV+d/Fu7Z/M8GG+G/4eG7vb8bLPy3/Qv+IL7/AA7+kP0bVvX9b0/q
vU+nxr+85fy96dcGh4OM+HxcP2M+3fG8CP5jw/E6VfF5+Xv6Xyel5tnkXYq7FXYq7FXYq7FXYq7F
XYq7FXYq7FXYq7FXYq//2Q==
+
+
+
+ uuid:775a4d7f-a10b-459d-af7e-266e382541d6
+ xmp.did:03fd4a37-9d8f-574a-82f0-4661c79cca29
+ uuid:5D20892493BFDB11914A8590D31508C8
+ proof:pdf
+
+ uuid:4b615770-c627-4ab3-9353-d0e15b2b064c
+ xmp.did:ea5770b6-6a3a-1648-9dc9-559c817e39ce
+ uuid:5D20892493BFDB11914A8590D31508C8
+ proof:pdf
+
+
+
+
+ saved
+ xmp.iid:081fa249-16ae-9446-959f-dd9f3aed7a7f
+ 2020-12-04T23:22:51+05:30
+ Adobe Illustrator CC 23.0 (Windows)
+ /
+
+
+ saved
+ xmp.iid:03fd4a37-9d8f-574a-82f0-4661c79cca29
+ 2020-12-05T17:08:41+01:00
+ Adobe Illustrator 25.0 (Windows)
+ /
+
+
+
+ Print
+ AIRobin
+ Document
+ False
+ False
+ 1
+
+ 994.714113
+ 151.653500
+ Pixels
+
+
+
+ Magenta
+ Yellow
+
+
+
+
+
+ Default Swatch Group
+ 0
+
+
+
+ White
+ RGB
+ PROCESS
+ 255
+ 255
+ 255
+
+
+ Black
+ RGB
+ PROCESS
+ 35
+ 31
+ 32
+
+
+ CMYK Red
+ RGB
+ PROCESS
+ 237
+ 28
+ 36
+
+
+ CMYK Yellow
+ RGB
+ PROCESS
+ 255
+ 242
+ 0
+
+
+ CMYK Green
+ RGB
+ PROCESS
+ 0
+ 166
+ 81
+
+
+ CMYK Cyan
+ RGB
+ PROCESS
+ 0
+ 174
+ 239
+
+
+ CMYK Blue
+ RGB
+ PROCESS
+ 46
+ 49
+ 146
+
+
+ CMYK Magenta
+ RGB
+ PROCESS
+ 236
+ 0
+ 140
+
+
+ C=15 M=100 Y=90 K=10
+ RGB
+ PROCESS
+ 190
+ 30
+ 45
+
+
+ C=0 M=90 Y=85 K=0
+ RGB
+ PROCESS
+ 239
+ 65
+ 54
+
+
+ C=0 M=80 Y=95 K=0
+ RGB
+ PROCESS
+ 241
+ 90
+ 41
+
+
+ C=0 M=50 Y=100 K=0
+ RGB
+ PROCESS
+ 247
+ 148
+ 29
+
+
+ C=0 M=35 Y=85 K=0
+ RGB
+ PROCESS
+ 251
+ 176
+ 64
+
+
+ C=5 M=0 Y=90 K=0
+ RGB
+ PROCESS
+ 249
+ 237
+ 50
+
+
+ C=20 M=0 Y=100 K=0
+ RGB
+ PROCESS
+ 215
+ 223
+ 35
+
+
+ C=50 M=0 Y=100 K=0
+ RGB
+ PROCESS
+ 141
+ 198
+ 63
+
+
+ C=75 M=0 Y=100 K=0
+ RGB
+ PROCESS
+ 57
+ 181
+ 74
+
+
+ C=85 M=10 Y=100 K=10
+ RGB
+ PROCESS
+ 0
+ 148
+ 68
+
+
+ C=90 M=30 Y=95 K=30
+ RGB
+ PROCESS
+ 0
+ 104
+ 56
+
+
+ C=75 M=0 Y=75 K=0
+ RGB
+ PROCESS
+ 43
+ 182
+ 115
+
+
+ C=80 M=10 Y=45 K=0
+ RGB
+ PROCESS
+ 0
+ 167
+ 157
+
+
+ C=70 M=15 Y=0 K=0
+ RGB
+ PROCESS
+ 39
+ 170
+ 225
+
+
+ C=85 M=50 Y=0 K=0
+ RGB
+ PROCESS
+ 27
+ 117
+ 188
+
+
+ C=100 M=95 Y=5 K=0
+ RGB
+ PROCESS
+ 43
+ 57
+ 144
+
+
+ C=100 M=100 Y=25 K=25
+ RGB
+ PROCESS
+ 38
+ 34
+ 98
+
+
+ C=75 M=100 Y=0 K=0
+ RGB
+ PROCESS
+ 102
+ 45
+ 145
+
+
+ C=50 M=100 Y=0 K=0
+ RGB
+ PROCESS
+ 146
+ 39
+ 143
+
+
+ C=35 M=100 Y=35 K=10
+ RGB
+ PROCESS
+ 158
+ 31
+ 99
+
+
+ C=10 M=100 Y=50 K=0
+ RGB
+ PROCESS
+ 218
+ 28
+ 92
+
+
+ C=0 M=95 Y=20 K=0
+ RGB
+ PROCESS
+ 238
+ 42
+ 123
+
+
+ C=25 M=25 Y=40 K=0
+ RGB
+ PROCESS
+ 194
+ 181
+ 155
+
+
+ C=40 M=45 Y=50 K=5
+ RGB
+ PROCESS
+ 155
+ 133
+ 121
+
+
+ C=50 M=50 Y=60 K=25
+ RGB
+ PROCESS
+ 114
+ 102
+ 88
+
+
+ C=55 M=60 Y=65 K=40
+ RGB
+ PROCESS
+ 89
+ 74
+ 66
+
+
+ C=25 M=40 Y=65 K=0
+ RGB
+ PROCESS
+ 196
+ 154
+ 108
+
+
+ C=30 M=50 Y=75 K=10
+ RGB
+ PROCESS
+ 169
+ 124
+ 80
+
+
+ C=35 M=60 Y=80 K=25
+ RGB
+ PROCESS
+ 139
+ 94
+ 60
+
+
+ C=40 M=65 Y=90 K=35
+ RGB
+ PROCESS
+ 117
+ 76
+ 41
+
+
+ C=40 M=70 Y=100 K=50
+ RGB
+ PROCESS
+ 96
+ 57
+ 19
+
+
+ C=50 M=70 Y=80 K=70
+ RGB
+ PROCESS
+ 60
+ 36
+ 21
+
+
+ R=253 G=75 B=45
+ PROCESS
+ 100.000000
+ RGB
+ 253
+ 75
+ 45
+
+
+
+
+
+ Grays
+ 1
+
+
+
+ C=0 M=0 Y=0 K=100
+ RGB
+ PROCESS
+ 35
+ 31
+ 32
+
+
+ C=0 M=0 Y=0 K=90
+ RGB
+ PROCESS
+ 65
+ 64
+ 66
+
+
+ C=0 M=0 Y=0 K=80
+ RGB
+ PROCESS
+ 88
+ 89
+ 91
+
+
+ C=0 M=0 Y=0 K=70
+ RGB
+ PROCESS
+ 109
+ 110
+ 113
+
+
+ C=0 M=0 Y=0 K=60
+ RGB
+ PROCESS
+ 128
+ 130
+ 133
+
+
+ C=0 M=0 Y=0 K=50
+ RGB
+ PROCESS
+ 147
+ 149
+ 152
+
+
+ C=0 M=0 Y=0 K=40
+ RGB
+ PROCESS
+ 167
+ 169
+ 172
+
+
+ C=0 M=0 Y=0 K=30
+ RGB
+ PROCESS
+ 188
+ 190
+ 192
+
+
+ C=0 M=0 Y=0 K=20
+ RGB
+ PROCESS
+ 209
+ 211
+ 212
+
+
+ C=0 M=0 Y=0 K=10
+ RGB
+ PROCESS
+ 230
+ 231
+ 232
+
+
+ C=0 M=0 Y=0 K=5
+ RGB
+ PROCESS
+ 241
+ 242
+ 242
+
+
+
+
+
+ Brights
+ 1
+
+
+
+ C=0 M=100 Y=100 K=0
+ RGB
+ PROCESS
+ 237
+ 28
+ 36
+
+
+ C=0 M=75 Y=100 K=0
+ RGB
+ PROCESS
+ 242
+ 101
+ 34
+
+
+ C=0 M=10 Y=95 K=0
+ RGB
+ PROCESS
+ 255
+ 222
+ 23
+
+
+ C=85 M=10 Y=100 K=0
+ RGB
+ PROCESS
+ 0
+ 161
+ 75
+
+
+ C=100 M=90 Y=0 K=0
+ RGB
+ PROCESS
+ 33
+ 64
+ 154
+
+
+ C=60 M=90 Y=0 K=0
+ RGB
+ PROCESS
+ 127
+ 63
+ 152
+
+
+
+
+
+
+ Adobe PDF library 15.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+endstream
endobj
3 0 obj
<>
endobj
5 0 obj
<>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 1000.0 1000.0]/Type/Page>>
endobj
23 0 obj
<>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 994.714 151.653]/Type/Page>>
endobj
24 0 obj
<>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 1000.0 526.49]/Type/Page>>
endobj
27 0 obj
<>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 1000.0 144.287]/Type/Page>>
endobj
33 0 obj
<>stream
+H‰¬—ÍŽ$·„ïý|âò7I^=|Ã?À@¶+Ò¾?à/’UÕíÙ
Æb§;ÙüIfFF¿üå-|ùé-…?ýð¿?RÈÅ2GÈ)¥p\Ö??þ~{|yû[
+ïßBŠkþ–Õø›‡…ðíŸÿÌÏÿüöø=äø—ÃXÌÉa–XGï¿>4üëãqöàûÇj+%~?òŠÝr8f¬¥…÷ÇQZ\ãÔ„2bm“Oy_ìm_qŽÔ[Ö
+vH#GKÓh¥„£çXº¯À‡\ù¥[œ‹#Zm˜+Žl\/z[ï“õÛfåkWËÍW;yŽ÷Gé1Ïæ§ÏVÎÍ\ܹ>òmâ}SÓ¯D