Compare commits
218 Commits
version/20
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a1acd377b | |||
| c5b84a91d1 | |||
| e77ecda3b8 | |||
| 4e317c10c5 | |||
| eb05a3ddb8 | |||
| a22d6a0924 | |||
| 3f0d67779a | |||
| 0a937ae8e9 | |||
| f8d94f3039 | |||
| 6bb261ac62 | |||
| 45f2c5bae7 | |||
| 5d8c1aa0b0 | |||
| 0101368369 | |||
| 4854f81592 | |||
| 4bed6e02e5 | |||
| 908f123d0e | |||
| 256dd24a1e | |||
| d4284407f9 | |||
| 80da5dfc52 | |||
| b6edf990e0 | |||
| a66dcf9382 | |||
| 9095a840d5 | |||
| 72259f6479 | |||
| 0973c74b9d | |||
| c7ed4f7ac1 | |||
| 3d577cf15e | |||
| 5474a32573 | |||
| a5940b88e3 | |||
| ff15716012 | |||
| c040b13b29 | |||
| 4915e980c5 | |||
| df362dd9ea | |||
| d4e4f93cb4 | |||
| 3af0de6a00 | |||
| 4f24d61290 | |||
| 4c5c4dcf2c | |||
| 660b5cb6c6 | |||
| 6ff1ea73a9 | |||
| 3de224690a | |||
| d4624b510a | |||
| 8856d762d0 | |||
| 5d1cbf14d1 | |||
| 6d5207f644 | |||
| 3b6497cd51 | |||
| ff7320b0f8 | |||
| e5a393c534 | |||
| bb4be944dc | |||
| 21efee8f44 | |||
| f61549a60f | |||
| 0a7bafd1b2 | |||
| b3987c5fa0 | |||
| 0da043a9fe | |||
| f336f204cb | |||
| 3bfcf18492 | |||
| dfafe8b43d | |||
| b5d43b15f8 | |||
| 2ccab75021 | |||
| 9070df6c26 | |||
| a1c8ad55ad | |||
| 872c05c690 | |||
| a9528dc1b5 | |||
| 0e59ade1f2 | |||
| 5ac49c695d | |||
| 3a30ecbe76 | |||
| 1f838bb2aa | |||
| cc42830e23 | |||
| 593eb959ca | |||
| 5bb6785ad6 | |||
| 535c11a729 | |||
| a0fa8d8524 | |||
| c14025c579 | |||
| 8bc3db7c90 | |||
| eaad564e23 | |||
| 511a94975b | |||
| 015810a2fd | |||
| e70e6b84c2 | |||
| d0b9c9a26f | |||
| 3e403fa348 | |||
| 48f4a971ef | |||
| 6314be14ad | |||
| 1a072c6c39 | |||
| ef2eed0bdf | |||
| 91227b1e96 | |||
| 67d68629da | |||
| e875db8f66 | |||
| 055a76393d | |||
| 0754821628 | |||
| fca88d9896 | |||
| dfe0404c51 | |||
| fa61696b46 | |||
| e5773738f4 | |||
| cac8539d79 | |||
| cf600f6f26 | |||
| e194715c3e | |||
| 787f02d5dc | |||
| a0ed01a610 | |||
| 02ba493759 | |||
| a7fea5434d | |||
| 4fb783e953 | |||
| affbf85699 | |||
| 0d92112a3f | |||
| b1ad3ec9db | |||
| c0601baca6 | |||
| 057c5c5e9a | |||
| 05429ab848 | |||
| b66d51a699 | |||
| f834bc0ff2 | |||
| 93fd883d7a | |||
| 7e080d4d68 | |||
| 3e3ca22d04 | |||
| e741caa6b3 | |||
| 4343246a41 | |||
| 3f6f83b4b6 | |||
| c63e1c9b87 | |||
| f44cf06d22 | |||
| 3f609b8601 | |||
| edd89b44a4 | |||
| 3e58748862 | |||
| 7088a6b0e6 | |||
| 6c880e0e62 | |||
| cb1e70be7f | |||
| 6ba150f737 | |||
| 131769ea73 | |||
| e68adbb30d | |||
| f1eef09099 | |||
| 5ab3c7fa9f | |||
| d0cec39a0f | |||
| e15f53a39a | |||
| 25fb995663 | |||
| eac658c64f | |||
| 15e2032493 | |||
| c87f6cd9d9 | |||
| e758995458 | |||
| 20c284a188 | |||
| b0936ea8f3 | |||
| bfc0f4a413 | |||
| 1a9a90cf6a | |||
| 00f1a6fa48 | |||
| 33754a06d2 | |||
| 69b838e1cf | |||
| d5e04a2301 | |||
| fbf251280f | |||
| eaadf62f01 | |||
| 8c33e7a7c1 | |||
| a7d9a80a28 | |||
| 2ea5dce8d3 | |||
| 14bf01efe4 | |||
| 67b24a60e4 | |||
| e6775297cb | |||
| 4e4e2b36b6 | |||
| 3189c56fc3 | |||
| 5b5ea47b7a | |||
| caa382f898 | |||
| 2d63488197 | |||
| c1c8e4c8d4 | |||
| a0e451c5e5 | |||
| eaba8006e6 | |||
| 39ff202f8c | |||
| 654e0d6245 | |||
| ec04443493 | |||
| d247c262af | |||
| dff49b2bef | |||
| 50666a76fb | |||
| b51a7f9746 | |||
| 001dfd9f6c | |||
| 5e4fbeeb25 | |||
| 2c910bf6ca | |||
| 9b11319e81 | |||
| 40dc4b3fb8 | |||
| 0e37b98968 | |||
| 7e132eb014 | |||
| 49dfb4756e | |||
| 814758e2aa | |||
| 5c42dac5e2 | |||
| 88603fa4f7 | |||
| 0232c4e162 | |||
| 11753c1fe1 | |||
| f5cc6c67ec | |||
| 8b8ed3527a | |||
| 1aa0274e7c | |||
| ecd33ca0c1 | |||
| e93be0de9a | |||
| a5adc4f8ed | |||
| a6baed9753 | |||
| ceaf832e63 | |||
| a6b0b14685 | |||
| f679250edd | |||
| acc4de2235 | |||
| 56a8276dbf | |||
| 6dfe6edbef | |||
| 6af4bd0d9a | |||
| 7ee7f6bd6a | |||
| f8b8334010 | |||
| d4b65dc4b4 | |||
| e4bbd3b1c0 | |||
| 87de5e625d | |||
| efbe51673e | |||
| a95bea53ea | |||
| 6021fc0f52 | |||
| 1415b68ff4 | |||
| be6853ac52 | |||
| 7fd6be5abb | |||
| 91d6f572a5 | |||
| 016a9ce34e | |||
| 8adb95af7f | |||
| 1dc54775d8 | |||
| 370ef716b5 | |||
| 16e56ad9ca | |||
| b5b5a9eed3 | |||
| 8b22e7bcc3 | |||
| d48b5b9511 | |||
| 0eccaa3f1e | |||
| 67d550a80d | |||
| ebb5711c32 | |||
| 79ec872232 | |||
| 4284e14ff7 | |||
| 92a09779d0 | |||
| 14c621631d |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2022.1.3
|
||||
current_version = 2022.2.1
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
||||
|
||||
3
.github/workflows/ci-main.yml
vendored
3
.github/workflows/ci-main.yml
vendored
@ -86,10 +86,9 @@ jobs:
|
||||
cp authentik/lib/default.yml local.env.yml
|
||||
cp -R .github ..
|
||||
cp -R scripts ..
|
||||
cp -R poetry.lock pyproject.toml ..
|
||||
git checkout $(git describe --abbrev=0 --match 'version/*')
|
||||
rm -rf .github/ scripts/
|
||||
mv ../.github ../scripts ../poetry.lock ../pyproject.toml .
|
||||
mv ../.github ../scripts .
|
||||
- name: prepare
|
||||
env:
|
||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||
|
||||
14
.github/workflows/release-publish.yml
vendored
14
.github/workflows/release-publish.yml
vendored
@ -30,14 +30,14 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik:2022.1.3,
|
||||
beryju/authentik:2022.2.1,
|
||||
beryju/authentik:latest,
|
||||
ghcr.io/goauthentik/server:2022.1.3,
|
||||
ghcr.io/goauthentik/server:2022.2.1,
|
||||
ghcr.io/goauthentik/server:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
- name: Building Docker Image (stable)
|
||||
if: ${{ github.event_name == 'release' && !contains('2022.1.3', 'rc') }}
|
||||
if: ${{ github.event_name == 'release' && !contains('2022.2.1', 'rc') }}
|
||||
run: |
|
||||
docker pull beryju/authentik:latest
|
||||
docker tag beryju/authentik:latest beryju/authentik:stable
|
||||
@ -78,14 +78,14 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik-${{ matrix.type }}:2022.1.3,
|
||||
beryju/authentik-${{ matrix.type }}:2022.2.1,
|
||||
beryju/authentik-${{ matrix.type }}:latest,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.1.3,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.2.1,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: Building Docker Image (stable)
|
||||
if: ${{ github.event_name == 'release' && !contains('2022.1.3', 'rc') }}
|
||||
if: ${{ github.event_name == 'release' && !contains('2022.2.1', 'rc') }}
|
||||
run: |
|
||||
docker pull beryju/authentik-${{ matrix.type }}:latest
|
||||
docker tag beryju/authentik-${{ matrix.type }}:latest beryju/authentik-${{ matrix.type }}:stable
|
||||
@ -170,7 +170,7 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
version: authentik@2022.1.3
|
||||
version: authentik@2022.2.1
|
||||
environment: beryjuorg-prod
|
||||
sourcemaps: './web/dist'
|
||||
url_prefix: '~/static/dist'
|
||||
|
||||
2
.github/workflows/release-tag.yml
vendored
2
.github/workflows/release-tag.yml
vendored
@ -27,7 +27,7 @@ jobs:
|
||||
docker-compose run -u root server test
|
||||
- name: Extract version number
|
||||
id: get_version
|
||||
uses: actions/github-script@v5
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -12,7 +12,8 @@
|
||||
"totp",
|
||||
"webauthn",
|
||||
"traefik",
|
||||
"passwordless"
|
||||
"passwordless",
|
||||
"kubernetes"
|
||||
],
|
||||
"python.linting.pylintEnabled": true,
|
||||
"todo-tree.tree.showCountsInTree": true,
|
||||
|
||||
@ -16,7 +16,7 @@ ENV NODE_ENV=production
|
||||
RUN cd /work/web && npm i && npm run build
|
||||
|
||||
# Stage 3: Build go proxy
|
||||
FROM docker.io/golang:1.17.6-bullseye AS builder
|
||||
FROM docker.io/golang:1.17.7-bullseye AS builder
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ---------- | ------------------ |
|
||||
| 2021.10.x | :white_check_mark: |
|
||||
| 2021.12.x | :white_check_mark: |
|
||||
| 2022.1.x | :white_check_mark: |
|
||||
| 2022.2.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2022.1.3"
|
||||
__version__ = "2022.2.1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@ -12,10 +12,13 @@ from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class TaskSerializer(PassiveSerializer):
|
||||
"""Serialize TaskInfo and TaskResult"""
|
||||
@ -89,6 +92,7 @@ class TaskViewSet(ViewSet):
|
||||
try:
|
||||
task_module = import_module(task.task_call_module)
|
||||
task_func = getattr(task_module, task.task_call_func)
|
||||
LOGGER.debug("Running task", task=task_func)
|
||||
task_func.delay(*task.task_call_args, **task.task_call_kwargs)
|
||||
messages.success(
|
||||
self.request,
|
||||
@ -96,6 +100,7 @@ class TaskViewSet(ViewSet):
|
||||
)
|
||||
return Response(status=204)
|
||||
except (ImportError, AttributeError): # pragma: no cover
|
||||
LOGGER.warning("Failed to run task, remove state", task=task)
|
||||
# if we get an import error, the module path has probably changed
|
||||
task.delete()
|
||||
return Response(status=500)
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
"""core Configs API"""
|
||||
from os import environ, path
|
||||
from os import path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
|
||||
from rest_framework.fields import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
@ -28,7 +27,6 @@ class Capabilities(models.TextChoices):
|
||||
|
||||
CAN_SAVE_MEDIA = "can_save_media"
|
||||
CAN_GEO_IP = "can_geo_ip"
|
||||
CAN_BACKUP = "can_backup"
|
||||
|
||||
|
||||
class ErrorReportingConfigSerializer(PassiveSerializer):
|
||||
@ -65,13 +63,6 @@ class ConfigView(APIView):
|
||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||
if GEOIP_READER.enabled:
|
||||
caps.append(Capabilities.CAN_GEO_IP)
|
||||
if SERVICE_HOST_ENV_NAME in environ:
|
||||
# Running in k8s, only s3 backup is supported
|
||||
if CONFIG.y("postgresql.s3_backup"):
|
||||
caps.append(Capabilities.CAN_BACKUP)
|
||||
else:
|
||||
# Running in compose, backup is always supported
|
||||
caps.append(Capabilities.CAN_BACKUP)
|
||||
return caps
|
||||
|
||||
@extend_schema(responses={200: ConfigSerializer(many=False)})
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
"""Application API Views"""
|
||||
from typing import Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import QuerySet
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import ReadOnlyField
|
||||
from rest_framework.fields import ReadOnlyField, SerializerMethodField
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
@ -39,11 +42,26 @@ def user_app_cache_key(user_pk: str) -> str:
|
||||
class ApplicationSerializer(ModelSerializer):
|
||||
"""Application Serializer"""
|
||||
|
||||
launch_url = ReadOnlyField(source="get_launch_url")
|
||||
launch_url = SerializerMethodField()
|
||||
provider_obj = ProviderSerializer(source="get_provider", required=False)
|
||||
|
||||
meta_icon = ReadOnlyField(source="get_meta_icon")
|
||||
|
||||
def get_launch_url(self, app: Application) -> Optional[str]:
|
||||
"""Allow formatting of launch URL"""
|
||||
url = app.get_launch_url()
|
||||
if not url:
|
||||
return url
|
||||
user = self.context["request"].user
|
||||
if isinstance(user, SimpleLazyObject):
|
||||
user._setup()
|
||||
user = user._wrapped
|
||||
try:
|
||||
return url % user.__dict__
|
||||
except ValueError as exc:
|
||||
LOGGER.warning("Failed to format launch url", exc=exc)
|
||||
return url
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Application
|
||||
|
||||
@ -3,7 +3,7 @@ from typing import Any
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from guardian.shortcuts import assign_perm, get_anonymous_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
@ -95,10 +95,12 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
def perform_create(self, serializer: TokenSerializer):
|
||||
if not self.request.user.is_superuser:
|
||||
return serializer.save(
|
||||
instance = serializer.save(
|
||||
user=self.request.user,
|
||||
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
|
||||
)
|
||||
assign_perm("authentik_core.view_token_key", self.request.user, instance)
|
||||
return instance
|
||||
return super().perform_create(serializer)
|
||||
|
||||
@permission_required("authentik_core.view_token_key")
|
||||
|
||||
@ -1,17 +1,7 @@
|
||||
"""authentik core tasks"""
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from os import environ
|
||||
|
||||
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.contrib.sessions.backends.cache import KEY_PREFIX
|
||||
from django.core import management
|
||||
from django.core.cache import cache
|
||||
from django.utils.timezone import now
|
||||
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, ExpiringModel
|
||||
@ -21,7 +11,6 @@ from authentik.events.monitored_tasks import (
|
||||
TaskResultStatus,
|
||||
prefill_task,
|
||||
)
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -53,46 +42,3 @@ def clean_expired_models(self: MonitoredTask):
|
||||
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
|
||||
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
|
||||
|
||||
|
||||
def should_backup() -> bool:
|
||||
"""Check if we should be doing backups"""
|
||||
if SERVICE_HOST_ENV_NAME in environ and not CONFIG.y("postgresql.s3_backup.bucket"):
|
||||
LOGGER.info("Running in k8s and s3 backups are not configured, skipping")
|
||||
return False
|
||||
if not CONFIG.y_bool("postgresql.backup.enabled"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
@prefill_task
|
||||
def backup_database(self: MonitoredTask): # pragma: no cover
|
||||
"""Database backup"""
|
||||
self.result_timeout_hours = 25
|
||||
if not should_backup():
|
||||
self.set_status(TaskResult(TaskResultStatus.UNKNOWN, ["Backups are not configured."]))
|
||||
return
|
||||
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,
|
||||
ValueError,
|
||||
) as exc:
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
{% block head_before %}
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}">
|
||||
<script src="{% static 'dist/poly.js' %}" type="module"></script>
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
||||
@ -13,7 +13,9 @@ class TestApplicationsAPI(APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_admin_user()
|
||||
self.allowed = Application.objects.create(name="allowed", slug="allowed")
|
||||
self.allowed = Application.objects.create(
|
||||
name="allowed", slug="allowed", meta_launch_url="https://goauthentik.io/%(username)s"
|
||||
)
|
||||
self.denied = Application.objects.create(name="denied", slug="denied")
|
||||
PolicyBinding.objects.create(
|
||||
target=self.denied,
|
||||
@ -64,8 +66,8 @@ class TestApplicationsAPI(APITestCase):
|
||||
"slug": "allowed",
|
||||
"provider": None,
|
||||
"provider_obj": None,
|
||||
"launch_url": None,
|
||||
"meta_launch_url": "",
|
||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||
"meta_icon": None,
|
||||
"meta_description": "",
|
||||
"meta_publisher": "",
|
||||
@ -100,8 +102,8 @@ class TestApplicationsAPI(APITestCase):
|
||||
"slug": "allowed",
|
||||
"provider": None,
|
||||
"provider_obj": None,
|
||||
"launch_url": None,
|
||||
"meta_launch_url": "",
|
||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||
"meta_icon": None,
|
||||
"meta_description": "",
|
||||
"meta_publisher": "",
|
||||
|
||||
@ -30,6 +30,7 @@ class TestTokenAPI(APITestCase):
|
||||
self.assertEqual(token.user, self.user)
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.expiring, True)
|
||||
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
|
||||
|
||||
def test_token_create_invalid(self):
|
||||
"""Test token creation endpoint (invalid data)"""
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
"""events GeoIP Reader"""
|
||||
from datetime import datetime
|
||||
from os import stat
|
||||
from time import time
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from geoip2.database import Reader
|
||||
@ -46,14 +44,18 @@ class GeoIPReader:
|
||||
LOGGER.warning("Failed to load GeoIP database", exc=exc)
|
||||
|
||||
def __check_expired(self):
|
||||
"""Check if the geoip database has been opened longer than 8 hours,
|
||||
and re-open it, as it will probably will have been re-downloaded"""
|
||||
now = time()
|
||||
diff = datetime.fromtimestamp(now) - datetime.fromtimestamp(self.__last_mtime)
|
||||
diff_hours = diff.total_seconds() // 3600
|
||||
if diff_hours >= 8:
|
||||
LOGGER.info("GeoIP databased loaded too long, re-opening", diff=diff)
|
||||
self.__open()
|
||||
"""Check if the modification date of the GeoIP database has
|
||||
changed, and reload it if so"""
|
||||
path = CONFIG.y("geoip")
|
||||
try:
|
||||
mtime = stat(path).st_mtime
|
||||
diff = self.__last_mtime < mtime
|
||||
if diff > 0:
|
||||
LOGGER.info("Found new GeoIP Database, reopening", diff=diff)
|
||||
self.__open()
|
||||
except OSError as exc:
|
||||
LOGGER.warning("Failed to check GeoIP age", exc=exc)
|
||||
return
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
|
||||
@ -5,16 +5,6 @@ postgresql:
|
||||
user: authentik
|
||||
port: 5432
|
||||
password: 'env://POSTGRES_PASSWORD'
|
||||
backup:
|
||||
enabled: false
|
||||
s3_backup:
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
bucket: ""
|
||||
region: eu-central-1
|
||||
host: ""
|
||||
location: ""
|
||||
insecure_skip_verify: false
|
||||
|
||||
web:
|
||||
listen: 0.0.0.0:9000
|
||||
@ -65,6 +55,7 @@ outposts:
|
||||
# %(version)s: Current version; 2021.4.1
|
||||
# %(build_hash)s: Build hash if you're running a beta version
|
||||
container_image_base: ghcr.io/goauthentik/%(type)s:%(version)s
|
||||
discover: true
|
||||
|
||||
cookie_domain: null
|
||||
disable_update_check: false
|
||||
|
||||
@ -32,6 +32,7 @@ class BaseEvaluator:
|
||||
self._globals = {
|
||||
"regex_match": BaseEvaluator.expr_regex_match,
|
||||
"regex_replace": BaseEvaluator.expr_regex_replace,
|
||||
"list_flatten": BaseEvaluator.expr_flatten,
|
||||
"ak_is_group_member": BaseEvaluator.expr_is_group_member,
|
||||
"ak_user_by": BaseEvaluator.expr_user_by,
|
||||
"ak_logger": get_logger(),
|
||||
@ -40,6 +41,15 @@ class BaseEvaluator:
|
||||
self._context = {}
|
||||
self._filename = "BaseEvalautor"
|
||||
|
||||
@staticmethod
|
||||
def expr_flatten(value: list[Any] | Any) -> Optional[Any]:
|
||||
"""Flatten `value` if its a list"""
|
||||
if isinstance(value, list):
|
||||
if len(value) < 1:
|
||||
return None
|
||||
return value[0]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def expr_regex_match(value: Any, regex: str) -> bool:
|
||||
"""Expression Filter to run re.search"""
|
||||
|
||||
6
authentik/lib/merge.py
Normal file
6
authentik/lib/merge.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""merge utils"""
|
||||
from deepmerge import Merger
|
||||
|
||||
MERGE_LIST_UNIQUE = Merger(
|
||||
[(list, ["append_unique"]), (dict, ["merge"]), (set, ["union"])], ["override"], ["override"]
|
||||
)
|
||||
@ -3,8 +3,6 @@ from typing import Optional
|
||||
|
||||
from aioredis.errors import ConnectionClosedError, ReplyError
|
||||
from billiard.exceptions import SoftTimeLimitExceeded, WorkerLostError
|
||||
from botocore.client import ClientError
|
||||
from botocore.exceptions import BotoCoreError
|
||||
from celery.exceptions import CeleryError
|
||||
from channels.middleware import BaseMiddleware
|
||||
from channels_redis.core import ChannelFull
|
||||
@ -81,9 +79,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
|
||||
WorkerLostError,
|
||||
CeleryError,
|
||||
SoftTimeLimitExceeded,
|
||||
# S3 errors
|
||||
BotoCoreError,
|
||||
ClientError,
|
||||
# custom baseclass
|
||||
SentryIgnoredException,
|
||||
# ldap errors
|
||||
@ -101,8 +96,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
|
||||
return None
|
||||
if "logger" in event:
|
||||
if event["logger"] in [
|
||||
"dbbackup",
|
||||
"botocore",
|
||||
"kombu",
|
||||
"asyncio",
|
||||
"multiprocessing",
|
||||
|
||||
@ -55,6 +55,10 @@ class OutpostConsumer(AuthJsonConsumer):
|
||||
|
||||
first_msg = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.logger = get_logger()
|
||||
|
||||
def connect(self):
|
||||
super().connect()
|
||||
uuid = self.scope["url_route"]["kwargs"]["pk"]
|
||||
@ -65,7 +69,7 @@ class OutpostConsumer(AuthJsonConsumer):
|
||||
)
|
||||
if not outpost:
|
||||
raise DenyConnection()
|
||||
self.logger = get_logger().bind(outpost=outpost)
|
||||
self.logger = self.logger.bind(outpost=outpost)
|
||||
try:
|
||||
self.accept()
|
||||
except RuntimeError as exc:
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from kubernetes.client.models.v1_container_port import V1ContainerPort
|
||||
from kubernetes.client.models.v1_service_port import V1ServicePort
|
||||
from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME
|
||||
|
||||
from authentik.outposts.controllers.k8s.triggers import NeedsRecreate
|
||||
@ -16,10 +17,31 @@ def get_namespace() -> str:
|
||||
return "default"
|
||||
|
||||
|
||||
def compare_ports(current: list[V1ContainerPort], reference: list[V1ContainerPort]):
|
||||
def compare_port(
|
||||
current: V1ServicePort | V1ContainerPort, reference: V1ServicePort | V1ContainerPort
|
||||
) -> bool:
|
||||
"""Compare a single port"""
|
||||
if current.name != reference.name:
|
||||
return False
|
||||
if current.protocol != reference.protocol:
|
||||
return False
|
||||
if isinstance(current, V1ServicePort) and isinstance(reference, V1ServicePort):
|
||||
# We only care about the target port
|
||||
if current.target_port != reference.target_port:
|
||||
return False
|
||||
if isinstance(current, V1ContainerPort) and isinstance(reference, V1ContainerPort):
|
||||
# We only care about the target port
|
||||
if current.container_port != reference.container_port:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compare_ports(
|
||||
current: list[V1ServicePort | V1ContainerPort], reference: list[V1ServicePort | V1ContainerPort]
|
||||
):
|
||||
"""Compare ports of a list"""
|
||||
if len(current) != len(reference):
|
||||
raise NeedsRecreate()
|
||||
for port in reference:
|
||||
if port not in current:
|
||||
if not any(compare_port(port, current_port) for current_port in current):
|
||||
raise NeedsRecreate()
|
||||
|
||||
@ -3,6 +3,8 @@ import os
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
|
||||
from docker.errors import DockerException
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
|
||||
HEADER = "### Managed by authentik"
|
||||
@ -27,6 +29,8 @@ class DockerInlineSSH:
|
||||
def __init__(self, host: str, keypair: CertificateKeyPair) -> None:
|
||||
self.host = host
|
||||
self.keypair = keypair
|
||||
if not self.keypair:
|
||||
raise DockerException("keypair must be set for SSH connections")
|
||||
self.config_path = Path("~/.ssh/config").expanduser()
|
||||
self.header = f"{HEADER} - {self.host}\n"
|
||||
|
||||
|
||||
@ -23,6 +23,7 @@ from authentik.events.monitored_tasks import (
|
||||
TaskResultStatus,
|
||||
prefill_task,
|
||||
)
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.reflection import path_to_class
|
||||
from authentik.outposts.controllers.base import BaseController, ControllerException
|
||||
from authentik.outposts.controllers.docker import DockerClient
|
||||
@ -231,6 +232,9 @@ def _outpost_single_update(outpost: Outpost, layer=None):
|
||||
@CELERY_APP.task()
|
||||
def outpost_local_connection():
|
||||
"""Checks the local environment and create Service connections."""
|
||||
if not CONFIG.y_bool("outposts.discover"):
|
||||
LOGGER.debug("outpost integration discovery is disabled")
|
||||
return
|
||||
# Explicitly check against token filename, as that's
|
||||
# only present when the integration is enabled
|
||||
if Path(SERVICE_TOKEN_FILENAME).exists():
|
||||
|
||||
@ -45,6 +45,13 @@ class GrantTypes(models.TextChoices):
|
||||
HYBRID = "hybrid"
|
||||
|
||||
|
||||
class ResponseMode(models.TextChoices):
|
||||
"""https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#OAuth.Post"""
|
||||
|
||||
QUERY = "query"
|
||||
FRAGMENT = "fragment"
|
||||
|
||||
|
||||
class SubModes(models.TextChoices):
|
||||
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
)
|
||||
with self.assertRaises(AuthorizeError):
|
||||
request = self.factory.get(
|
||||
@ -51,7 +51,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://local.invalid",
|
||||
"redirect_uri": "http://local.invalid/Foo",
|
||||
"request": "foo",
|
||||
},
|
||||
)
|
||||
@ -105,26 +105,30 @@ class TestAuthorize(OAuthTestCase):
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
)
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://local.invalid",
|
||||
"redirect_uri": "http://local.invalid/Foo",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).redirect_uri,
|
||||
"http://local.invalid/Foo",
|
||||
)
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
"response_type": "id_token",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://local.invalid",
|
||||
"redirect_uri": "http://local.invalid/Foo",
|
||||
"scope": "openid",
|
||||
"state": "foo",
|
||||
},
|
||||
@ -140,7 +144,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
data={
|
||||
"response_type": "id_token",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://local.invalid",
|
||||
"redirect_uri": "http://local.invalid/Foo",
|
||||
"state": "foo",
|
||||
},
|
||||
)
|
||||
@ -153,7 +157,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
data={
|
||||
"response_type": "code token",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://local.invalid",
|
||||
"redirect_uri": "http://local.invalid/Foo",
|
||||
"scope": "openid",
|
||||
"state": "foo",
|
||||
},
|
||||
@ -167,7 +171,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
data={
|
||||
"response_type": "invalid",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://local.invalid",
|
||||
"redirect_uri": "http://local.invalid/Foo",
|
||||
},
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
|
||||
@ -44,6 +44,7 @@ from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
ResponseMode,
|
||||
ResponseTypes,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import HttpResponseRedirectScheme
|
||||
@ -99,7 +100,7 @@ class OAuthAuthorizationParams:
|
||||
# and POST request.
|
||||
query_dict = request.POST if request.method == "POST" else request.GET
|
||||
state = query_dict.get("state")
|
||||
redirect_uri = query_dict.get("redirect_uri", "").lower()
|
||||
redirect_uri = query_dict.get("redirect_uri", "")
|
||||
|
||||
response_type = query_dict.get("response_type", "")
|
||||
grant_type = None
|
||||
@ -153,7 +154,10 @@ class OAuthAuthorizationParams:
|
||||
def check_redirect_uri(self):
|
||||
"""Redirect URI validation."""
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
if not self.redirect_uri:
|
||||
# We don't want to actually lowercase the final URL we redirect to,
|
||||
# we only lowercase it for comparison
|
||||
redirect_uri = self.redirect_uri.lower()
|
||||
if not redirect_uri:
|
||||
LOGGER.warning("Missing redirect uri.")
|
||||
raise RedirectUriError("", allowed_redirect_urls)
|
||||
|
||||
@ -169,7 +173,7 @@ class OAuthAuthorizationParams:
|
||||
allow=self.redirect_uri,
|
||||
)
|
||||
return
|
||||
if self.redirect_uri not in [x.lower() for x in allowed_redirect_urls]:
|
||||
if redirect_uri not in [x.lower() for x in allowed_redirect_urls]:
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect_uri=self.redirect_uri,
|
||||
@ -299,13 +303,23 @@ class OAuthFulfillmentStage(StageView):
|
||||
code = self.params.create_code(self.request)
|
||||
code.save(force_insert=True)
|
||||
|
||||
if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
|
||||
query_dict = self.request.POST if self.request.method == "POST" else self.request.GET
|
||||
response_mode = ResponseMode.QUERY
|
||||
# Get response mode from url param, otherwise decide based on grant type
|
||||
if "response_mode" in query_dict:
|
||||
response_mode = query_dict["response_mode"]
|
||||
elif self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
|
||||
response_mode = ResponseMode.QUERY
|
||||
elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
response_mode = ResponseMode.FRAGMENT
|
||||
|
||||
if response_mode == ResponseMode.QUERY:
|
||||
query_params["code"] = code.code
|
||||
query_params["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
|
||||
uri = uri._replace(query=urlencode(query_params, doseq=True))
|
||||
return urlunsplit(uri)
|
||||
if self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
if response_mode == ResponseMode.FRAGMENT:
|
||||
query_fragment = self.create_implicit_response(code)
|
||||
|
||||
uri = uri._replace(
|
||||
|
||||
@ -12,4 +12,8 @@ class AuthentikProviderProxyConfig(AppConfig):
|
||||
verbose_name = "authentik Providers.Proxy"
|
||||
|
||||
def ready(self) -> None:
|
||||
from authentik.providers.proxy.tasks import proxy_set_defaults
|
||||
|
||||
import_module("authentik.providers.proxy.managed")
|
||||
|
||||
proxy_set_defaults.delay()
|
||||
|
||||
@ -23,17 +23,17 @@ class ProxyDockerController(DockerController):
|
||||
proxy_provider: ProxyProvider
|
||||
external_host_name = urlparse(proxy_provider.external_host)
|
||||
hosts.append(f"`{external_host_name.netloc}`")
|
||||
traefik_name = f"ak-outpost-{self.outpost.pk.hex}"
|
||||
traefik_name = self.name
|
||||
labels = super()._get_labels()
|
||||
labels["traefik.enable"] = "true"
|
||||
labels[
|
||||
f"traefik.http.routers.{traefik_name}-router.rule"
|
||||
] = f"Host({','.join(hosts)}) && PathPrefix(`/akprox`)"
|
||||
] = f"Host({','.join(hosts)}) && PathPrefix(`/outpost.goauthentik.io`)"
|
||||
labels[f"traefik.http.routers.{traefik_name}-router.tls"] = "true"
|
||||
labels[f"traefik.http.routers.{traefik_name}-router.service"] = f"{traefik_name}-service"
|
||||
labels[
|
||||
f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path"
|
||||
] = "/akprox/ping"
|
||||
] = "/outpost.goauthentik.io/ping"
|
||||
labels[
|
||||
f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.port"
|
||||
] = "9300"
|
||||
|
||||
@ -92,6 +92,8 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
||||
# Buffer sizes for large headers with JWTs
|
||||
"nginx.ingress.kubernetes.io/proxy-buffers-number": "4",
|
||||
"nginx.ingress.kubernetes.io/proxy-buffer-size": "16k",
|
||||
# Enable TLS in traefik
|
||||
"traefik.ingress.kubernetes.io/router.tls": "true",
|
||||
}
|
||||
annotations.update(self.controller.outpost.config.kubernetes_ingress_annotations)
|
||||
return annotations
|
||||
@ -126,7 +128,7 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
||||
port=V1ServiceBackendPort(name="http"),
|
||||
),
|
||||
),
|
||||
path="/akprox",
|
||||
path="/outpost.goauthentik.io",
|
||||
path_type="ImplementationSpecific",
|
||||
)
|
||||
]
|
||||
|
||||
@ -119,7 +119,10 @@ class TraefikMiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware])
|
||||
),
|
||||
spec=TraefikMiddlewareSpec(
|
||||
forwardAuth=TraefikMiddlewareSpecForwardAuth(
|
||||
address=f"http://{self.name}.{self.namespace}:9000/akprox/auth/traefik",
|
||||
address=(
|
||||
f"http://{self.name}.{self.namespace}:9000/"
|
||||
"outpost.goauthentik.io/auth/traefik"
|
||||
),
|
||||
authResponseHeaders=[
|
||||
"X-authentik-username",
|
||||
"X-authentik-groups",
|
||||
|
||||
@ -27,7 +27,7 @@ def get_cookie_secret():
|
||||
|
||||
|
||||
def _get_callback_url(uri: str) -> str:
|
||||
return urljoin(uri, "/akprox/callback")
|
||||
return urljoin(uri, "outpost.goauthentik.io/callback")
|
||||
|
||||
|
||||
class ProxyMode(models.TextChoices):
|
||||
|
||||
11
authentik/providers/proxy/tasks.py
Normal file
11
authentik/providers/proxy/tasks.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""proxy provider tasks"""
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def proxy_set_defaults():
|
||||
"""Ensure correct defaults are set for all providers"""
|
||||
for provider in ProxyProvider.objects.all():
|
||||
provider.set_oauth_defaults()
|
||||
provider.save()
|
||||
@ -15,6 +15,7 @@ from authentik.providers.saml.processors.request_parser import AuthNRequestParse
|
||||
from authentik.sources.saml.exceptions import MismatchedRequestID
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
SAML_BINDING_REDIRECT,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
)
|
||||
@ -98,6 +99,9 @@ class TestAuthNRequest(TestCase):
|
||||
|
||||
# First create an AuthNRequest
|
||||
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
||||
auth_n = request_proc.get_auth_n()
|
||||
self.assertEqual(auth_n.attrib["ProtocolBinding"], SAML_BINDING_REDIRECT)
|
||||
|
||||
request = request_proc.build_auth_n()
|
||||
# Now we check the ID and signature
|
||||
parsed_request = AuthNRequestParser(self.provider).parse(
|
||||
|
||||
@ -1,14 +1,4 @@
|
||||
"""
|
||||
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/
|
||||
"""
|
||||
"""root settings for authentik"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
@ -16,7 +6,6 @@ import os
|
||||
import sys
|
||||
from hashlib import sha512
|
||||
from json import dumps
|
||||
from tempfile import gettempdir
|
||||
from time import time
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
@ -24,18 +13,16 @@ import structlog
|
||||
from celery.schedules import crontab
|
||||
from sentry_sdk import init as sentry_init
|
||||
from sentry_sdk.api import set_tag
|
||||
from sentry_sdk.integrations.boto3 import Boto3Integration
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from sentry_sdk.integrations.threading import ThreadingIntegration
|
||||
|
||||
from authentik import ENV_GIT_HASH_KEY, __version__, get_build_hash, get_full_version
|
||||
from authentik import ENV_GIT_HASH_KEY, __version__, get_build_hash
|
||||
from authentik.core.middleware import structlog_add_request_id
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.logging import add_process_id
|
||||
from authentik.lib.sentry import before_send
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.lib.utils.reflection import get_env
|
||||
from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP
|
||||
|
||||
@ -149,7 +136,6 @@ INSTALLED_APPS = [
|
||||
"guardian",
|
||||
"django_prometheus",
|
||||
"channels",
|
||||
"dbbackup",
|
||||
]
|
||||
|
||||
GUARDIAN_MONKEY_PATCH = False
|
||||
@ -369,32 +355,6 @@ CELERY_RESULT_BACKEND = (
|
||||
f"{_redis_url}/{CONFIG.y('redis.message_queue_db')}{REDIS_CELERY_TLS_REQUIREMENTS}"
|
||||
)
|
||||
|
||||
# Database backup
|
||||
DBBACKUP_STORAGE = "django.core.files.storage.FileSystemStorage"
|
||||
DBBACKUP_STORAGE_OPTIONS = {"location": "./backups" if DEBUG else "/backups"}
|
||||
DBBACKUP_FILENAME_TEMPLATE = f"authentik-backup-{__version__}-{{datetime}}.sql"
|
||||
DBBACKUP_CONNECTOR_MAPPING = {
|
||||
"django_prometheus.db.backends.postgresql": "dbbackup.db.postgresql.PgDumpConnector",
|
||||
}
|
||||
DBBACKUP_TMP_DIR = gettempdir() if DEBUG else "/tmp" # nosec
|
||||
DBBACKUP_CLEANUP_KEEP = 10
|
||||
if CONFIG.y("postgresql.s3_backup.bucket", "") != "":
|
||||
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"),
|
||||
"location": CONFIG.y("postgresql.s3_backup.location", ""),
|
||||
"verify": not CONFIG.y_bool("postgresql.s3_backup.insecure_skip_verify", False),
|
||||
}
|
||||
j_print(
|
||||
"Database backup to S3 is configured",
|
||||
host=CONFIG.y("postgresql.s3_backup.host"),
|
||||
)
|
||||
|
||||
# Sentry integration
|
||||
SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8"
|
||||
|
||||
@ -408,7 +368,6 @@ if _ERROR_REPORTING:
|
||||
DjangoIntegration(transaction_style="function_name"),
|
||||
CeleryIntegration(),
|
||||
RedisIntegration(),
|
||||
Boto3Integration(),
|
||||
ThreadingIntegration(propagate_hub=True),
|
||||
],
|
||||
before_send=before_send,
|
||||
@ -425,29 +384,6 @@ if _ERROR_REPORTING:
|
||||
"Error reporting is enabled",
|
||||
env=CONFIG.y("error_reporting.environment", "customer"),
|
||||
)
|
||||
if not CONFIG.y_bool("disable_startup_analytics", False):
|
||||
should_send = env not in ["dev", "ci"]
|
||||
if should_send:
|
||||
try:
|
||||
get_http_session().post(
|
||||
"https://goauthentik.io/api/event",
|
||||
json={
|
||||
"domain": "authentik",
|
||||
"name": "pageview",
|
||||
"referrer": get_full_version(),
|
||||
"url": (
|
||||
f"http://localhost/{env}?utm_source={get_full_version()}&utm_medium={env}"
|
||||
),
|
||||
},
|
||||
headers={
|
||||
"User-Agent": sha512(str(SECRET_KEY).encode("ascii")).hexdigest()[:16],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
# pylint: disable=bare-except
|
||||
except: # nosec
|
||||
pass
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.1/howto/static-files/
|
||||
@ -529,12 +465,9 @@ _LOGGING_HANDLER_MAP = {
|
||||
"urllib3": "WARNING",
|
||||
"websockets": "WARNING",
|
||||
"daphne": "WARNING",
|
||||
"dbbackup": "ERROR",
|
||||
"kubernetes": "INFO",
|
||||
"asyncio": "WARNING",
|
||||
"aioredis": "WARNING",
|
||||
"s3transfer": "WARNING",
|
||||
"botocore": "WARNING",
|
||||
}
|
||||
for handler_name, level in _LOGGING_HANDLER_MAP.items():
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
|
||||
@ -35,21 +35,21 @@ class LDAPProviderManager(ObjectManager):
|
||||
"goauthentik.io/sources/ldap/ms-userprincipalname",
|
||||
name="authentik default Active Directory Mapping: userPrincipalName",
|
||||
object_field="attributes.upn",
|
||||
expression="return ldap.get('userPrincipalName')",
|
||||
expression="return list_flatten(ldap.get('userPrincipalName'))",
|
||||
),
|
||||
EnsureExists(
|
||||
LDAPPropertyMapping,
|
||||
"goauthentik.io/sources/ldap/ms-givenName",
|
||||
name="authentik default Active Directory Mapping: givenName",
|
||||
object_field="attributes.givenName",
|
||||
expression="return ldap.get('givenName')",
|
||||
expression="return list_flatten(ldap.get('givenName'))",
|
||||
),
|
||||
EnsureExists(
|
||||
LDAPPropertyMapping,
|
||||
"goauthentik.io/sources/ldap/ms-sn",
|
||||
name="authentik default Active Directory Mapping: sn",
|
||||
object_field="attributes.sn",
|
||||
expression="return ldap.get('sn')",
|
||||
expression="return list_flatten(ldap.get('sn'))",
|
||||
),
|
||||
# OpenLDAP specific mappings
|
||||
EnsureExists(
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
from typing import Any
|
||||
|
||||
from deepmerge import always_merger
|
||||
from django.db.models.base import Model
|
||||
from django.db.models.query import QuerySet
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
|
||||
@ -123,8 +123,8 @@ class BaseLDAPSynchronizer:
|
||||
continue
|
||||
setattr(instance, key, value)
|
||||
final_atttributes = {}
|
||||
always_merger.merge(final_atttributes, instance.attributes)
|
||||
always_merger.merge(final_atttributes, data.get("attributes", {}))
|
||||
MERGE_LIST_UNIQUE.merge(final_atttributes, instance.attributes)
|
||||
MERGE_LIST_UNIQUE.merge(final_atttributes, data.get("attributes", {}))
|
||||
instance.attributes = final_atttributes
|
||||
instance.save()
|
||||
return (instance, False)
|
||||
|
||||
@ -3,6 +3,7 @@ from ldap3.core.exceptions import LDAPException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.reflection import class_to_path, path_to_class
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
@ -52,5 +53,5 @@ def ldap_sync(self: MonitoredTask, source_pk: str, sync_class: str):
|
||||
)
|
||||
except LDAPException as exc:
|
||||
# No explicit event is created here as .set_status with an error will do that
|
||||
LOGGER.debug(exc)
|
||||
LOGGER.warning(exception_to_string(exc))
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||
|
||||
@ -18,6 +18,8 @@ from authentik.sources.saml.processors.constants import (
|
||||
RSA_SHA256,
|
||||
RSA_SHA384,
|
||||
RSA_SHA512,
|
||||
SAML_BINDING_POST,
|
||||
SAML_BINDING_REDIRECT,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_PERSISTENT,
|
||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||
@ -37,6 +39,15 @@ class SAMLBindingTypes(models.TextChoices):
|
||||
POST = "POST", _("POST Binding")
|
||||
POST_AUTO = "POST_AUTO", _("POST Binding with auto-confirmation")
|
||||
|
||||
@property
|
||||
def uri(self) -> str:
|
||||
"""Convert database field to URI"""
|
||||
return {
|
||||
SAMLBindingTypes.POST: SAML_BINDING_POST,
|
||||
SAMLBindingTypes.POST_AUTO: SAML_BINDING_POST,
|
||||
SAMLBindingTypes.REDIRECT: SAML_BINDING_REDIRECT,
|
||||
}[self]
|
||||
|
||||
|
||||
class SAMLNameIDPolicy(models.TextChoices):
|
||||
"""SAML NameID Policies"""
|
||||
|
||||
@ -10,7 +10,7 @@ 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.models import SAMLBindingTypes, SAMLSource
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
NS_MAP,
|
||||
@ -62,7 +62,7 @@ class RequestProcessor:
|
||||
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["ProtocolBinding"] = SAMLBindingTypes(self.source.binding_type).uri
|
||||
auth_n_request.attrib["Version"] = "2.0"
|
||||
# Create issuer object
|
||||
auth_n_request.append(self.get_issuer())
|
||||
|
||||
@ -13,8 +13,8 @@ class AuthenticatorValidateStageSerializer(StageSerializer):
|
||||
|
||||
def validate_not_configured_action(self, value):
|
||||
"""Ensure that a configuration stage is set when not_configured_action is configure"""
|
||||
configuration_stage = self.initial_data.get("configuration_stage")
|
||||
if value == NotConfiguredAction.CONFIGURE and configuration_stage is None:
|
||||
configuration_stages = self.initial_data.get("configuration_stages")
|
||||
if value == NotConfiguredAction.CONFIGURE and configuration_stages is None:
|
||||
raise ValidationError(
|
||||
(
|
||||
'When "Not configured action" is set to "Configure", '
|
||||
@ -29,7 +29,7 @@ class AuthenticatorValidateStageSerializer(StageSerializer):
|
||||
fields = StageSerializer.Meta.fields + [
|
||||
"not_configured_action",
|
||||
"device_classes",
|
||||
"configuration_stage",
|
||||
"configuration_stages",
|
||||
]
|
||||
|
||||
|
||||
@ -38,5 +38,5 @@ class AuthenticatorValidateStageViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
queryset = AuthenticatorValidateStage.objects.all()
|
||||
serializer_class = AuthenticatorValidateStageSerializer
|
||||
filterset_fields = ["name", "not_configured_action", "configuration_stage"]
|
||||
filterset_fields = ["name", "not_configured_action", "configuration_stages"]
|
||||
ordering = ["name"]
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
# Generated by Django 4.0.1 on 2022-01-05 22:09
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def migrate_configuration_stage(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
AuthenticatorValidateStage = apps.get_model(
|
||||
"authentik_stages_authenticator_validate", "AuthenticatorValidateStage"
|
||||
)
|
||||
|
||||
for stage in AuthenticatorValidateStage.objects.using(db_alias).all():
|
||||
if stage.configuration_stage:
|
||||
stage.configuration_stages.set([stage.configuration_stage])
|
||||
stage.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0021_auto_20211227_2103"),
|
||||
("authentik_stages_authenticator_validate", "0009_default_stage"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="authenticatorvalidatestage",
|
||||
name="configuration_stages",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Stages used to configure Authenticator when user doesn't have any compatible devices. After this configuration Stage passes, the user is not prompted again.",
|
||||
related_name="+",
|
||||
to="authentik_flows.Stage",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_configuration_stage),
|
||||
migrations.RemoveField(
|
||||
model_name="authenticatorvalidatestage",
|
||||
name="configuration_stage",
|
||||
),
|
||||
]
|
||||
@ -38,16 +38,14 @@ class AuthenticatorValidateStage(Stage):
|
||||
choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP
|
||||
)
|
||||
|
||||
configuration_stage = models.ForeignKey(
|
||||
configuration_stages = models.ManyToManyField(
|
||||
Stage,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
on_delete=models.SET_DEFAULT,
|
||||
related_name="+",
|
||||
help_text=_(
|
||||
(
|
||||
"Stage used to configure Authenticator when user doesn't have any compatible "
|
||||
"Stages used to configure Authenticator when user doesn't have any compatible "
|
||||
"devices. After this configuration Stage passes, the user is not prompted again."
|
||||
)
|
||||
),
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
"""Authenticator Validation"""
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django_otp import devices_for_user
|
||||
from rest_framework.fields import CharField, IntegerField, JSONField, ListField
|
||||
from rest_framework.fields import CharField, IntegerField, JSONField, ListField, UUIDField
|
||||
from rest_framework.serializers import ValidationError
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import cleanse_dict, sanitize_dict
|
||||
from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUserInfoChallenge
|
||||
@ -26,6 +28,18 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
LOGGER = get_logger()
|
||||
SESSION_STAGES = "goauthentik.io/stages/authenticator_validate/stages"
|
||||
SESSION_SELECTED_STAGE = "goauthentik.io/stages/authenticator_validate/selected_stage"
|
||||
SESSION_DEVICE_CHALLENGES = "goauthentik.io/stages/authenticator_validate/device_challenges"
|
||||
|
||||
|
||||
class SelectableStageSerializer(PassiveSerializer):
|
||||
"""Serializer for stages which can be selected by users"""
|
||||
|
||||
pk = UUIDField()
|
||||
name = CharField()
|
||||
verbose_name = CharField()
|
||||
meta_model_name = CharField()
|
||||
|
||||
|
||||
class AuthenticatorValidationChallenge(WithUserInfoChallenge):
|
||||
@ -33,12 +47,14 @@ class AuthenticatorValidationChallenge(WithUserInfoChallenge):
|
||||
|
||||
device_challenges = ListField(child=DeviceChallenge())
|
||||
component = CharField(default="ak-stage-authenticator-validate")
|
||||
configuration_stages = ListField(child=SelectableStageSerializer())
|
||||
|
||||
|
||||
class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||
"""Challenge used for Code-based and WebAuthn authenticators"""
|
||||
|
||||
selected_challenge = DeviceChallenge(required=False)
|
||||
selected_stage = CharField(required=False)
|
||||
|
||||
code = CharField(required=False)
|
||||
webauthn = JSONField(required=False)
|
||||
@ -46,7 +62,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||
component = CharField(default="ak-stage-authenticator-validate")
|
||||
|
||||
def _challenge_allowed(self, classes: list):
|
||||
device_challenges: list[dict] = self.stage.request.session.get("device_challenges")
|
||||
device_challenges: list[dict] = self.stage.request.session.get(SESSION_DEVICE_CHALLENGES)
|
||||
if not any(x["device_class"] in classes for x in device_challenges):
|
||||
raise ValidationError("No compatible device class allowed")
|
||||
|
||||
@ -71,7 +87,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||
def validate_selected_challenge(self, challenge: dict) -> dict:
|
||||
"""Check which challenge the user has selected. Actual logic only used for SMS stage."""
|
||||
# First check if the challenge is valid
|
||||
for device_challenge in self.stage.request.session.get("device_challenges"):
|
||||
for device_challenge in self.stage.request.session.get(SESSION_DEVICE_CHALLENGES):
|
||||
if device_challenge.get("device_class", "") != challenge.get("device_class", ""):
|
||||
raise ValidationError("invalid challenge selected")
|
||||
if device_challenge.get("device_uid", "") != challenge.get("device_uid", ""):
|
||||
@ -84,6 +100,15 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||
select_challenge(self.stage.request, devices.first())
|
||||
return challenge
|
||||
|
||||
def validate_selected_stage(self, stage_pk: str) -> str:
|
||||
"""Check that the selected stage is valid"""
|
||||
stages = self.stage.request.session.get(SESSION_STAGES, [])
|
||||
if not any(str(stage.pk) == stage_pk for stage in stages):
|
||||
raise ValidationError("Selected stage is invalid")
|
||||
LOGGER.debug("Setting selected stage to ", stage=stage_pk)
|
||||
self.stage.request.session[SESSION_SELECTED_STAGE] = stage_pk
|
||||
return stage_pk
|
||||
|
||||
def validate(self, attrs: dict):
|
||||
# Checking if the given data is from a valid device class is done above
|
||||
# Here we only check if the any data was sent at all
|
||||
@ -164,7 +189,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
else:
|
||||
LOGGER.debug("No pending user, continuing")
|
||||
return self.executor.stage_ok()
|
||||
self.request.session["device_challenges"] = challenges
|
||||
self.request.session[SESSION_DEVICE_CHALLENGES] = challenges
|
||||
|
||||
# No allowed devices
|
||||
if len(challenges) < 1:
|
||||
@ -175,32 +200,74 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
LOGGER.debug("Authenticator not configured, denying")
|
||||
return self.executor.stage_invalid()
|
||||
if stage.not_configured_action == NotConfiguredAction.CONFIGURE:
|
||||
if not stage.configuration_stage:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=(
|
||||
"Authenticator validation stage is set to configure user "
|
||||
"but no configuration flow is set."
|
||||
),
|
||||
stage=self,
|
||||
).from_http(self.request).set_user(user).save()
|
||||
return self.executor.stage_invalid()
|
||||
LOGGER.debug("Authenticator not configured, sending user to configure")
|
||||
# Because the foreign key to stage.configuration_stage points to
|
||||
# a base stage class, we need to do another lookup
|
||||
stage = Stage.objects.get_subclass(pk=stage.configuration_stage.pk)
|
||||
# plan.insert inserts at 1 index, so when stage_ok pops 0,
|
||||
# the configuration stage is next
|
||||
self.executor.plan.insert_stage(stage)
|
||||
return self.executor.stage_ok()
|
||||
LOGGER.debug("Authenticator not configured, forcing configure")
|
||||
return self.prepare_stages(user)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def prepare_stages(self, user: User, *args, **kwargs) -> HttpResponse:
|
||||
"""Check how the user can configure themselves. If no stages are set, return an error.
|
||||
If a single stage is set, insert that stage directly. If multiple are selected, include
|
||||
them in the challenge."""
|
||||
stage: AuthenticatorValidateStage = self.executor.current_stage
|
||||
if not stage.configuration_stages.exists():
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=(
|
||||
"Authenticator validation stage is set to configure user "
|
||||
"but no configuration flow is set."
|
||||
),
|
||||
stage=self,
|
||||
).from_http(self.request).set_user(user).save()
|
||||
return self.executor.stage_invalid()
|
||||
if stage.configuration_stages.count() == 1:
|
||||
next_stage = Stage.objects.get_subclass(pk=stage.configuration_stages.first().pk)
|
||||
LOGGER.debug("Single stage configured, auto-selecting", stage=next_stage)
|
||||
self.request.session[SESSION_SELECTED_STAGE] = next_stage
|
||||
# Because that normal insetion only happens on post, we directly inject it here and
|
||||
# return it
|
||||
self.executor.plan.insert_stage(next_stage)
|
||||
return self.executor.stage_ok()
|
||||
stages = Stage.objects.filter(pk__in=stage.configuration_stages.all()).select_subclasses()
|
||||
self.request.session[SESSION_STAGES] = stages
|
||||
return super().get(self.request, *args, **kwargs)
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
res = super().post(request, *args, **kwargs)
|
||||
if (
|
||||
SESSION_SELECTED_STAGE in self.request.session
|
||||
and self.executor.current_stage.not_configured_action == NotConfiguredAction.CONFIGURE
|
||||
):
|
||||
LOGGER.debug("Got selected stage in session, running that")
|
||||
stage_pk = self.request.session.get(SESSION_SELECTED_STAGE)
|
||||
# Because the foreign key to stage.configuration_stage points to
|
||||
# a base stage class, we need to do another lookup
|
||||
stage = Stage.objects.get_subclass(pk=stage_pk)
|
||||
# plan.insert inserts at 1 index, so when stage_ok pops 0,
|
||||
# the configuration stage is next
|
||||
self.executor.plan.insert_stage(stage)
|
||||
return self.executor.stage_ok()
|
||||
return res
|
||||
|
||||
def get_challenge(self) -> AuthenticatorValidationChallenge:
|
||||
challenges = self.request.session["device_challenges"]
|
||||
challenges = self.request.session.get(SESSION_DEVICE_CHALLENGES, [])
|
||||
stages = self.request.session.get(SESSION_STAGES, [])
|
||||
stage_challenges = []
|
||||
for stage in stages:
|
||||
serializer = SelectableStageSerializer(
|
||||
data={
|
||||
"pk": stage.pk,
|
||||
"name": stage.name,
|
||||
"verbose_name": str(stage._meta.verbose_name),
|
||||
"meta_model_name": f"{stage._meta.app_label}.{stage._meta.model_name}",
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
stage_challenges.append(serializer.data)
|
||||
return AuthenticatorValidationChallenge(
|
||||
data={
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"device_challenges": challenges,
|
||||
"configuration_stages": stage_challenges,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -43,8 +43,8 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name="foo",
|
||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||
configuration_stage=conf_stage,
|
||||
)
|
||||
stage.configuration_stages.set([conf_stage])
|
||||
flow = Flow.objects.create(name="test", slug="test", title="test")
|
||||
FlowStageBinding.objects.create(target=flow, stage=conf_stage, order=0)
|
||||
FlowStageBinding.objects.create(target=flow, stage=stage, order=1)
|
||||
|
||||
@ -17,7 +17,7 @@ services:
|
||||
image: redis:alpine
|
||||
restart: unless-stopped
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.1.3}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.2.1}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -38,7 +38,7 @@ services:
|
||||
- "0.0.0.0:${AUTHENTIK_PORT_HTTP:-9000}:9000"
|
||||
- "0.0.0.0:${AUTHENTIK_PORT_HTTPS:-9443}:9443"
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.1.3}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.2.1}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
||||
14
go.mod
14
go.mod
@ -8,16 +8,16 @@ require (
|
||||
github.com/coreos/go-oidc v2.2.1+incompatible
|
||||
github.com/garyburd/redigo v1.6.2 // indirect
|
||||
github.com/getsentry/sentry-go v0.12.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.1
|
||||
github.com/go-openapi/runtime v0.21.1
|
||||
github.com/go-openapi/strfmt v0.21.1
|
||||
github.com/go-ldap/ldap/v3 v3.4.2
|
||||
github.com/go-openapi/runtime v0.23.0
|
||||
github.com/go-openapi/strfmt v0.21.2
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/imdario/mergo v0.3.12
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||
@ -25,10 +25,10 @@ require (
|
||||
github.com/pires/go-proxyproto v0.6.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pquerna/cachecontrol v0.0.0-20201205024021-ac21108117ac // indirect
|
||||
github.com/prometheus/client_golang v1.12.0
|
||||
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b // indirect
|
||||
github.com/prometheus/client_golang v1.12.1
|
||||
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/stretchr/testify v1.7.0 // indirect
|
||||
github.com/stretchr/testify v1.7.0
|
||||
goauthentik.io/api v0.2021125.1
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
|
||||
21
go.sum
21
go.sum
@ -125,8 +125,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.1 h1:fU/0xli6HY02ocbMuozHAYsaHLcnkLjvho2r5a34BUU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.1/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg=
|
||||
github.com/go-ldap/ldap/v3 v3.4.2 h1:zFZKcXKLqZpFMrMQGHeHWKXbDTdNCmhGY9AK41zPh+8=
|
||||
github.com/go-ldap/ldap/v3 v3.4.2/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
@ -183,8 +183,8 @@ github.com/go-openapi/runtime v0.19.4/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29g
|
||||
github.com/go-openapi/runtime v0.19.15/go.mod h1:dhGWCTKRXlAfGnQG0ONViOZpjfg0m2gUt9nTQPQZuoo=
|
||||
github.com/go-openapi/runtime v0.19.16/go.mod h1:5P9104EJgYcizotuXhEuUrzVc+j1RiSjahULvYmlv98=
|
||||
github.com/go-openapi/runtime v0.19.24/go.mod h1:Lm9YGCeecBnUUkFTxPC4s1+lwrkJ0pthx8YvyjCfkgk=
|
||||
github.com/go-openapi/runtime v0.21.1 h1:/KIG00BzA2x2HRStX2tnhbqbQdPcFlkgsYCiNY20FZs=
|
||||
github.com/go-openapi/runtime v0.21.1/go.mod h1:aQg+kaIQEn+A2CRSY1TxbM8+sT9g2V3aLc1FbIAnbbs=
|
||||
github.com/go-openapi/runtime v0.23.0 h1:HX6ET2sHCIvaKeDDQoU01CtO1ekg5EkekHSkLTtWXH0=
|
||||
github.com/go-openapi/runtime v0.23.0/go.mod h1:aQg+kaIQEn+A2CRSY1TxbM8+sT9g2V3aLc1FbIAnbbs=
|
||||
github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
|
||||
github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
|
||||
github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY=
|
||||
@ -208,8 +208,8 @@ github.com/go-openapi/strfmt v0.19.11/go.mod h1:UukAYgTaQfqJuAFlNxxMWNvMYiwiXtLs
|
||||
github.com/go-openapi/strfmt v0.20.0/go.mod h1:UukAYgTaQfqJuAFlNxxMWNvMYiwiXtLsF2VwmoFtbtc=
|
||||
github.com/go-openapi/strfmt v0.20.2/go.mod h1:43urheQI9dNtE5lTZQfuFJvjYJKPrxicATpEfZwHUNk=
|
||||
github.com/go-openapi/strfmt v0.21.0/go.mod h1:ZRQ409bWMj+SOgXofQAGTIo2Ebu72Gs+WaRADcS5iNg=
|
||||
github.com/go-openapi/strfmt v0.21.1 h1:G6s2t5V5kGCHLVbSdZ/6lI8Wm4OzoPFkc3/cjAsKQrM=
|
||||
github.com/go-openapi/strfmt v0.21.1/go.mod h1:I/XVKeLc5+MM5oPNN7P6urMOpuLXEcNrCX/rPGuWb0k=
|
||||
github.com/go-openapi/strfmt v0.21.2 h1:5NDNgadiX1Vhemth/TH4gCGopWSTdDjxl60H3B7f+os=
|
||||
github.com/go-openapi/strfmt v0.21.2/go.mod h1:I/XVKeLc5+MM5oPNN7P6urMOpuLXEcNrCX/rPGuWb0k=
|
||||
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
|
||||
github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
|
||||
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
@ -334,8 +334,8 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
@ -471,8 +471,8 @@ github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
|
||||
github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg=
|
||||
github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
|
||||
github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk=
|
||||
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
@ -517,7 +517,6 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
|
||||
@ -25,4 +25,4 @@ func OutpostUserAgent() string {
|
||||
return fmt.Sprintf("authentik-outpost@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2022.1.3"
|
||||
const VERSION = "2022.2.1"
|
||||
|
||||
@ -25,7 +25,7 @@ var (
|
||||
func RunServer() {
|
||||
m := mux.NewRouter()
|
||||
l := log.WithField("logger", "authentik.outpost.metrics")
|
||||
m.HandleFunc("/akprox/ping", func(rw http.ResponseWriter, r *http.Request) {
|
||||
m.HandleFunc("/outpost.goauthentik.io/ping", func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(204)
|
||||
})
|
||||
m.Path("/metrics").Handler(promhttp.Handler())
|
||||
|
||||
@ -46,6 +46,7 @@ type Application struct {
|
||||
|
||||
log *log.Entry
|
||||
mux *mux.Router
|
||||
ak *ak.APIController
|
||||
|
||||
errorTemplates *template.Template
|
||||
}
|
||||
@ -77,7 +78,7 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, cs *ak.CryptoStore
|
||||
oauth2Config := oauth2.Config{
|
||||
ClientID: *p.ClientId,
|
||||
ClientSecret: *p.ClientSecret,
|
||||
RedirectURL: urlJoin(p.ExternalHost, "/akprox/callback"),
|
||||
RedirectURL: urlJoin(p.ExternalHost, "/outpost.goauthentik.io/callback"),
|
||||
Endpoint: endpoint.Endpoint,
|
||||
Scopes: p.ScopesToRequest,
|
||||
}
|
||||
@ -93,6 +94,7 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, cs *ak.CryptoStore
|
||||
httpClient: c,
|
||||
mux: mux,
|
||||
errorTemplates: templates.GetTemplates(),
|
||||
ak: ak,
|
||||
}
|
||||
a.sessions = a.getStore(p)
|
||||
mux.Use(web.NewLoggingHandler(muxLogger, func(l *log.Entry, r *http.Request) *log.Entry {
|
||||
@ -143,10 +145,10 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, cs *ak.CryptoStore
|
||||
mux.Use(sentryhttp.New(sentryhttp.Options{}).Handle)
|
||||
|
||||
// Support /start and /sign_in for backwards compatibility
|
||||
mux.HandleFunc("/akprox/start", a.handleRedirect)
|
||||
mux.HandleFunc("/akprox/sign_in", a.handleRedirect)
|
||||
mux.HandleFunc("/akprox/callback", a.handleCallback)
|
||||
mux.HandleFunc("/akprox/sign_out", a.handleSignOut)
|
||||
mux.HandleFunc("/outpost.goauthentik.io/start", a.handleRedirect)
|
||||
mux.HandleFunc("/outpost.goauthentik.io/sign_in", a.handleRedirect)
|
||||
mux.HandleFunc("/outpost.goauthentik.io/callback", a.handleCallback)
|
||||
mux.HandleFunc("/outpost.goauthentik.io/sign_out", a.handleSignOut)
|
||||
switch *p.Mode {
|
||||
case api.PROXYMODE_PROXY:
|
||||
err = a.configureProxy()
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
package application
|
||||
|
||||
type ProxyClaims struct {
|
||||
UserAttributes map[string]interface{} `json:"user_attributes"`
|
||||
UserAttributes map[string]interface{} `json:"user_attributes"`
|
||||
BackendOverride string `json:"backend_override"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Exp int `json:"exp"`
|
||||
Email string `json:"email"`
|
||||
Verified bool `json:"email_verified"`
|
||||
Proxy ProxyClaims `json:"ak_proxy"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Groups []string `json:"groups"`
|
||||
Sub string `json:"sub"`
|
||||
Exp int `json:"exp"`
|
||||
Email string `json:"email"`
|
||||
Verified bool `json:"email_verified"`
|
||||
Proxy *ProxyClaims `json:"ak_proxy"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Groups []string `json:"groups"`
|
||||
|
||||
RawToken string
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ func (a *Application) ErrorPage(rw http.ResponseWriter, r *http.Request, err str
|
||||
data := ErrorPageData{
|
||||
Title: "Bad Gateway",
|
||||
Message: "Error proxying to upstream server",
|
||||
ProxyPrefix: "/akprox",
|
||||
ProxyPrefix: "/outpost.goauthentik.io",
|
||||
}
|
||||
if claims != nil && len(err) > 0 {
|
||||
data.Message = err
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
@ -59,7 +60,7 @@ func (a *Application) addHeaders(headers http.Header, c *Claims) {
|
||||
}
|
||||
|
||||
// getTraefikForwardUrl See https://doc.traefik.io/traefik/middlewares/forwardauth/
|
||||
func (a *Application) getTraefikForwardUrl(r *http.Request) *url.URL {
|
||||
func (a *Application) getTraefikForwardUrl(r *http.Request) (*url.URL, error) {
|
||||
u, err := url.Parse(fmt.Sprintf(
|
||||
"%s://%s%s",
|
||||
r.Header.Get("X-Forwarded-Proto"),
|
||||
@ -67,27 +68,51 @@ func (a *Application) getTraefikForwardUrl(r *http.Request) *url.URL {
|
||||
r.Header.Get("X-Forwarded-Uri"),
|
||||
))
|
||||
if err != nil {
|
||||
a.log.WithError(err).Warning("Failed to parse URL from traefik")
|
||||
return r.URL
|
||||
return nil, err
|
||||
}
|
||||
a.log.WithField("url", u.String()).Trace("traefik forwarded url")
|
||||
return u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// getNginxForwardUrl See https://github.com/kubernetes/ingress-nginx/blob/main/rootfs/etc/nginx/template/nginx.tmpl
|
||||
func (a *Application) getNginxForwardUrl(r *http.Request) *url.URL {
|
||||
func (a *Application) getNginxForwardUrl(r *http.Request) (*url.URL, error) {
|
||||
ou := r.Header.Get("X-Original-URI")
|
||||
if ou != "" {
|
||||
// Turn this full URL into a relative URL
|
||||
u := &url.URL{
|
||||
Host: "",
|
||||
Scheme: "",
|
||||
Path: ou,
|
||||
}
|
||||
a.log.WithField("url", u.String()).Info("building forward URL from X-Original-URI")
|
||||
return u, nil
|
||||
}
|
||||
h := r.Header.Get("X-Original-URL")
|
||||
if len(h) < 1 {
|
||||
a.log.WithError(errors.New("blank URL")).Warning("blank URL")
|
||||
return r.URL
|
||||
return nil, errors.New("no forward URL found")
|
||||
}
|
||||
u, err := url.Parse(h)
|
||||
if err != nil {
|
||||
a.log.WithError(err).Warning("failed to parse URL from nginx")
|
||||
return r.URL
|
||||
return nil, err
|
||||
}
|
||||
a.log.WithField("url", u.String()).Trace("nginx forwarded url")
|
||||
return u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (a *Application) ReportMisconfiguration(r *http.Request, msg string, fields map[string]interface{}) {
|
||||
fields["message"] = msg
|
||||
a.log.WithFields(fields).Error("Reporting configuration error")
|
||||
req := api.EventRequest{
|
||||
Action: api.EVENTACTIONS_CONFIGURATION_ERROR,
|
||||
App: "authentik.providers.proxy", // must match python apps.py name
|
||||
ClientIp: *api.NewNullableString(api.PtrString(r.RemoteAddr)),
|
||||
Context: &fields,
|
||||
}
|
||||
_, _, err := a.ak.Client.EventsApi.EventsEventsCreate(context.Background()).EventRequest(req).Execute()
|
||||
if err != nil {
|
||||
a.log.WithError(err).Warning("failed to report configuration error")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Application) IsAllowlisted(u *url.URL) bool {
|
||||
|
||||
@ -12,21 +12,33 @@ import (
|
||||
)
|
||||
|
||||
func (a *Application) configureForward() error {
|
||||
a.mux.HandleFunc("/akprox/auth", func(rw http.ResponseWriter, r *http.Request) {
|
||||
a.mux.HandleFunc("/outpost.goauthentik.io/auth", func(rw http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := r.URL.Query()["traefik"]; ok {
|
||||
a.forwardHandleTraefik(rw, r)
|
||||
return
|
||||
}
|
||||
a.forwardHandleNginx(rw, r)
|
||||
})
|
||||
a.mux.HandleFunc("/akprox/auth/traefik", a.forwardHandleTraefik)
|
||||
a.mux.HandleFunc("/akprox/auth/nginx", a.forwardHandleNginx)
|
||||
a.mux.HandleFunc("/outpost.goauthentik.io/auth/traefik", a.forwardHandleTraefik)
|
||||
a.mux.HandleFunc("/outpost.goauthentik.io/auth/nginx", a.forwardHandleNginx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Request) {
|
||||
a.log.WithField("header", r.Header).Trace("tracing headers for debug")
|
||||
fwd := a.getTraefikForwardUrl(r)
|
||||
// First check if we've got everything we need
|
||||
fwd, err := a.getTraefikForwardUrl(r)
|
||||
if err != nil {
|
||||
a.ReportMisconfiguration(r, fmt.Sprintf("Outpost %s (Provider %s) failed to detect a forward URL from Traefik", a.outpostName, a.proxyConfig.Name), map[string]interface{}{
|
||||
"provider": a.proxyConfig.Name,
|
||||
"outpost": a.outpostName,
|
||||
"url": r.URL.String(),
|
||||
"headers": cleanseHeaders(r.Header),
|
||||
})
|
||||
http.Error(rw, "configuration error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := a.getClaims(r)
|
||||
if claims != nil && err == nil {
|
||||
a.addHeaders(rw.Header(), claims)
|
||||
@ -37,8 +49,8 @@ func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Reque
|
||||
a.log.Trace("path can be accessed without authentication")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.Header.Get("X-Forwarded-Uri"), "/akprox") {
|
||||
a.log.WithField("url", r.URL.String()).Trace("path begins with /akprox, allowing access")
|
||||
if strings.HasPrefix(r.Header.Get("X-Forwarded-Uri"), "/outpost.goauthentik.io") {
|
||||
a.log.WithField("url", r.URL.String()).Trace("path begins with /outpost.goauthentik.io, allowing access")
|
||||
return
|
||||
}
|
||||
host := ""
|
||||
@ -68,14 +80,25 @@ func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Reque
|
||||
if proto != "" {
|
||||
proto = proto + ":"
|
||||
}
|
||||
rdFinal := fmt.Sprintf("%s//%s%s", proto, host, "/akprox/start")
|
||||
rdFinal := fmt.Sprintf("%s//%s%s", proto, host, "/outpost.goauthentik.io/start")
|
||||
a.log.WithField("url", rdFinal).Debug("Redirecting to login")
|
||||
http.Redirect(rw, r, rdFinal, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (a *Application) forwardHandleNginx(rw http.ResponseWriter, r *http.Request) {
|
||||
a.log.WithField("header", r.Header).Trace("tracing headers for debug")
|
||||
fwd := a.getNginxForwardUrl(r)
|
||||
fwd, err := a.getNginxForwardUrl(r)
|
||||
if err != nil {
|
||||
a.ReportMisconfiguration(r, fmt.Sprintf("Outpost %s (Provider %s) failed to detect a forward URL from nginx", a.outpostName, a.proxyConfig.Name), map[string]interface{}{
|
||||
"provider": a.proxyConfig.Name,
|
||||
"outpost": a.outpostName,
|
||||
"url": r.URL.String(),
|
||||
"headers": cleanseHeaders(r.Header),
|
||||
})
|
||||
http.Error(rw, "configuration error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := a.getClaims(r)
|
||||
if claims != nil && err == nil {
|
||||
a.addHeaders(rw.Header(), claims)
|
||||
@ -96,8 +119,8 @@ func (a *Application) forwardHandleNginx(rw http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
if fwd.String() != r.URL.String() {
|
||||
if strings.HasPrefix(fwd.Path, "/akprox") {
|
||||
a.log.WithField("url", r.URL.String()).Trace("path begins with /akprox, allowing access")
|
||||
if strings.HasPrefix(fwd.Path, "/outpost.goauthentik.io") {
|
||||
a.log.WithField("url", r.URL.String()).Trace("path begins with /outpost.goauthentik.io, allowing access")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,17 +12,17 @@ import (
|
||||
|
||||
func TestForwardHandleNginx_Single_Blank(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/nginx", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.forwardHandleNginx(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code)
|
||||
}
|
||||
|
||||
func TestForwardHandleNginx_Single_Skip(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/nginx", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
|
||||
req.Header.Set("X-Original-URL", "http://test.goauthentik.io/skip")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@ -33,7 +33,7 @@ func TestForwardHandleNginx_Single_Skip(t *testing.T) {
|
||||
|
||||
func TestForwardHandleNginx_Single_Headers(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/nginx", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
|
||||
req.Header.Set("X-Original-URL", "http://test.goauthentik.io/app")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@ -45,9 +45,24 @@ func TestForwardHandleNginx_Single_Headers(t *testing.T) {
|
||||
assert.Equal(t, "http://test.goauthentik.io/app", s.Values[constants.SessionRedirect])
|
||||
}
|
||||
|
||||
func TestForwardHandleNginx_Single_URI(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "https://foo.bar/outpost.goauthentik.io/auth/nginx", nil)
|
||||
req.Header.Set("X-Original-URI", "/app")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.forwardHandleNginx(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Code, http.StatusUnauthorized)
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
assert.Equal(t, "/app", s.Values[constants.SessionRedirect])
|
||||
}
|
||||
|
||||
func TestForwardHandleNginx_Single_Claims(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/nginx", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
|
||||
req.Header.Set("X-Original-URI", "/")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.forwardHandleNginx(rr, req)
|
||||
@ -55,7 +70,7 @@ func TestForwardHandleNginx_Single_Claims(t *testing.T) {
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
s.Values[constants.SessionClaims] = Claims{
|
||||
Sub: "foo",
|
||||
Proxy: ProxyClaims{
|
||||
Proxy: &ProxyClaims{
|
||||
UserAttributes: map[string]interface{}{
|
||||
"username": "foo",
|
||||
"password": "bar",
|
||||
@ -93,15 +108,12 @@ func TestForwardHandleNginx_Domain_Blank(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
|
||||
a.proxyConfig.CookieDomain = api.PtrString("foo")
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/nginx", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.forwardHandleNginx(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
assert.Equal(t, "/akprox/auth/nginx", s.Values[constants.SessionRedirect])
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code)
|
||||
}
|
||||
|
||||
func TestForwardHandleNginx_Domain_Header(t *testing.T) {
|
||||
@ -109,7 +121,7 @@ func TestForwardHandleNginx_Domain_Header(t *testing.T) {
|
||||
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
|
||||
a.proxyConfig.CookieDomain = api.PtrString("foo")
|
||||
a.proxyConfig.ExternalHost = "http://auth.test.goauthentik.io"
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/nginx", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
|
||||
req.Header.Set("X-Original-URL", "http://test.goauthentik.io/app")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@ -12,23 +12,17 @@ import (
|
||||
|
||||
func TestForwardHandleTraefik_Single_Blank(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/traefik", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.forwardHandleTraefik(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
|
||||
loc, _ := rr.Result().Location()
|
||||
assert.Equal(t, "/akprox/start", loc.String())
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
// Since we're not setting the traefik specific headers, expect it to redirect to the auth URL
|
||||
assert.Equal(t, "/akprox/auth/traefik", s.Values[constants.SessionRedirect])
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code)
|
||||
}
|
||||
|
||||
func TestForwardHandleTraefik_Single_Skip(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/traefik", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
|
||||
req.Header.Set("X-Forwarded-Uri", "/skip")
|
||||
@ -41,7 +35,7 @@ func TestForwardHandleTraefik_Single_Skip(t *testing.T) {
|
||||
|
||||
func TestForwardHandleTraefik_Single_Headers(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/traefik", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
|
||||
req.Header.Set("X-Forwarded-Uri", "/app")
|
||||
@ -51,7 +45,7 @@ func TestForwardHandleTraefik_Single_Headers(t *testing.T) {
|
||||
|
||||
assert.Equal(t, rr.Code, http.StatusTemporaryRedirect)
|
||||
loc, _ := rr.Result().Location()
|
||||
assert.Equal(t, loc.String(), "http://test.goauthentik.io/akprox/start")
|
||||
assert.Equal(t, loc.String(), "http://test.goauthentik.io/outpost.goauthentik.io/start")
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
assert.Equal(t, "http://test.goauthentik.io/app", s.Values[constants.SessionRedirect])
|
||||
@ -59,7 +53,10 @@ func TestForwardHandleTraefik_Single_Headers(t *testing.T) {
|
||||
|
||||
func TestForwardHandleTraefik_Single_Claims(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/traefik", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
|
||||
req.Header.Set("X-Forwarded-Uri", "/app")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.forwardHandleTraefik(rr, req)
|
||||
@ -67,7 +64,7 @@ func TestForwardHandleTraefik_Single_Claims(t *testing.T) {
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
s.Values[constants.SessionClaims] = Claims{
|
||||
Sub: "foo",
|
||||
Proxy: ProxyClaims{
|
||||
Proxy: &ProxyClaims{
|
||||
UserAttributes: map[string]interface{}{
|
||||
"username": "foo",
|
||||
"password": "bar",
|
||||
@ -105,18 +102,12 @@ func TestForwardHandleTraefik_Domain_Blank(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
|
||||
a.proxyConfig.CookieDomain = api.PtrString("foo")
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/traefik", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.forwardHandleTraefik(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
|
||||
loc, _ := rr.Result().Location()
|
||||
assert.Equal(t, "/akprox/start", loc.String())
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
// Since we're not setting the traefik specific headers, expect it to redirect to the auth URL
|
||||
assert.Equal(t, "/akprox/auth/traefik", s.Values[constants.SessionRedirect])
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code)
|
||||
}
|
||||
|
||||
func TestForwardHandleTraefik_Domain_Header(t *testing.T) {
|
||||
@ -124,7 +115,7 @@ func TestForwardHandleTraefik_Domain_Header(t *testing.T) {
|
||||
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
|
||||
a.proxyConfig.CookieDomain = api.PtrString("foo")
|
||||
a.proxyConfig.ExternalHost = "http://auth.test.goauthentik.io"
|
||||
req, _ := http.NewRequest("GET", "/akprox/auth/traefik", nil)
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
|
||||
req.Header.Set("X-Forwarded-Uri", "/app")
|
||||
@ -134,7 +125,7 @@ func TestForwardHandleTraefik_Domain_Header(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
|
||||
loc, _ := rr.Result().Location()
|
||||
assert.Equal(t, "http://auth.test.goauthentik.io/akprox/start", loc.String())
|
||||
assert.Equal(t, "http://auth.test.goauthentik.io/outpost.goauthentik.io/start", loc.String())
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
assert.Equal(t, "http://test.goauthentik.io/app", s.Values[constants.SessionRedirect])
|
||||
|
||||
@ -60,7 +60,7 @@ func (a *Application) configureProxy() error {
|
||||
}
|
||||
metrics.UpstreamTiming.With(prometheus.Labels{
|
||||
"outpost_name": a.outpostName,
|
||||
"upstream_host": u.String(),
|
||||
"upstream_host": r.URL.Host,
|
||||
"scheme": r.URL.Scheme,
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
@ -71,10 +71,22 @@ func (a *Application) configureProxy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Application) proxyModifyRequest(u *url.URL) func(req *http.Request) {
|
||||
return func(req *http.Request) {
|
||||
req.URL.Scheme = u.Scheme
|
||||
req.URL.Host = u.Host
|
||||
func (a *Application) proxyModifyRequest(ou *url.URL) func(req *http.Request) {
|
||||
return func(r *http.Request) {
|
||||
r.Header.Set("X-Forwarded-Host", r.Host)
|
||||
claims, _ := a.getClaims(r)
|
||||
r.URL.Scheme = ou.Scheme
|
||||
r.URL.Host = ou.Host
|
||||
if claims != nil && claims.Proxy != nil && claims.Proxy.BackendOverride != "" {
|
||||
u, err := url.Parse(claims.Proxy.BackendOverride)
|
||||
if err != nil {
|
||||
a.log.WithField("backend_override", claims.Proxy.BackendOverride).WithError(err).Warning("failed parse user backend override")
|
||||
} else {
|
||||
r.URL.Scheme = u.Scheme
|
||||
r.URL.Host = u.Host
|
||||
}
|
||||
}
|
||||
a.log.WithField("upstream_url", r.URL.String()).Trace("final upstream url")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
82
internal/outpost/proxyv2/application/mode_proxy_test.go
Normal file
82
internal/outpost/proxyv2/application/mode_proxy_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"goauthentik.io/internal/outpost/proxyv2/constants"
|
||||
)
|
||||
|
||||
func TestProxy_ModifyRequest(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "http://frontend/foo", nil)
|
||||
u, err := url.Parse("http://backend:8012")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
a.proxyModifyRequest(u)(req)
|
||||
|
||||
assert.Equal(t, "frontend", req.Header.Get("X-Forwarded-Host"))
|
||||
assert.Equal(t, "/foo", req.URL.Path)
|
||||
assert.Equal(t, "backend:8012", req.URL.Host)
|
||||
assert.Equal(t, "frontend", req.Host)
|
||||
}
|
||||
|
||||
func TestProxy_ModifyRequest_Claims(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "http://frontend/foo", nil)
|
||||
u, err := url.Parse("http://backend:8012")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
s.Values[constants.SessionClaims] = Claims{
|
||||
Sub: "foo",
|
||||
Proxy: &ProxyClaims{
|
||||
BackendOverride: "http://other-backend:8123",
|
||||
},
|
||||
}
|
||||
err = a.sessions.Save(req, rr, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
a.proxyModifyRequest(u)(req)
|
||||
|
||||
assert.Equal(t, "/foo", req.URL.Path)
|
||||
assert.Equal(t, "other-backend:8123", req.URL.Host)
|
||||
assert.Equal(t, "frontend", req.Host)
|
||||
}
|
||||
|
||||
func TestProxy_ModifyRequest_Claims_Invalid(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "http://frontend/foo", nil)
|
||||
u, err := url.Parse("http://backend:8012")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
s.Values[constants.SessionClaims] = Claims{
|
||||
Sub: "foo",
|
||||
Proxy: &ProxyClaims{
|
||||
BackendOverride: ":qewr",
|
||||
},
|
||||
}
|
||||
err = a.sessions.Save(req, rr, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
a.proxyModifyRequest(u)(req)
|
||||
|
||||
assert.Equal(t, "/foo", req.URL.Path)
|
||||
assert.Equal(t, "backend:8012", req.URL.Host)
|
||||
assert.Equal(t, "frontend", req.Host)
|
||||
}
|
||||
@ -3,14 +3,46 @@ package application
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/securecookie"
|
||||
"goauthentik.io/api"
|
||||
"goauthentik.io/internal/outpost/proxyv2/constants"
|
||||
)
|
||||
|
||||
const (
|
||||
redirectParam = "rd"
|
||||
)
|
||||
|
||||
func (a *Application) checkRedirectParam(r *http.Request) (string, bool) {
|
||||
rd := r.URL.Query().Get(redirectParam)
|
||||
if rd == "" {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(rd)
|
||||
if err != nil {
|
||||
a.log.WithError(err).Warning("Failed to parse redirect URL")
|
||||
return "", false
|
||||
}
|
||||
// Check to make sure we only redirect to allowed places
|
||||
if a.Mode() == api.PROXYMODE_PROXY || a.Mode() == api.PROXYMODE_FORWARD_SINGLE {
|
||||
if !strings.Contains(u.String(), a.proxyConfig.ExternalHost) {
|
||||
a.log.WithField("url", u.String()).WithField("ext", a.proxyConfig.ExternalHost).Warning("redirect URI did not contain external host")
|
||||
return "", false
|
||||
}
|
||||
} else {
|
||||
if !strings.HasSuffix(u.Host, *a.proxyConfig.CookieDomain) {
|
||||
a.log.WithField("host", u.Host).WithField("dom", *a.proxyConfig.CookieDomain).Warning("redirect URI Host was not included in cookie domain")
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return u.String(), true
|
||||
}
|
||||
|
||||
func (a *Application) handleRedirect(rw http.ResponseWriter, r *http.Request) {
|
||||
newState := base64.RawStdEncoding.EncodeToString(securecookie.GenerateRandomKey(32))
|
||||
newState := base64.RawURLEncoding.EncodeToString(securecookie.GenerateRandomKey(32))
|
||||
s, err := a.sessions.Get(r, constants.SeesionName)
|
||||
if err != nil {
|
||||
s.Values[constants.SessionOAuthState] = []string{}
|
||||
@ -20,6 +52,11 @@ func (a *Application) handleRedirect(rw http.ResponseWriter, r *http.Request) {
|
||||
s.Values[constants.SessionOAuthState] = []string{}
|
||||
state = []string{}
|
||||
}
|
||||
rd, ok := a.checkRedirectParam(r)
|
||||
if ok {
|
||||
s.Values[constants.SessionRedirect] = rd
|
||||
a.log.WithField("rd", rd).Trace("Setting redirect")
|
||||
}
|
||||
s.Values[constants.SessionOAuthState] = append(state, newState)
|
||||
err = s.Save(r, rw)
|
||||
if err != nil {
|
||||
@ -29,7 +66,10 @@ func (a *Application) handleRedirect(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *Application) handleCallback(rw http.ResponseWriter, r *http.Request) {
|
||||
s, _ := a.sessions.Get(r, constants.SeesionName)
|
||||
s, err := a.sessions.Get(r, constants.SeesionName)
|
||||
if err != nil {
|
||||
a.log.WithError(err).Trace("failed to get session")
|
||||
}
|
||||
state, ok := s.Values[constants.SessionOAuthState]
|
||||
if !ok {
|
||||
a.log.Warning("No state saved in session")
|
||||
@ -62,8 +102,8 @@ func (a *Application) handleCallback(rw http.ResponseWriter, r *http.Request) {
|
||||
redirect := a.proxyConfig.ExternalHost
|
||||
redirectR, ok := s.Values[constants.SessionRedirect]
|
||||
if ok {
|
||||
a.log.WithField("redirect", redirectR).Trace("got final redirect from session")
|
||||
redirect = redirectR.(string)
|
||||
}
|
||||
a.log.WithField("redirect", redirect).Trace("final redirect")
|
||||
http.Redirect(rw, r, redirect, http.StatusFound)
|
||||
}
|
||||
|
||||
51
internal/outpost/proxyv2/application/oauth_test.go
Normal file
51
internal/outpost/proxyv2/application/oauth_test.go
Normal file
@ -0,0 +1,51 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"goauthentik.io/api"
|
||||
)
|
||||
|
||||
func TestCheckRedirectParam(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start", nil)
|
||||
|
||||
rd, ok := a.checkRedirectParam(req)
|
||||
|
||||
assert.Equal(t, false, ok)
|
||||
assert.Equal(t, "", rd)
|
||||
|
||||
req, _ = http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://google.com", nil)
|
||||
|
||||
rd, ok = a.checkRedirectParam(req)
|
||||
|
||||
assert.Equal(t, false, ok)
|
||||
assert.Equal(t, "", rd)
|
||||
|
||||
req, _ = http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://ext.t.goauthentik.io/test", nil)
|
||||
|
||||
rd, ok = a.checkRedirectParam(req)
|
||||
|
||||
assert.Equal(t, true, ok)
|
||||
assert.Equal(t, "https://ext.t.goauthentik.io/test", rd)
|
||||
}
|
||||
|
||||
func TestCheckRedirectParam_Domain(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
|
||||
a.proxyConfig.CookieDomain = api.PtrString("t.goauthentik.io")
|
||||
req, _ := http.NewRequest("GET", "https://a.t.goauthentik.io/outpost.goauthentik.io/auth/start", nil)
|
||||
|
||||
rd, ok := a.checkRedirectParam(req)
|
||||
|
||||
assert.Equal(t, false, ok)
|
||||
assert.Equal(t, "", rd)
|
||||
req, _ = http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://ext.t.goauthentik.io/test", nil)
|
||||
|
||||
rd, ok = a.checkRedirectParam(req)
|
||||
|
||||
assert.Equal(t, true, ok)
|
||||
assert.Equal(t, "https://ext.t.goauthentik.io/test", rd)
|
||||
}
|
||||
@ -15,6 +15,7 @@ func newTestApplication() *Application {
|
||||
ClientId: api.PtrString(ak.TestSecret()),
|
||||
ClientSecret: api.PtrString(ak.TestSecret()),
|
||||
CookieSecret: api.PtrString(ak.TestSecret()),
|
||||
ExternalHost: "https://ext.t.goauthentik.io",
|
||||
CookieDomain: api.PtrString(""),
|
||||
Mode: api.PROXYMODE_FORWARD_SINGLE.Ptr(),
|
||||
SkipPathRegex: api.PtrString("/skip.*"),
|
||||
|
||||
@ -42,7 +42,7 @@ func (a *Application) redirectToStart(rw http.ResponseWriter, r *http.Request) {
|
||||
a.log.WithError(err).Warning("failed to save session before redirect")
|
||||
}
|
||||
|
||||
authUrl := urlJoin(a.proxyConfig.ExternalHost, "/akprox/start")
|
||||
authUrl := urlJoin(a.proxyConfig.ExternalHost, "/outpost.goauthentik.io/start")
|
||||
http.Redirect(rw, r, authUrl, http.StatusFound)
|
||||
}
|
||||
|
||||
@ -87,3 +87,13 @@ func contains(s []string, e string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cleanseHeaders(headers http.Header) map[string]string {
|
||||
h := make(map[string]string)
|
||||
for hk, hv := range headers {
|
||||
if len(hv) > 0 {
|
||||
h[hk] = hv[0]
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ func TestRedirectToStart_Proxy(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusFound, rr.Code)
|
||||
loc, _ := rr.Result().Location()
|
||||
assert.Equal(t, "https://test.goauthentik.io/akprox/start", loc.String())
|
||||
assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start", loc.String())
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
assert.Equal(t, "https://test.goauthentik.io/foo/bar/baz", s.Values[constants.SessionRedirect])
|
||||
@ -38,7 +38,7 @@ func TestRedirectToStart_Forward(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusFound, rr.Code)
|
||||
loc, _ := rr.Result().Location()
|
||||
assert.Equal(t, "https://test.goauthentik.io/akprox/start", loc.String())
|
||||
assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start", loc.String())
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
assert.Equal(t, "https://test.goauthentik.io/foo/bar/baz", s.Values[constants.SessionRedirect])
|
||||
@ -56,7 +56,7 @@ func TestRedirectToStart_Forward_Domain_Invalid(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusFound, rr.Code)
|
||||
loc, _ := rr.Result().Location()
|
||||
assert.Equal(t, "https://test.goauthentik.io/akprox/start", loc.String())
|
||||
assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start", loc.String())
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
assert.Equal(t, "https://test.goauthentik.io", s.Values[constants.SessionRedirect])
|
||||
@ -74,7 +74,7 @@ func TestRedirectToStart_Forward_Domain(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusFound, rr.Code)
|
||||
loc, _ := rr.Result().Location()
|
||||
assert.Equal(t, "https://test.goauthentik.io/akprox/start", loc.String())
|
||||
assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start", loc.String())
|
||||
|
||||
s, _ := a.sessions.Get(req, constants.SeesionName)
|
||||
assert.Equal(t, "https://test.goauthentik.io", s.Values[constants.SessionRedirect])
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"goauthentik.io/api"
|
||||
"goauthentik.io/internal/outpost/proxyv2/application"
|
||||
"goauthentik.io/internal/outpost/proxyv2/metrics"
|
||||
"goauthentik.io/internal/utils/web"
|
||||
@ -31,7 +32,7 @@ func (ps *ProxyServer) HandlePing(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (ps *ProxyServer) HandleStatic(rw http.ResponseWriter, r *http.Request) {
|
||||
before := time.Now()
|
||||
web.DisableIndex(http.StripPrefix("/akprox/static/dist", staticWeb.StaticHandler)).ServeHTTP(rw, r)
|
||||
web.DisableIndex(http.StripPrefix("/outpost.goauthentik.io/static/dist", staticWeb.StaticHandler)).ServeHTTP(rw, r)
|
||||
after := time.Since(before)
|
||||
metrics.Requests.With(prometheus.Labels{
|
||||
"outpost_name": ps.akAPI.Outpost.Name,
|
||||
@ -58,6 +59,9 @@ func (ps *ProxyServer) lookupApp(r *http.Request) (*application.Application, str
|
||||
var longestMatch *application.Application
|
||||
longestMatchLength := 0
|
||||
for _, app := range ps.apps {
|
||||
if app.Mode() != api.PROXYMODE_FORWARD_DOMAIN {
|
||||
continue
|
||||
}
|
||||
// Check if the cookie domain has a leading period for a wildcard
|
||||
// This will decrease the weight of a wildcard domain, but a request to example.com
|
||||
// with the cookie domain set to example.com will still be routed correctly.
|
||||
@ -70,6 +74,11 @@ func (ps *ProxyServer) lookupApp(r *http.Request) (*application.Application, str
|
||||
}
|
||||
longestMatch = app
|
||||
longestMatchLength = len(cd)
|
||||
// Also for forward_auth_domain, we need to respond on the external domain
|
||||
if app.ProxyConfig().ExternalHost == host {
|
||||
ps.log.WithField("host", host).WithField("app", app.ProxyConfig().Name).Debug("Found app based on external_host")
|
||||
return app, host
|
||||
}
|
||||
}
|
||||
// Check if our longes match is 0, in which case we didn't match, so we
|
||||
// manually return no app
|
||||
@ -81,11 +90,11 @@ func (ps *ProxyServer) lookupApp(r *http.Request) (*application.Application, str
|
||||
}
|
||||
|
||||
func (ps *ProxyServer) Handle(rw http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/akprox/static") {
|
||||
if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/static") {
|
||||
ps.HandleStatic(rw, r)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/akprox/ping") {
|
||||
if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/ping") {
|
||||
ps.HandlePing(rw, r)
|
||||
return
|
||||
}
|
||||
@ -100,6 +109,7 @@ func (ps *ProxyServer) Handle(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
ps.log.WithField("headers", r.Header).Trace("tracing headers for no hostname match")
|
||||
ps.log.WithField("host", host).Warning("no app for hostname")
|
||||
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@ -25,7 +25,7 @@ var (
|
||||
func RunServer() {
|
||||
m := mux.NewRouter()
|
||||
l := log.WithField("logger", "authentik.outpost.metrics")
|
||||
m.HandleFunc("/akprox/ping", func(rw http.ResponseWriter, r *http.Request) {
|
||||
m.HandleFunc("/outpost.goauthentik.io/ping", func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(204)
|
||||
})
|
||||
m.Path("/metrics").Handler(promhttp.Handler())
|
||||
|
||||
@ -64,8 +64,8 @@ func NewProxyServer(ac *ak.APIController, portOffset int) *ProxyServer {
|
||||
akAPI: ac,
|
||||
defaultCert: defaultCert,
|
||||
}
|
||||
globalMux.PathPrefix("/akprox/static").HandlerFunc(s.HandleStatic)
|
||||
globalMux.Path("/akprox/ping").HandlerFunc(s.HandlePing)
|
||||
globalMux.PathPrefix("/outpost.goauthentik.io/static").HandlerFunc(s.HandleStatic)
|
||||
globalMux.Path("/outpost.goauthentik.io/ping").HandlerFunc(s.HandlePing)
|
||||
rootMux.PathPrefix("/").HandlerFunc(s.Handle)
|
||||
return s
|
||||
}
|
||||
@ -102,7 +102,11 @@ func (ps *ProxyServer) GetCertificate(serverName string) *tls.Certificate {
|
||||
}
|
||||
|
||||
func (ps *ProxyServer) getCertificates(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
appCert := ps.GetCertificate(info.ServerName)
|
||||
sn := info.ServerName
|
||||
if sn == "" {
|
||||
return &ps.defaultCert, nil
|
||||
}
|
||||
appCert := ps.GetCertificate(sn)
|
||||
if appCert == nil {
|
||||
return &ps.defaultCert, nil
|
||||
}
|
||||
|
||||
@ -5,12 +5,13 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="shortcut icon" type="image/png" href="/akprox/static/dist/assets/icons/icon.png">
|
||||
<link rel="stylesheet" type="text/css" href="/akprox/static/dist/patternfly.min.css">
|
||||
<link rel="stylesheet" type="text/css" href="/akprox/static/dist/authentik.css">
|
||||
<link rel="shortcut icon" type="image/png" href="/outpost.goauthentik.io/static/dist/assets/icons/icon.png">
|
||||
<link rel="stylesheet" type="text/css" href="/outpost.goauthentik.io/static/dist/patternfly.min.css">
|
||||
<link rel="stylesheet" type="text/css" href="/outpost.goauthentik.io/static/dist/authentik.css">
|
||||
<link rel="stylesheet" type="text/css" href="/outpost.goauthentik.io/static/dist/custom.css">
|
||||
<style>
|
||||
.pf-c-background-image::before {
|
||||
--ak-flow-background: url("/akprox/static/dist/assets/images/flow_background.jpg");
|
||||
--ak-flow-background: url("/outpost.goauthentik.io/static/dist/assets/images/flow_background.jpg");
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@ -32,7 +33,7 @@
|
||||
<div class="ak-login-container">
|
||||
<header class="pf-c-login__header">
|
||||
<div class="pf-c-brand ak-brand">
|
||||
<img src="/akprox/static/dist/assets/icons/icon_left_brand.svg" alt="authentik icon" />
|
||||
<img src="/outpost.goauthentik.io/static/dist/assets/icons/icon_left_brand.svg" alt="authentik icon" />
|
||||
</div>
|
||||
</header>
|
||||
<main class="pf-c-login__main">
|
||||
|
||||
@ -3,7 +3,8 @@ package templates
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
//go:embed error.html
|
||||
|
||||
@ -99,14 +99,14 @@ func (h loggingHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
h.handler.ServeHTTP(responseLogger, req)
|
||||
duration := float64(time.Since(t)) / float64(time.Millisecond)
|
||||
h.afterHandler(h.logger.WithFields(log.Fields{
|
||||
"remote": req.RemoteAddr,
|
||||
"host": GetHost(req),
|
||||
"request_protocol": req.Proto,
|
||||
"runtime": fmt.Sprintf("%0.3f", duration),
|
||||
"method": req.Method,
|
||||
"size": responseLogger.Size(),
|
||||
"status": responseLogger.Status(),
|
||||
"upstream": responseLogger.upstream,
|
||||
"request_useragent": req.UserAgent(),
|
||||
"remote": req.RemoteAddr,
|
||||
"host": GetHost(req),
|
||||
"runtime": fmt.Sprintf("%0.3f", duration),
|
||||
"method": req.Method,
|
||||
"scheme": req.URL.Scheme,
|
||||
"size": responseLogger.Size(),
|
||||
"status": responseLogger.Status(),
|
||||
"upstream": responseLogger.upstream,
|
||||
"user_agent": req.UserAgent(),
|
||||
}), req).Info(url.RequestURI())
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@ -24,11 +25,12 @@ func (ws *WebServer) configureProxy() {
|
||||
if req.TLS != nil {
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
}
|
||||
ws.log.WithField("url", req.URL.String()).WithField("headers", req.Header).Trace("tracing request to backend")
|
||||
}
|
||||
rp := &httputil.ReverseProxy{Director: director}
|
||||
rp.ErrorHandler = ws.proxyErrorHandler
|
||||
rp.ModifyResponse = ws.proxyModifyResponse
|
||||
ws.m.PathPrefix("/akprox").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
ws.m.PathPrefix("/outpost.goauthentik.io").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if ws.ProxyServer != nil {
|
||||
before := time.Now()
|
||||
ws.ProxyServer.Handle(rw, r)
|
||||
@ -65,9 +67,20 @@ func (ws *WebServer) configureProxy() {
|
||||
}
|
||||
|
||||
func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request, err error) {
|
||||
ws.log.Warning(err.Error())
|
||||
ws.log.WithError(err).Warning("failed to proxy to backend")
|
||||
rw.WriteHeader(http.StatusBadGateway)
|
||||
_, err = rw.Write([]byte("authentik starting..."))
|
||||
em := fmt.Sprintf("failed to connect to authentik backend: %v", err)
|
||||
if !ws.p.IsRunning() {
|
||||
em = "authentik starting..."
|
||||
}
|
||||
// return json if the client asks for json
|
||||
if req.Header.Get("Accept") == "application/json" {
|
||||
eem, _ := json.Marshal(map[string]string{
|
||||
"error": em,
|
||||
})
|
||||
em = string(eem)
|
||||
}
|
||||
_, err = rw.Write([]byte(em))
|
||||
if err != nil {
|
||||
ws.log.WithError(err).Warning("failed to write error message")
|
||||
}
|
||||
@ -75,5 +88,6 @@ func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request
|
||||
|
||||
func (ws *WebServer) proxyModifyResponse(r *http.Response) error {
|
||||
r.Header.Set("X-Powered-By", "authentik")
|
||||
r.Header.Del("Server")
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,6 +16,9 @@ func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Certif
|
||||
ws.log.WithError(err).Error("failed to generate default cert")
|
||||
}
|
||||
return func(ch *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if ch.ServerName == "" {
|
||||
return &cert, nil
|
||||
}
|
||||
if ws.ProxyServer != nil {
|
||||
appCert := ws.ProxyServer.GetCertificate(ch.ServerName)
|
||||
if appCert != nil {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# Stage 1: Build
|
||||
FROM docker.io/golang:1.17.6-bullseye AS builder
|
||||
FROM docker.io/golang:1.17.7-bullseye AS builder
|
||||
|
||||
WORKDIR /go/src/goauthentik.io
|
||||
|
||||
@ -19,7 +19,7 @@ ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
|
||||
COPY --from=builder /go/ldap /
|
||||
|
||||
HEALTHCHECK CMD [ "wget", "--spider", "http://localhost:9300/akprox/ping" ]
|
||||
HEALTHCHECK CMD [ "wget", "--spider", "http://localhost:9300/outpost.goauthentik.io/ping" ]
|
||||
|
||||
EXPOSE 3389 6636 9300
|
||||
|
||||
|
||||
30
lifecycle/ak
30
lifecycle/ak
@ -32,30 +32,6 @@ function check_if_root {
|
||||
chpst -u authentik:$GROUP env HOME=/authentik $1
|
||||
}
|
||||
|
||||
function prefixwith {
|
||||
local prefix="$1"
|
||||
shift
|
||||
"$@" > >(sed "s/^/$prefix: /") 2> >(sed "s/^/$prefix (err): /" >&2)
|
||||
}
|
||||
|
||||
function restore {
|
||||
PG_HOST=$(python -m authentik.lib.config postgresql.host 2> /dev/null)
|
||||
PG_NAME=$(python -m authentik.lib.config postgresql.name 2> /dev/null)
|
||||
PG_USER=$(python -m authentik.lib.config postgresql.user 2> /dev/null)
|
||||
PG_PORT=$(python -m authentik.lib.config postgresql.port 2> /dev/null)
|
||||
export PGPASSWORD=$(python -m authentik.lib.config postgresql.password 2> /dev/null)
|
||||
log "Ensuring no one can connect to the database"
|
||||
prefixwith "psql" psql -h"${PG_HOST}" -U"${PG_USER}" -c"UPDATE pg_database SET datallowconn = 'false' WHERE datname = '${PG_NAME}';" "postgres"
|
||||
prefixwith "psql" psql -h"${PG_HOST}" -U"${PG_USER}" -c"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${PG_NAME}';" "postgres"
|
||||
log "deleting and re-creating database"
|
||||
prefixwith "psql" dropdb -h"${PG_HOST}" -U"${PG_USER}" "${PG_NAME}" || trueacku
|
||||
prefixwith "psql" createdb -h"${PG_HOST}" -U"${PG_USER}" "${PG_NAME}"
|
||||
log "running initial migrations"
|
||||
prefixwith "migrate" python -m lifecycle.migrate 2> /dev/null
|
||||
log "restoring database"
|
||||
prefixwith "restore" python -m manage dbrestore -i ${@:2}
|
||||
}
|
||||
|
||||
MODE_FILE="/tmp/authentik-mode"
|
||||
|
||||
if [[ "$1" == "server" ]]; then
|
||||
@ -75,12 +51,6 @@ elif [[ "$1" == "worker" ]]; then
|
||||
elif [[ "$1" == "flower" ]]; then
|
||||
echo "flower" > $MODE_FILE
|
||||
celery -A authentik.root.celery flower
|
||||
elif [[ "$1" == "backup" ]]; then
|
||||
wait_for_db
|
||||
python -m manage dbbackup --clean
|
||||
elif [[ "$1" == "restore" ]]; then
|
||||
wait_for_db
|
||||
restore $@
|
||||
elif [[ "$1" == "bash" ]]; then
|
||||
/bin/bash
|
||||
elif [[ "$1" == "test" ]]; then
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
"""Gunicorn config"""
|
||||
import os
|
||||
import pwd
|
||||
from hashlib import sha512
|
||||
from multiprocessing import cpu_count
|
||||
|
||||
import structlog
|
||||
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
|
||||
|
||||
from authentik import get_full_version
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.lib.utils.reflection import get_env
|
||||
|
||||
bind = "127.0.0.1:8000"
|
||||
reload = True
|
||||
|
||||
try:
|
||||
pwd.getpwnam("authentik")
|
||||
@ -70,3 +75,31 @@ def worker_exit(server, worker):
|
||||
from prometheus_client import multiprocess
|
||||
|
||||
multiprocess.mark_process_dead(worker.pid)
|
||||
|
||||
|
||||
if not CONFIG.y_bool("disable_startup_analytics", False):
|
||||
env = get_env()
|
||||
should_send = env not in ["dev", "ci"]
|
||||
if should_send:
|
||||
try:
|
||||
get_http_session().post(
|
||||
"https://goauthentik.io/api/event",
|
||||
json={
|
||||
"domain": "authentik",
|
||||
"name": "pageview",
|
||||
"referrer": get_full_version(),
|
||||
"url": (
|
||||
f"http://localhost/{env}?utm_source={get_full_version()}&utm_medium={env}"
|
||||
),
|
||||
},
|
||||
headers={
|
||||
"User-Agent": sha512(str(CONFIG.y("secret_key")).encode("ascii")).hexdigest()[
|
||||
:16
|
||||
],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
# pylint: disable=bare-except
|
||||
except: # nosec
|
||||
pass
|
||||
|
||||
BIN
locale/es/LC_MESSAGES/django.mo
Normal file
BIN
locale/es/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1831
locale/es/LC_MESSAGES/django.po
Normal file
1831
locale/es/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
locale/zh-Hans/LC_MESSAGES/django.mo
Normal file
BIN
locale/zh-Hans/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1749
locale/zh-Hans/LC_MESSAGES/django.po
Normal file
1749
locale/zh-Hans/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
locale/zh-Hant/LC_MESSAGES/django.mo
Normal file
BIN
locale/zh-Hant/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1749
locale/zh-Hant/LC_MESSAGES/django.po
Normal file
1749
locale/zh-Hant/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
locale/zh_TW/LC_MESSAGES/django.mo
Normal file
BIN
locale/zh_TW/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1749
locale/zh_TW/LC_MESSAGES/django.po
Normal file
1749
locale/zh_TW/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -22,13 +22,6 @@ warnings.filterwarnings(
|
||||
"efault_app_config."
|
||||
),
|
||||
)
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=(
|
||||
"'dbbackup' defines default_app_config = 'dbbackup.apps.DbbackupConfig'. Django now det"
|
||||
"ects this configuration automatically. You can remove default_app_config."
|
||||
),
|
||||
)
|
||||
|
||||
defuse_stdlib()
|
||||
|
||||
|
||||
682
poetry.lock
generated
682
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@ ENV NODE_ENV=production
|
||||
RUN cd /static && npm i && npm run build-proxy
|
||||
|
||||
# Stage 2: Build
|
||||
FROM docker.io/golang:1.17.6-bullseye AS builder
|
||||
FROM docker.io/golang:1.17.7-bullseye AS builder
|
||||
|
||||
WORKDIR /go/src/goauthentik.io
|
||||
|
||||
@ -32,7 +32,7 @@ COPY --from=web-builder /static/security.txt /web/security.txt
|
||||
COPY --from=web-builder /static/dist/ /web/dist/
|
||||
COPY --from=web-builder /static/authentik/ /web/authentik/
|
||||
|
||||
HEALTHCHECK CMD [ "wget", "--spider", "http://localhost:9300/akprox/ping" ]
|
||||
HEALTHCHECK CMD [ "wget", "--spider", "http://localhost:9300/outpost.goauthentik.io/ping" ]
|
||||
|
||||
EXPOSE 9000 9300 9443
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ pythonPlatform = "Linux"
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py39']
|
||||
target-version = ['py310']
|
||||
exclude = 'node_modules'
|
||||
|
||||
[tool.isort]
|
||||
@ -92,12 +92,11 @@ addopts = "-p no:celery --junitxml=unittest.xml"
|
||||
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2022.1.3"
|
||||
version = "2022.2.1"
|
||||
description = ""
|
||||
authors = ["Jens Langhammer <jens.langhammer@beryju.org>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
boto3 = "*"
|
||||
celery = "*"
|
||||
channels = "*"
|
||||
channels-redis = "*"
|
||||
@ -107,14 +106,12 @@ dacite = "*"
|
||||
deepmerge = "*"
|
||||
defusedxml = "*"
|
||||
django = "*"
|
||||
django-dbbackup = "=4.0.0b0"
|
||||
django-filter = "*"
|
||||
django-guardian = "*"
|
||||
django-model-utils = "*"
|
||||
django-otp = "*"
|
||||
django-prometheus = "*"
|
||||
django-redis = "*"
|
||||
django-storages = "*"
|
||||
djangorestframework = "*"
|
||||
djangorestframework-guardian = "*"
|
||||
docker = "*"
|
||||
|
||||
72
schema.yml
72
schema.yml
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2022.1.3
|
||||
version: 2022.2.1
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@beryju.org
|
||||
@ -15045,10 +15045,14 @@ paths:
|
||||
description: AuthenticatorValidateStage Viewset
|
||||
parameters:
|
||||
- in: query
|
||||
name: configuration_stage
|
||||
name: configuration_stages
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
explode: true
|
||||
style: form
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
@ -19826,11 +19830,12 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DeviceClassesEnum'
|
||||
description: Device classes which can be used to authenticate
|
||||
configuration_stage:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: Stage used to configure Authenticator when user doesn't have
|
||||
configuration_stages:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Stages used to configure Authenticator when user doesn't have
|
||||
any compatible devices. After this configuration Stage passes, the user
|
||||
is not prompted again.
|
||||
required:
|
||||
@ -19858,11 +19863,12 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DeviceClassesEnum'
|
||||
description: Device classes which can be used to authenticate
|
||||
configuration_stage:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: Stage used to configure Authenticator when user doesn't have
|
||||
configuration_stages:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Stages used to configure Authenticator when user doesn't have
|
||||
any compatible devices. After this configuration Stage passes, the user
|
||||
is not prompted again.
|
||||
required:
|
||||
@ -19892,7 +19898,12 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DeviceChallenge'
|
||||
configuration_stages:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelectableStage'
|
||||
required:
|
||||
- configuration_stages
|
||||
- device_challenges
|
||||
- pending_user
|
||||
- pending_user_avatar
|
||||
@ -19907,6 +19918,9 @@ components:
|
||||
default: ak-stage-authenticator-validate
|
||||
selected_challenge:
|
||||
$ref: '#/components/schemas/DeviceChallengeRequest'
|
||||
selected_stage:
|
||||
type: string
|
||||
minLength: 1
|
||||
code:
|
||||
type: string
|
||||
minLength: 1
|
||||
@ -20017,7 +20031,6 @@ components:
|
||||
enum:
|
||||
- can_save_media
|
||||
- can_geo_ip
|
||||
- can_backup
|
||||
type: string
|
||||
CaptchaChallenge:
|
||||
type: object
|
||||
@ -26678,11 +26691,12 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DeviceClassesEnum'
|
||||
description: Device classes which can be used to authenticate
|
||||
configuration_stage:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: Stage used to configure Authenticator when user doesn't have
|
||||
configuration_stages:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Stages used to configure Authenticator when user doesn't have
|
||||
any compatible devices. After this configuration Stage passes, the user
|
||||
is not prompted again.
|
||||
PatchedCaptchaStageRequest:
|
||||
@ -30018,6 +30032,24 @@ components:
|
||||
- direct
|
||||
- cached
|
||||
type: string
|
||||
SelectableStage:
|
||||
type: object
|
||||
description: Serializer for stages which can be selected by users
|
||||
properties:
|
||||
pk:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
verbose_name:
|
||||
type: string
|
||||
meta_model_name:
|
||||
type: string
|
||||
required:
|
||||
- meta_model_name
|
||||
- name
|
||||
- pk
|
||||
- verbose_name
|
||||
ServiceConnection:
|
||||
type: object
|
||||
description: ServiceConnection Serializer
|
||||
|
||||
@ -3,7 +3,11 @@ from authentik.lib.generators import generate_id
|
||||
from yaml import safe_dump
|
||||
|
||||
with open("local.env.yml", "w") as _config:
|
||||
safe_dump({
|
||||
"log_level": "debug",
|
||||
"secret_key": generate_id(),
|
||||
}, _config, default_flow_style=False)
|
||||
safe_dump(
|
||||
{
|
||||
"log_level": "debug",
|
||||
"secret_key": generate_id(),
|
||||
},
|
||||
_config,
|
||||
default_flow_style=False,
|
||||
)
|
||||
|
||||
@ -13,7 +13,10 @@ if os.environ.get(env_pr_branch, "") != "":
|
||||
should_build = str(os.environ.get("DOCKER_USERNAME", "") != "").lower()
|
||||
|
||||
print("##[set-output name=branchName]%s" % branch_name)
|
||||
print("##[set-output name=branchNameContainer]%s" % branch_name.replace("refs/heads/", "").replace("/", "-"))
|
||||
print(
|
||||
"##[set-output name=branchNameContainer]%s"
|
||||
% branch_name.replace("refs/heads/", "").replace("/", "-")
|
||||
)
|
||||
print("##[set-output name=timestamp]%s" % int(time()))
|
||||
print("##[set-output name=sha]%s" % os.environ[sha])
|
||||
print("##[set-output name=shouldBuild]%s" % should_build)
|
||||
|
||||
@ -2,20 +2,15 @@
|
||||
from json import loads, dumps
|
||||
|
||||
TSCONFIG_ESM = {
|
||||
"compilerOptions": {
|
||||
"declaration": True,
|
||||
"target": "es6",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist/esm",
|
||||
"typeRoots": [
|
||||
"node_modules/@types"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
"compilerOptions": {
|
||||
"declaration": True,
|
||||
"target": "es6",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist/esm",
|
||||
"typeRoots": ["node_modules/@types"],
|
||||
},
|
||||
"exclude": ["dist", "node_modules"],
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +19,7 @@ with open("web-api/package.json", encoding="utf-8") as _package:
|
||||
package["license"] = "GPL-3.0-only"
|
||||
package["module"] = "./dist/esm/index.js"
|
||||
package["sideEffects"] = False
|
||||
package["scripts"]["build"] = "tsc && tsc --project tsconfig.esm.json"
|
||||
package["scripts"]["build"] = "tsc && tsc --project tsconfig.esm.json"
|
||||
|
||||
open("web-api/package.json", "w+", encoding="utf-8").write(dumps(package))
|
||||
open("web-api/tsconfig.esm.json", "w+", encoding="utf-8").write(dumps(TSCONFIG_ESM))
|
||||
|
||||
@ -105,7 +105,7 @@ class TestProviderProxy(SeleniumTestCase):
|
||||
self.assertIn(f"X-Authentik-Username: {self.user.username}", full_body_text)
|
||||
self.assertIn("X-Foo: bar", full_body_text)
|
||||
|
||||
self.driver.get("http://localhost:9000/akprox/sign_out")
|
||||
self.driver.get("http://localhost:9000/outpost.goauthentik.io/sign_out")
|
||||
sleep(2)
|
||||
full_body_text = self.driver.find_element(By.CSS_SELECTOR, ".pf-c-title.pf-m-3xl").text
|
||||
self.assertIn("You've logged out of proxy.", full_body_text)
|
||||
|
||||
904
web/package-lock.json
generated
904
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -20,7 +20,13 @@
|
||||
"en",
|
||||
"pseudo-LOCALE",
|
||||
"fr_FR",
|
||||
"tr"
|
||||
"tr",
|
||||
"es",
|
||||
"pl",
|
||||
"zh_TW",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"de"
|
||||
],
|
||||
"formatOptions": {
|
||||
"lineNumbers": false
|
||||
@ -46,55 +52,55 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.16.12",
|
||||
"@babel/plugin-proposal-decorators": "^7.16.7",
|
||||
"@babel/plugin-transform-runtime": "^7.16.10",
|
||||
"@babel/core": "^7.17.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.17.2",
|
||||
"@babel/plugin-transform-runtime": "^7.17.0",
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@formatjs/intl-listformat": "^6.5.1",
|
||||
"@fortawesome/fontawesome-free": "^5.15.4",
|
||||
"@goauthentik/api": "^2022.1.2-1643057053",
|
||||
"@formatjs/intl-listformat": "^6.5.2",
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"@goauthentik/api": "^2022.1.5-1644681372",
|
||||
"@jackfranklin/rollup-plugin-markdown": "^0.3.0",
|
||||
"@lingui/cli": "^3.13.2",
|
||||
"@lingui/core": "^3.13.2",
|
||||
"@lingui/detect-locale": "^3.13.2",
|
||||
"@lingui/macro": "^3.13.2",
|
||||
"@patternfly/patternfly": "^4.164.2",
|
||||
"@patternfly/patternfly": "^4.171.1",
|
||||
"@polymer/iron-form": "^3.0.1",
|
||||
"@polymer/paper-input": "^3.2.1",
|
||||
"@rollup/plugin-babel": "^5.3.0",
|
||||
"@rollup/plugin-commonjs": "^21.0.1",
|
||||
"@rollup/plugin-node-resolve": "^13.1.3",
|
||||
"@rollup/plugin-replace": "^3.0.1",
|
||||
"@rollup/plugin-replace": "^3.1.0",
|
||||
"@rollup/plugin-typescript": "^8.3.0",
|
||||
"@sentry/browser": "^6.17.2",
|
||||
"@sentry/tracing": "^6.17.2",
|
||||
"@sentry/browser": "^6.17.8",
|
||||
"@sentry/tracing": "^6.17.8",
|
||||
"@squoosh/cli": "^0.7.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^3.1.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^3.2.0",
|
||||
"@types/chart.js": "^2.9.35",
|
||||
"@types/codemirror": "5.60.5",
|
||||
"@types/grecaptcha": "^3.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.1",
|
||||
"@typescript-eslint/parser": "^5.10.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.12.0",
|
||||
"@typescript-eslint/parser": "^5.12.0",
|
||||
"@webcomponents/webcomponentsjs": "^2.6.0",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"chart.js": "^3.7.0",
|
||||
"chart.js": "^3.7.1",
|
||||
"chartjs-adapter-moment": "^1.0.0",
|
||||
"codemirror": "^5.65.1",
|
||||
"construct-style-sheets-polyfill": "^3.0.5",
|
||||
"country-flag-icons": "^1.4.20",
|
||||
"eslint": "^8.7.0",
|
||||
"construct-style-sheets-polyfill": "^3.1.0",
|
||||
"country-flag-icons": "^1.4.21",
|
||||
"eslint": "^8.9.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-custom-elements": "0.0.4",
|
||||
"eslint-plugin-lit": "^1.6.1",
|
||||
"flowchart.js": "^1.17.0",
|
||||
"flowchart.js": "^1.17.1",
|
||||
"fuse.js": "^6.5.3",
|
||||
"lit": "^2.1.2",
|
||||
"lit": "^2.1.4",
|
||||
"moment": "^2.29.1",
|
||||
"prettier": "^2.5.1",
|
||||
"rapidoc": "^9.1.4",
|
||||
"rollup": "^2.66.1",
|
||||
"rollup": "^2.67.2",
|
||||
"rollup-plugin-copy": "^3.4.0",
|
||||
"rollup-plugin-cssimport": "^1.0.2",
|
||||
"rollup-plugin-minify-html-literals": "^1.2.6",
|
||||
|
||||
@ -34,6 +34,7 @@ export const resources = [
|
||||
dest: "dist/",
|
||||
},
|
||||
{ src: "src/authentik.css", dest: "dist/" },
|
||||
{ src: "src/custom.css", dest: "dist/" },
|
||||
|
||||
{
|
||||
src: "node_modules/@patternfly/patternfly/assets/*",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 704 KiB After Width: | Height: | Size: 580 KiB |
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2022.1.3";
|
||||
export const VERSION = "2022.2.1";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
||||
1
web/src/custom.css
Normal file
1
web/src/custom.css
Normal file
@ -0,0 +1 @@
|
||||
/* User customisable */
|
||||
@ -113,6 +113,9 @@ export class FlowExecutor extends LitElement implements StageHost {
|
||||
.pf-c-drawer__content {
|
||||
background-color: transparent;
|
||||
}
|
||||
.pf-c-login__main {
|
||||
width: 100%;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
@ -430,8 +433,7 @@ export class FlowExecutor extends LitElement implements StageHost {
|
||||
)
|
||||
? html`
|
||||
<li>
|
||||
<a
|
||||
href="https://unsplash.com/@kimonmaritz"
|
||||
<a href="https://unsplash.com/@trime"
|
||||
>${t`Background image`}</a
|
||||
>
|
||||
</li>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user