Compare commits
36 Commits
version/20
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
1c03cfa906 | |||
e2dbab5bca | |||
3a6c42fefb | |||
6bb180f94e | |||
03dea17519 | |||
49d83f11bd | |||
5f0af81e4d | |||
63591e1710 | |||
6503a7b048 | |||
7e244e0679 | |||
c1998bf3f2 | |||
83372618a8 | |||
89a876e141 | |||
26d6e8bc5c | |||
d9dc373170 | |||
4ec37c5239 | |||
a9cfa6fe35 | |||
5ac5084149 | |||
eda38a30b1 | |||
9b84bf7174 | |||
f74549be6d | |||
76f4d7fb0a | |||
d1cf1dd083 | |||
2835fbd390 | |||
76ad2c8925 | |||
2270629fdc | |||
43a629efc1 | |||
4044e52403 | |||
aa7c846467 | |||
8ab7f4073b | |||
a05856c2ef | |||
9e9154e04a | |||
32549066c0 | |||
5ed3e879a2 | |||
4e4923ad0e | |||
0302d147e9 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.2.1
|
||||
current_version = 2024.2.4
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
|
@ -11,6 +11,10 @@ inputs:
|
||||
description: "Docker image arch"
|
||||
|
||||
outputs:
|
||||
shouldBuild:
|
||||
description: "Whether to build image or not"
|
||||
value: ${{ steps.ev.outputs.shouldBuild }}
|
||||
|
||||
sha:
|
||||
description: "sha"
|
||||
value: ${{ steps.ev.outputs.sha }}
|
||||
|
@ -7,6 +7,8 @@ from time import time
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read(".bumpversion.cfg")
|
||||
|
||||
should_build = str(os.environ.get("DOCKER_USERNAME", None) is not None).lower()
|
||||
|
||||
branch_name = os.environ["GITHUB_REF"]
|
||||
if os.environ.get("GITHUB_HEAD_REF", "") != "":
|
||||
branch_name = os.environ["GITHUB_HEAD_REF"]
|
||||
@ -52,6 +54,7 @@ image_main_tag = image_tags[0]
|
||||
image_tags_rendered = ",".join(image_tags)
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
|
||||
print("shouldBuild=%s" % should_build, file=_output)
|
||||
print("sha=%s" % sha, file=_output)
|
||||
print("version=%s" % version, file=_output)
|
||||
print("prerelease=%s" % prerelease, file=_output)
|
||||
|
8
.github/workflows/ci-main.yml
vendored
8
.github/workflows/ci-main.yml
vendored
@ -219,7 +219,6 @@ jobs:
|
||||
# Needed to upload contianer images to ghcr.io
|
||||
packages: write
|
||||
timeout-minutes: 120
|
||||
if: "github.repository == 'goauthentik/authentik'"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@ -231,10 +230,13 @@ jobs:
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-server
|
||||
image-arch: ${{ matrix.arch }}
|
||||
- name: Login to Container Registry
|
||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@ -250,7 +252,7 @@ jobs:
|
||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
push: true
|
||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
build-args: |
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
cache-from: type=gha
|
||||
@ -272,6 +274,8 @@ jobs:
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-server
|
||||
- name: Comment on PR
|
||||
|
6
.github/workflows/ci-outpost.yml
vendored
6
.github/workflows/ci-outpost.yml
vendored
@ -71,7 +71,6 @@ jobs:
|
||||
permissions:
|
||||
# Needed to upload contianer images to ghcr.io
|
||||
packages: write
|
||||
if: "github.repository == 'goauthentik/authentik'"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@ -83,9 +82,12 @@ jobs:
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
|
||||
- name: Login to Container Registry
|
||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@ -98,7 +100,7 @@ jobs:
|
||||
with:
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
push: true
|
||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
build-args: |
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
6
.github/workflows/release-publish.yml
vendored
6
.github/workflows/release-publish.yml
vendored
@ -20,6 +20,8 @@ jobs:
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/server,beryju/authentik
|
||||
- name: Docker Login Registry
|
||||
@ -72,6 +74,8 @@ jobs:
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/${{ matrix.type }},beryju/authentik-${{ matrix.type }}
|
||||
- name: make empty clients
|
||||
@ -168,6 +172,8 @@ jobs:
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/server
|
||||
- name: Get static files from docker image
|
||||
|
2
.github/workflows/release-tag.yml
vendored
2
.github/workflows/release-tag.yml
vendored
@ -32,6 +32,8 @@ jobs:
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/server
|
||||
- name: Create Release
|
||||
|
@ -103,9 +103,10 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
|
||||
--mount=type=cache,target=/root/.cache/pip \
|
||||
--mount=type=cache,target=/root/.cache/pypoetry \
|
||||
python -m venv /ak-root/venv/ && \
|
||||
pip3 install --upgrade pip && \
|
||||
pip3 install poetry && \
|
||||
poetry install --only=main --no-ansi --no-interaction
|
||||
bash -c "source ${VENV_PATH}/bin/activate && \
|
||||
pip3 install --upgrade pip && \
|
||||
pip3 install poetry && \
|
||||
poetry install --only=main --no-ansi --no-interaction --no-root"
|
||||
|
||||
# Stage 6: Run
|
||||
FROM docker.io/python:3.12.2-slim-bookworm AS final-image
|
||||
|
@ -3,7 +3,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2024.2.1"
|
||||
__version__ = "2024.2.4"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -68,7 +68,11 @@ class ConfigView(APIView):
|
||||
"""Get all capabilities this server instance supports"""
|
||||
caps = []
|
||||
deb_test = settings.DEBUG or settings.TEST
|
||||
if Path(settings.MEDIA_ROOT).is_mount() or deb_test:
|
||||
if (
|
||||
CONFIG.get("storage.media.backend", "file") == "s3"
|
||||
or Path(settings.STORAGES["default"]["OPTIONS"]["location"]).is_mount()
|
||||
or deb_test
|
||||
):
|
||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||
for processor in get_context_processors():
|
||||
if cap := processor.capability():
|
||||
|
@ -20,7 +20,7 @@ from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import model_to_dict
|
||||
from authentik.rbac.decorators import permission_required
|
||||
@ -36,6 +36,13 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
self.fields["key"] = CharField(required=False)
|
||||
|
||||
def validate_user(self, user: User):
|
||||
"""Ensure user of token cannot be changed"""
|
||||
if self.instance and self.instance.user_id:
|
||||
if user.pk != self.instance.user_id:
|
||||
raise ValidationError("User cannot be changed")
|
||||
return user
|
||||
|
||||
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
|
||||
"""Ensure only API or App password tokens are created."""
|
||||
request: Request = self.context.get("request")
|
||||
|
@ -154,7 +154,7 @@ class UserSerializer(ModelSerializer):
|
||||
|
||||
def get_avatar(self, user: User) -> str:
|
||||
"""User's avatar, either a http/https URL or a data URI"""
|
||||
return get_avatar(user, self.context["request"])
|
||||
return get_avatar(user, self.context.get("request"))
|
||||
|
||||
def validate_path(self, path: str) -> str:
|
||||
"""Validate path"""
|
||||
@ -218,7 +218,7 @@ class UserSelfSerializer(ModelSerializer):
|
||||
|
||||
def get_avatar(self, user: User) -> str:
|
||||
"""User's avatar, either a http/https URL or a data URI"""
|
||||
return get_avatar(user, self.context["request"])
|
||||
return get_avatar(user, self.context.get("request"))
|
||||
|
||||
@extend_schema_field(
|
||||
ListSerializer(
|
||||
@ -611,7 +611,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
email_stage: EmailStage = stages.first()
|
||||
message = TemplateEmailMessage(
|
||||
subject=_(email_stage.subject),
|
||||
to=[for_user.email],
|
||||
to=[(for_user.name, for_user.email)],
|
||||
template_name=email_stage.template,
|
||||
language=for_user.locale(request),
|
||||
template_context={
|
||||
|
@ -7,8 +7,8 @@ from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.api.tokens import TokenSerializer
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ class TestTokenAPI(APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.create(username="testuser")
|
||||
self.user = create_test_user()
|
||||
self.admin = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
@ -76,6 +76,24 @@ class TestTokenAPI(APITestCase):
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.expiring, False)
|
||||
|
||||
def test_token_change_user(self):
|
||||
"""Test creating a token and then changing the user"""
|
||||
ident = generate_id()
|
||||
response = self.client.post(reverse("authentik_api:token-list"), {"identifier": ident})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
token = Token.objects.get(identifier=ident)
|
||||
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))
|
||||
response = self.client.put(
|
||||
reverse("authentik_api:token-detail", kwargs={"identifier": ident}),
|
||||
data={"identifier": "user_token_poc_v3", "intent": "api", "user": self.admin.pk},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
token.refresh_from_db()
|
||||
self.assertEqual(token.user, self.user)
|
||||
|
||||
def test_list(self):
|
||||
"""Test Token List (Test normal authentication)"""
|
||||
Token.objects.all().delete()
|
||||
|
@ -31,7 +31,7 @@ class EnterpriseRequiredMixin:
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
"""Check that a valid license exists"""
|
||||
if not LicenseKey.cached_summary().valid:
|
||||
if not LicenseKey.cached_summary().has_license:
|
||||
raise ValidationError(_("Enterprise is required to create/update this object."))
|
||||
return super().validate(attrs)
|
||||
|
||||
|
@ -11,7 +11,6 @@ from django.db.models.expressions import BaseExpression, Combinable
|
||||
from django.db.models.signals import post_init
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.middleware import AuditMiddleware, should_log_model
|
||||
from authentik.events.utils import cleanse_dict, sanitize_item
|
||||
|
||||
@ -28,13 +27,10 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
super().connect(request)
|
||||
if not self.enabled:
|
||||
return
|
||||
user = getattr(request, "user", self.anonymous_user)
|
||||
if not user.is_authenticated:
|
||||
user = self.anonymous_user
|
||||
if not hasattr(request, "request_id"):
|
||||
return
|
||||
post_init.connect(
|
||||
partial(self.post_init_handler, user=user, request=request),
|
||||
partial(self.post_init_handler, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
@ -76,7 +72,7 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
diff[key] = {"previous_value": value, "new_value": after.get(key)}
|
||||
return sanitize_item(diff)
|
||||
|
||||
def post_init_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||
def post_init_handler(self, request: HttpRequest, sender, instance: Model, **_):
|
||||
"""post_init django model handler"""
|
||||
if not should_log_model(instance):
|
||||
return
|
||||
@ -91,7 +87,6 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
# pylint: disable=too-many-arguments
|
||||
def post_save_handler(
|
||||
self,
|
||||
user: User,
|
||||
request: HttpRequest,
|
||||
sender,
|
||||
instance: Model,
|
||||
@ -113,6 +108,4 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
for field_set in ignored_field_sets:
|
||||
if set(diff.keys()) == set(field_set):
|
||||
return None
|
||||
return super().post_save_handler(
|
||||
user, request, sender, instance, created, thread_kwargs, **_
|
||||
)
|
||||
return super().post_save_handler(request, sender, instance, created, thread_kwargs, **_)
|
||||
|
@ -188,20 +188,21 @@ class LicenseKey:
|
||||
|
||||
def summary(self) -> LicenseSummary:
|
||||
"""Summary of license status"""
|
||||
has_license = License.objects.all().count() > 0
|
||||
last_valid = LicenseKey.last_valid_date()
|
||||
show_admin_warning = last_valid < now() - timedelta(weeks=2)
|
||||
show_user_warning = last_valid < now() - timedelta(weeks=4)
|
||||
read_only = last_valid < now() - timedelta(weeks=6)
|
||||
latest_valid = datetime.fromtimestamp(self.exp)
|
||||
return LicenseSummary(
|
||||
show_admin_warning=show_admin_warning,
|
||||
show_user_warning=show_user_warning,
|
||||
read_only=read_only,
|
||||
show_admin_warning=show_admin_warning and has_license,
|
||||
show_user_warning=show_user_warning and has_license,
|
||||
read_only=read_only and has_license,
|
||||
latest_valid=latest_valid,
|
||||
internal_users=self.internal_users,
|
||||
external_users=self.external_users,
|
||||
valid=self.is_valid(),
|
||||
has_license=License.objects.all().count() > 0,
|
||||
has_license=has_license,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
@ -6,13 +6,13 @@ from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer
|
||||
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
|
||||
from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint
|
||||
from authentik.enterprise.providers.rac.models import ConnectionToken
|
||||
|
||||
|
||||
class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
@ -23,7 +23,7 @@ class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
user = GroupMemberSerializer(source="session.user", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Endpoint
|
||||
model = ConnectionToken
|
||||
fields = [
|
||||
"pk",
|
||||
"provider",
|
||||
@ -49,5 +49,5 @@ class ConnectionTokenViewSet(
|
||||
filterset_fields = ["endpoint", "session__user", "provider"]
|
||||
search_fields = ["endpoint__name", "provider__name"]
|
||||
ordering = ["endpoint__name", "provider__name"]
|
||||
permission_classes = [OwnerPermissions]
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
|
@ -2,11 +2,14 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django.db.models.signals import pre_save
|
||||
from django.core.cache import cache
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import get_current_timezone
|
||||
|
||||
from authentik.enterprise.license import CACHE_KEY_ENTERPRISE_LICENSE
|
||||
from authentik.enterprise.models import License
|
||||
from authentik.enterprise.tasks import enterprise_update_usage
|
||||
|
||||
|
||||
@receiver(pre_save, sender=License)
|
||||
@ -17,3 +20,10 @@ def pre_save_license(sender: type[License], instance: License, **_):
|
||||
instance.internal_users = status.internal_users
|
||||
instance.external_users = status.external_users
|
||||
instance.expiry = datetime.fromtimestamp(status.exp, tz=get_current_timezone())
|
||||
|
||||
|
||||
@receiver(post_save, sender=License)
|
||||
def post_save_license(sender: type[License], instance: License, **_):
|
||||
"""Trigger license usage calculation when license is saved"""
|
||||
cache.delete(CACHE_KEY_ENTERPRISE_LICENSE)
|
||||
enterprise_update_usage.delay()
|
||||
|
@ -82,26 +82,29 @@ class AuditMiddleware:
|
||||
|
||||
self.anonymous_user = get_anonymous_user()
|
||||
|
||||
def get_user(self, request: HttpRequest) -> User:
|
||||
user = getattr(request, "user", self.anonymous_user)
|
||||
if not user.is_authenticated:
|
||||
return self.anonymous_user
|
||||
return user
|
||||
|
||||
def connect(self, request: HttpRequest):
|
||||
"""Connect signal for automatic logging"""
|
||||
self._ensure_fallback_user()
|
||||
user = getattr(request, "user", self.anonymous_user)
|
||||
if not user.is_authenticated:
|
||||
user = self.anonymous_user
|
||||
if not hasattr(request, "request_id"):
|
||||
return
|
||||
post_save.connect(
|
||||
partial(self.post_save_handler, user=user, request=request),
|
||||
partial(self.post_save_handler, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
pre_delete.connect(
|
||||
partial(self.pre_delete_handler, user=user, request=request),
|
||||
partial(self.pre_delete_handler, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
m2m_changed.connect(
|
||||
partial(self.m2m_changed_handler, user=user, request=request),
|
||||
partial(self.m2m_changed_handler, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
@ -147,7 +150,6 @@ class AuditMiddleware:
|
||||
# pylint: disable=too-many-arguments
|
||||
def post_save_handler(
|
||||
self,
|
||||
user: User,
|
||||
request: HttpRequest,
|
||||
sender,
|
||||
instance: Model,
|
||||
@ -158,16 +160,18 @@ class AuditMiddleware:
|
||||
"""Signal handler for all object's post_save"""
|
||||
if not should_log_model(instance):
|
||||
return
|
||||
user = self.get_user(request)
|
||||
|
||||
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
|
||||
thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
|
||||
thread.kwargs.update(thread_kwargs or {})
|
||||
thread.run()
|
||||
|
||||
def pre_delete_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||
def pre_delete_handler(self, request: HttpRequest, sender, instance: Model, **_):
|
||||
"""Signal handler for all object's pre_delete"""
|
||||
if not should_log_model(instance): # pragma: no cover
|
||||
return
|
||||
user = self.get_user(request)
|
||||
|
||||
EventNewThread(
|
||||
EventAction.MODEL_DELETED,
|
||||
@ -176,14 +180,13 @@ class AuditMiddleware:
|
||||
model=model_to_dict(instance),
|
||||
).run()
|
||||
|
||||
def m2m_changed_handler(
|
||||
self, user: User, request: HttpRequest, sender, instance: Model, action: str, **_
|
||||
):
|
||||
def m2m_changed_handler(self, request: HttpRequest, sender, instance: Model, action: str, **_):
|
||||
"""Signal handler for all object's m2m_changed"""
|
||||
if action not in ["pre_add", "pre_remove", "post_clear"]:
|
||||
return
|
||||
if not should_log_m2m(instance):
|
||||
return
|
||||
user = self.get_user(request)
|
||||
|
||||
EventNewThread(
|
||||
EventAction.MODEL_UPDATED,
|
||||
|
@ -451,6 +451,13 @@ class NotificationTransport(SerializerModel):
|
||||
|
||||
def send_email(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification via global email configuration"""
|
||||
if notification.user.email.strip() == "":
|
||||
LOGGER.info(
|
||||
"Discarding notification as user has no email address",
|
||||
user=notification.user,
|
||||
notification=notification,
|
||||
)
|
||||
return None
|
||||
subject_prefix = "authentik Notification: "
|
||||
context = {
|
||||
"key_value": {
|
||||
@ -480,7 +487,7 @@ class NotificationTransport(SerializerModel):
|
||||
}
|
||||
mail = TemplateEmailMessage(
|
||||
subject=subject_prefix + context["title"],
|
||||
to=[f"{notification.user.name} <{notification.user.email}>"],
|
||||
to=[(notification.user.name, notification.user.email)],
|
||||
language=notification.user.locale(),
|
||||
template_name="email/event_notification.html",
|
||||
template_context=context,
|
||||
|
@ -3,9 +3,10 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.models import Application, Token, TokenIntents
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestEventsMiddleware(APITestCase):
|
||||
@ -47,3 +48,30 @@ class TestEventsMiddleware(APITestCase):
|
||||
context__model__name="test-delete",
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_create_with_api(self):
|
||||
"""Test model creation event (with API token auth)"""
|
||||
self.client.logout()
|
||||
token = Token.objects.create(user=self.user, intent=TokenIntents.INTENT_API, expiring=False)
|
||||
uid = generate_id()
|
||||
self.client.post(
|
||||
reverse("authentik_api:application-list"),
|
||||
data={"name": uid, "slug": uid},
|
||||
HTTP_AUTHORIZATION=f"Bearer {token.key}",
|
||||
)
|
||||
self.assertTrue(Application.objects.filter(name=uid).exists())
|
||||
event = Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED,
|
||||
context__model__model_name="application",
|
||||
context__model__app="authentik_core",
|
||||
context__model__name=uid,
|
||||
).first()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(
|
||||
event.user,
|
||||
{
|
||||
"pk": self.user.pk,
|
||||
"email": self.user.email,
|
||||
"username": self.user.username,
|
||||
},
|
||||
)
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""flow views tests"""
|
||||
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.test.client import RequestFactory
|
||||
@ -18,7 +19,12 @@ from authentik.flows.models import (
|
||||
from authentik.flows.planner import FlowPlan, FlowPlanner
|
||||
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView
|
||||
from authentik.flows.views.executor import (
|
||||
NEXT_ARG_NAME,
|
||||
QS_QUERY,
|
||||
SESSION_KEY_PLAN,
|
||||
FlowExecutorView,
|
||||
)
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
@ -121,16 +127,73 @@ class TestFlowExecutor(FlowTestCase):
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
)
|
||||
def test_invalid_flow_redirect(self):
|
||||
"""Tests that an invalid flow still redirects"""
|
||||
"""Test invalid flow with valid redirect destination"""
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
|
||||
dest = "/unique-string"
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}")
|
||||
response = self.client.get(url + f"?{QS_QUERY}={urlencode({NEXT_ARG_NAME: dest})}")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
|
||||
self.assertEqual(response.url, "/unique-string")
|
||||
|
||||
@patch(
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
)
|
||||
def test_invalid_flow_invalid_redirect(self):
|
||||
"""Test invalid flow redirect with an invalid URL"""
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
|
||||
dest = "http://something.example.com/unique-string"
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
|
||||
response = self.client.get(url + f"?{QS_QUERY}={urlencode({NEXT_ARG_NAME: dest})}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow,
|
||||
component="ak-stage-access-denied",
|
||||
error_message="Invalid next URL",
|
||||
)
|
||||
|
||||
@patch(
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
)
|
||||
def test_valid_flow_redirect(self):
|
||||
"""Test valid flow with valid redirect destination"""
|
||||
flow = create_test_flow()
|
||||
|
||||
dest = "/unique-string"
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
|
||||
response = self.client.get(url + f"?{QS_QUERY}={urlencode({NEXT_ARG_NAME: dest})}")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, "/unique-string")
|
||||
|
||||
@patch(
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
)
|
||||
def test_valid_flow_invalid_redirect(self):
|
||||
"""Test valid flow redirect with an invalid URL"""
|
||||
flow = create_test_flow()
|
||||
|
||||
dest = "http://something.example.com/unique-string"
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
|
||||
response = self.client.get(url + f"?{QS_QUERY}={urlencode({NEXT_ARG_NAME: dest})}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow,
|
||||
component="ak-stage-access-denied",
|
||||
error_message="Invalid next URL",
|
||||
)
|
||||
|
||||
@patch(
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
|
@ -12,6 +12,7 @@ from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.generic import View
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@ -178,6 +179,8 @@ class FlowExecutorView(APIView):
|
||||
self.cancel()
|
||||
self._logger.debug("f(exec): Continuing existing plan")
|
||||
|
||||
# Initial flow request, check if we have an upstream query string passed in
|
||||
request.session[SESSION_KEY_GET] = get_params
|
||||
# Don't check session again as we've either already loaded the plan or we need to plan
|
||||
if not self.plan:
|
||||
request.session[SESSION_KEY_HISTORY] = []
|
||||
@ -192,8 +195,6 @@ class FlowExecutorView(APIView):
|
||||
# To match behaviour with loading an empty flow plan from cache,
|
||||
# we don't show an error message here, but rather call _flow_done()
|
||||
return self._flow_done()
|
||||
# Initial flow request, check if we have an upstream query string passed in
|
||||
request.session[SESSION_KEY_GET] = get_params
|
||||
# We don't save the Plan after getting the next stage
|
||||
# as it hasn't been successfully passed yet
|
||||
try:
|
||||
@ -392,7 +393,11 @@ class FlowExecutorView(APIView):
|
||||
NEXT_ARG_NAME, "authentik_core:root-redirect"
|
||||
)
|
||||
self.cancel()
|
||||
return to_stage_response(self.request, redirect_with_qs(next_param))
|
||||
if next_param and not is_url_absolute(next_param):
|
||||
return to_stage_response(self.request, redirect_with_qs(next_param))
|
||||
return to_stage_response(
|
||||
self.request, self.stage_invalid(error_message=_("Invalid next URL"))
|
||||
)
|
||||
|
||||
def stage_ok(self) -> HttpResponse:
|
||||
"""Callback called by stages upon successful completion.
|
||||
|
@ -36,8 +36,21 @@ class TestAuthorize(OAuthTestCase):
|
||||
|
||||
def test_invalid_grant_type(self):
|
||||
"""Test with invalid grant type"""
|
||||
OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
)
|
||||
with self.assertRaises(AuthorizeError):
|
||||
request = self.factory.get("/", data={"response_type": "invalid"})
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
"response_type": "invalid",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://local.invalid/Foo",
|
||||
},
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
|
||||
def test_invalid_client_id(self):
|
||||
@ -344,7 +357,12 @@ class TestAuthorize(OAuthTestCase):
|
||||
]
|
||||
)
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
provider.property_mappings.add(
|
||||
ScopeMapping.objects.create(
|
||||
name=generate_id(), scope_name="test", expression="""return {"sub": "foo"}"""
|
||||
)
|
||||
)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
state = generate_id()
|
||||
user = create_test_admin_user()
|
||||
self.client.force_login(user)
|
||||
@ -365,7 +383,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
"response_type": "id_token",
|
||||
"client_id": "test",
|
||||
"state": state,
|
||||
"scope": "openid",
|
||||
"scope": "openid test",
|
||||
"redirect_uri": "http://localhost",
|
||||
"nonce": generate_id(),
|
||||
},
|
||||
@ -390,6 +408,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
jwt = self.validate_jwt(token, provider)
|
||||
self.assertEqual(jwt["amr"], ["pwd"])
|
||||
self.assertEqual(jwt["sub"], "foo")
|
||||
self.assertAlmostEqual(
|
||||
jwt["exp"] - now().timestamp(),
|
||||
expires,
|
||||
|
@ -4,9 +4,10 @@ from urllib.parse import urlencode
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.models import Application, Group
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
@ -77,3 +78,23 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
+ "?"
|
||||
+ urlencode({QS_KEY_CODE: token.user_code}),
|
||||
)
|
||||
|
||||
def test_device_init_denied(self):
|
||||
"""Test device init"""
|
||||
group = Group.objects.create(name="foo")
|
||||
PolicyBinding.objects.create(
|
||||
group=group,
|
||||
target=self.application,
|
||||
order=0,
|
||||
)
|
||||
token = DeviceToken.objects.create(
|
||||
user_code="foo",
|
||||
provider=self.provider,
|
||||
)
|
||||
res = self.client.get(
|
||||
reverse("authentik_providers_oauth2_root:device-login")
|
||||
+ "?"
|
||||
+ urlencode({QS_KEY_CODE: token.user_code})
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn(b"Permission denied", res.content)
|
||||
|
@ -121,44 +121,18 @@ class OAuthAuthorizationParams:
|
||||
redirect_uri = query_dict.get("redirect_uri", "")
|
||||
|
||||
response_type = query_dict.get("response_type", "")
|
||||
grant_type = None
|
||||
# Determine which flow to use.
|
||||
if response_type in [ResponseTypes.CODE]:
|
||||
grant_type = GrantTypes.AUTHORIZATION_CODE
|
||||
elif response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
]:
|
||||
grant_type = GrantTypes.IMPLICIT
|
||||
elif response_type in [
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
grant_type = GrantTypes.HYBRID
|
||||
|
||||
# Grant type validation.
|
||||
if not grant_type:
|
||||
LOGGER.warning("Invalid response type", type=response_type)
|
||||
raise AuthorizeError(redirect_uri, "unsupported_response_type", "", state)
|
||||
|
||||
# Validate and check the response_mode against the predefined dict
|
||||
# Set to Query or Fragment if not defined in request
|
||||
response_mode = query_dict.get("response_mode", False)
|
||||
|
||||
if response_mode not in ResponseMode.values:
|
||||
response_mode = ResponseMode.QUERY
|
||||
|
||||
if grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
response_mode = ResponseMode.FRAGMENT
|
||||
|
||||
max_age = query_dict.get("max_age")
|
||||
return OAuthAuthorizationParams(
|
||||
client_id=query_dict.get("client_id", ""),
|
||||
redirect_uri=redirect_uri,
|
||||
response_type=response_type,
|
||||
response_mode=response_mode,
|
||||
grant_type=grant_type,
|
||||
grant_type="",
|
||||
scope=set(query_dict.get("scope", "").split()),
|
||||
state=state,
|
||||
nonce=query_dict.get("nonce"),
|
||||
@ -178,6 +152,7 @@ class OAuthAuthorizationParams:
|
||||
LOGGER.warning("Invalid client identifier", client_id=self.client_id)
|
||||
raise ClientIdError(client_id=self.client_id)
|
||||
self.check_redirect_uri()
|
||||
self.check_grant()
|
||||
self.check_scope(github_compat)
|
||||
self.check_nonce()
|
||||
self.check_code_challenge()
|
||||
@ -186,6 +161,34 @@ class OAuthAuthorizationParams:
|
||||
self.redirect_uri, "request_not_supported", self.grant_type, self.state
|
||||
)
|
||||
|
||||
def check_grant(self):
|
||||
"""Check grant"""
|
||||
# Determine which flow to use.
|
||||
if self.response_type in [ResponseTypes.CODE]:
|
||||
self.grant_type = GrantTypes.AUTHORIZATION_CODE
|
||||
elif self.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
]:
|
||||
self.grant_type = GrantTypes.IMPLICIT
|
||||
elif self.response_type in [
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
self.grant_type = GrantTypes.HYBRID
|
||||
|
||||
# Grant type validation.
|
||||
if not self.grant_type:
|
||||
LOGGER.warning("Invalid response type", type=self.response_type)
|
||||
raise AuthorizeError(self.redirect_uri, "unsupported_response_type", "", self.state)
|
||||
|
||||
if self.response_mode not in ResponseMode.values:
|
||||
self.response_mode = ResponseMode.QUERY
|
||||
|
||||
if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
self.response_mode = ResponseMode.FRAGMENT
|
||||
|
||||
def check_redirect_uri(self):
|
||||
"""Redirect URI validation."""
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
@ -257,9 +260,9 @@ class OAuthAuthorizationParams:
|
||||
if SCOPE_OFFLINE_ACCESS in self.scope:
|
||||
# https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
||||
if PROMPT_CONSENT not in self.prompt:
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "consent_required", self.grant_type, self.state
|
||||
)
|
||||
# Instead of ignoring the `offline_access` scope when `prompt`
|
||||
# isn't set to `consent`, we set override it ourselves
|
||||
self.prompt.add(PROMPT_CONSENT)
|
||||
if self.response_type not in [
|
||||
ResponseTypes.CODE,
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
|
@ -12,10 +12,11 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE, get_application
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -38,7 +39,9 @@ class DeviceView(View):
|
||||
).first()
|
||||
if not provider:
|
||||
return HttpResponseBadRequest()
|
||||
if not get_application(provider):
|
||||
try:
|
||||
_ = provider.application
|
||||
except Application.DoesNotExist:
|
||||
return HttpResponseBadRequest()
|
||||
self.provider = provider
|
||||
self.client_id = client_id
|
||||
|
@ -1,11 +1,10 @@
|
||||
"""Device flow views"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
from rest_framework.exceptions import ErrorDetail
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, IntegerField
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@ -18,6 +17,7 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO,
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.views.device_finish import (
|
||||
PLAN_CONTEXT_DEVICE,
|
||||
@ -44,48 +44,52 @@ def get_application(provider: OAuth2Provider) -> Optional[Application]:
|
||||
return None
|
||||
|
||||
|
||||
def validate_code(code: int, request: HttpRequest) -> Optional[HttpResponse]:
|
||||
"""Validate user token"""
|
||||
token = DeviceToken.objects.filter(
|
||||
user_code=code,
|
||||
).first()
|
||||
if not token:
|
||||
return None
|
||||
class CodeValidatorView(PolicyAccessView):
|
||||
"""Helper to validate frontside token"""
|
||||
|
||||
app = get_application(token.provider)
|
||||
if not app:
|
||||
return None
|
||||
def __init__(self, code: str, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.code = code
|
||||
|
||||
scope_descriptions = UserInfoView().get_scope_descriptions(token.scope, token.provider)
|
||||
planner = FlowPlanner(token.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: app,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_DEVICE: token,
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": app.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
|
||||
},
|
||||
def resolve_provider_application(self):
|
||||
self.token = DeviceToken.objects.filter(user_code=self.code).first()
|
||||
if not self.token:
|
||||
raise Application.DoesNotExist
|
||||
self.provider = self.token.provider
|
||||
self.application = self.token.provider.application
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs):
|
||||
scope_descriptions = UserInfoView().get_scope_descriptions(self.token.scope, self.provider)
|
||||
planner = FlowPlanner(self.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
planner.use_cache = False
|
||||
try:
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_DEVICE: self.token,
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": self.application.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
|
||||
},
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
LOGGER.warning("Flow not applicable to user")
|
||||
return None
|
||||
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
request.GET,
|
||||
flow_slug=self.token.provider.authorization_flow.slug,
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
LOGGER.warning("Flow not applicable to user")
|
||||
return None
|
||||
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
request.GET,
|
||||
flow_slug=token.provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
|
||||
class DeviceEntryView(View):
|
||||
class DeviceEntryView(PolicyAccessView):
|
||||
"""View used to initiate the device-code flow, url entered by endusers"""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
@ -95,7 +99,9 @@ class DeviceEntryView(View):
|
||||
LOGGER.info("Brand has no device code flow configured", brand=brand)
|
||||
return HttpResponse(status=404)
|
||||
if QS_KEY_CODE in request.GET:
|
||||
validation = validate_code(request.GET[QS_KEY_CODE], request)
|
||||
validation = CodeValidatorView(request.GET[QS_KEY_CODE], request=request).dispatch(
|
||||
request
|
||||
)
|
||||
if validation:
|
||||
return validation
|
||||
LOGGER.info("Got code from query parameter but no matching token found")
|
||||
@ -130,6 +136,13 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
|
||||
code = IntegerField()
|
||||
component = CharField(default="ak-provider-oauth2-device-code")
|
||||
|
||||
def validate_code(self, code: int) -> HttpResponse | None:
|
||||
"""Validate code and save the returned http response"""
|
||||
response = CodeValidatorView(code, request=self.stage.request).dispatch(self.stage.request)
|
||||
if not response:
|
||||
raise ValidationError(_("Invalid code"), "invalid")
|
||||
return response
|
||||
|
||||
|
||||
class OAuthDeviceCodeStage(ChallengeStageView):
|
||||
"""Flow challenge for users to enter device codes"""
|
||||
@ -145,12 +158,4 @@ class OAuthDeviceCodeStage(ChallengeStageView):
|
||||
)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
code = response.validated_data["code"]
|
||||
validation = validate_code(code, self.request)
|
||||
if not validation:
|
||||
response._errors.setdefault("code", [])
|
||||
response._errors["code"].append(ErrorDetail(_("Invalid code"), "invalid"))
|
||||
return self.challenge_invalid(response)
|
||||
# Run cancel to cleanup the current flow
|
||||
self.executor.cancel()
|
||||
return validation
|
||||
return response.validated_data["code"]
|
||||
|
@ -101,8 +101,8 @@ class UserInfoView(View):
|
||||
value=value,
|
||||
)
|
||||
continue
|
||||
LOGGER.debug("updated scope", scope=scope)
|
||||
always_merger.merge(final_claims, value)
|
||||
LOGGER.debug("updated scope", scope=scope)
|
||||
return final_claims
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
@ -121,8 +121,9 @@ class UserInfoView(View):
|
||||
"""Handle GET Requests for UserInfo"""
|
||||
if not self.token:
|
||||
return HttpResponseBadRequest()
|
||||
claims = self.get_claims(self.token.provider, self.token)
|
||||
claims["sub"] = self.token.id_token.sub
|
||||
claims = {}
|
||||
claims.setdefault("sub", self.token.id_token.sub)
|
||||
claims.update(self.get_claims(self.token.provider, self.token))
|
||||
if self.token.id_token.nonce:
|
||||
claims["nonce"] = self.token.id_token.nonce
|
||||
response = TokenResponse(claims)
|
||||
|
@ -7,6 +7,8 @@ from psycopg import connect
|
||||
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
QUERY = """SELECT id FROM public.authentik_install_id ORDER BY id LIMIT 1;"""
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_install_id() -> str:
|
||||
@ -18,7 +20,7 @@ def get_install_id() -> str:
|
||||
if settings.TEST:
|
||||
return str(uuid4())
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT id FROM public.authentik_install_id LIMIT 1;")
|
||||
cursor.execute(QUERY)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
|
||||
@ -38,5 +40,5 @@ def get_install_id_raw():
|
||||
sslkey=CONFIG.get("postgresql.sslkey"),
|
||||
)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id FROM public.authentik_install_id LIMIT 1;")
|
||||
cursor.execute(QUERY)
|
||||
return cursor.fetchone()[0]
|
||||
|
@ -10,6 +10,7 @@ from webauthn import options_to_json
|
||||
from webauthn.helpers.bytes_to_base64url import bytes_to_base64url
|
||||
from webauthn.helpers.exceptions import InvalidRegistrationResponse
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorAttachment,
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialCreationOptions,
|
||||
ResidentKeyRequirement,
|
||||
@ -91,7 +92,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||
# set, cast it to string to ensure it's not a django class
|
||||
authenticator_attachment = stage.authenticator_attachment
|
||||
if authenticator_attachment:
|
||||
authenticator_attachment = str(authenticator_attachment)
|
||||
authenticator_attachment = AuthenticatorAttachment(str(authenticator_attachment))
|
||||
|
||||
registration_options: PublicKeyCredentialCreationOptions = generate_registration_options(
|
||||
rp_id=get_rp_id(self.request),
|
||||
|
@ -30,7 +30,7 @@ class Command(TenantCommand):
|
||||
delete_stage = True
|
||||
message = TemplateEmailMessage(
|
||||
subject="authentik Test-Email",
|
||||
to=[options["to"]],
|
||||
to=[("", options["to"])],
|
||||
template_name="email/setup.html",
|
||||
template_context={},
|
||||
)
|
||||
|
@ -111,7 +111,7 @@ class EmailStageView(ChallengeStageView):
|
||||
try:
|
||||
message = TemplateEmailMessage(
|
||||
subject=_(current_stage.subject),
|
||||
to=[f"{pending_user.name} <{email}>"],
|
||||
to=[(pending_user.name, email)],
|
||||
language=pending_user.locale(self.request),
|
||||
template_name=current_stage.template,
|
||||
template_context={
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% load i18n %}{% translate "Welcome!" %}
|
||||
{% load i18n %}{% autoescape off %}{% translate "Welcome!" %}
|
||||
|
||||
{% translate "We're excited to have you get started. First, you need to confirm your account. Just open the link below." %}
|
||||
|
||||
@ -6,3 +6,4 @@
|
||||
|
||||
--
|
||||
Powered by goauthentik.io.
|
||||
{% endautoescape %}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% load authentik_stages_email %}{% load i18n %}{% translate "Dear authentik user," %}
|
||||
{% load authentik_stages_email %}{% load i18n %}{% autoescape off %}{% translate "Dear authentik user," %}
|
||||
|
||||
{% translate "The following notification was created:" %}
|
||||
|
||||
@ -16,3 +16,4 @@ This email was sent from the notification transport {{ name }}.
|
||||
|
||||
--
|
||||
Powered by goauthentik.io.
|
||||
{% endautoescape %}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% load i18n %}{% load humanize %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}
|
||||
{% load i18n %}{% load humanize %}{% autoescape off %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}
|
||||
|
||||
{% blocktrans %}
|
||||
You recently requested to change your password for your authentik account. Use the link below to set a new password.
|
||||
@ -10,3 +10,4 @@ If you did not request a password change, please ignore this Email. The link abo
|
||||
|
||||
--
|
||||
Powered by goauthentik.io.
|
||||
{% endautoescape %}
|
||||
|
@ -1,7 +1,8 @@
|
||||
{% load i18n %}authentik Test-Email
|
||||
{% load i18n %}{% autoescape off %}authentik Test-Email
|
||||
{% blocktrans %}
|
||||
This is a test email to inform you, that you've successfully configured authentik emails.
|
||||
{% endblocktrans %}
|
||||
|
||||
--
|
||||
Powered by goauthentik.io.
|
||||
{% endautoescape %}
|
||||
|
@ -39,6 +39,7 @@ class TestEmailStageSending(FlowTestCase):
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
Event.objects.filter(action=EventAction.EMAIL_SENT).delete()
|
||||
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
with patch(
|
||||
|
@ -9,6 +9,7 @@ from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.locmem import EmailBackend
|
||||
from django.core.mail.message import sanitize_address
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
@ -19,6 +20,7 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.stages.email.models import EmailStage, get_template_choices
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
|
||||
def get_templates_setting(temp_dir: str) -> dict[str, Any]:
|
||||
@ -89,3 +91,12 @@ class TestEmailStageTemplates(FlowTestCase):
|
||||
event.context["message"], "Exception occurred while rendering E-mail template"
|
||||
)
|
||||
self.assertEqual(event.context["template"], "invalid.html")
|
||||
|
||||
def test_template_address(self):
|
||||
"""Test addresses are correctly parsed"""
|
||||
message = TemplateEmailMessage(to=[("foo@bar.baz", "foo@bar.baz")])
|
||||
[sanitize_address(addr, "utf-8") for addr in message.recipients()]
|
||||
self.assertEqual(message.recipients(), ["foo@bar.baz"])
|
||||
message = TemplateEmailMessage(to=[("some-name", "foo@bar.baz")])
|
||||
[sanitize_address(addr, "utf-8") for addr in message.recipients()]
|
||||
self.assertEqual(message.recipients(), ["some-name <foo@bar.baz>"])
|
||||
|
@ -25,8 +25,19 @@ def logo_data() -> MIMEImage:
|
||||
class TemplateEmailMessage(EmailMultiAlternatives):
|
||||
"""Wrapper around EmailMultiAlternatives with integrated template rendering"""
|
||||
|
||||
def __init__(self, template_name=None, template_context=None, language="", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
def __init__(
|
||||
self, to: list[tuple[str]], template_name=None, template_context=None, language="", **kwargs
|
||||
):
|
||||
sanitized_to = []
|
||||
# Ensure that all recipients are valid
|
||||
for recipient_name, recipient_email in to:
|
||||
if recipient_name == recipient_email:
|
||||
sanitized_to.append(recipient_email)
|
||||
else:
|
||||
sanitized_to.append(f"{recipient_name} <{recipient_email}>")
|
||||
super().__init__(to=sanitized_to, **kwargs)
|
||||
if not template_name:
|
||||
return
|
||||
with translation.override(language):
|
||||
html_content = render_to_string(template_name, template_context)
|
||||
try:
|
||||
|
@ -12,6 +12,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from authentik.core.middleware import SESSION_KEY_IMPERSONATE_USER
|
||||
from authentik.core.models import USER_ATTRIBUTE_SOURCES, User, UserSourceConnection, UserTypes
|
||||
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION
|
||||
from authentik.events.utils import sanitize_item
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
@ -47,7 +48,7 @@ class UserWriteStageView(StageView):
|
||||
# this is just a sanity check to ensure that is removed
|
||||
if parts[0] == "attributes":
|
||||
parts = parts[1:]
|
||||
set_path_in_dict(user.attributes, ".".join(parts), value)
|
||||
set_path_in_dict(user.attributes, ".".join(parts), sanitize_item(value))
|
||||
|
||||
def ensure_user(self) -> tuple[Optional[User], bool]:
|
||||
"""Ensure a user exists"""
|
||||
|
@ -87,11 +87,6 @@ class Tenant(TenantMixin, SerializerModel):
|
||||
raise IntegrityError("Cannot create schema named template")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
if self.schema_name in ("public", "template"):
|
||||
raise IntegrityError("Cannot delete schema public or template")
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def serializer(self) -> Serializer:
|
||||
from authentik.tenants.api.tenants import TenantSerializer
|
||||
|
14
authentik/tenants/signals.py
Normal file
14
authentik/tenants/signals.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""authentik tenants signals"""
|
||||
|
||||
from django.db import models
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django_tenants.utils import get_public_schema_name
|
||||
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Tenant)
|
||||
def tenants_ensure_no_default_delete(sender, instance: Tenant, **kwargs):
|
||||
if instance.schema_name == get_public_schema_name():
|
||||
raise models.ProtectedError("Cannot delete schema public", instance)
|
@ -32,7 +32,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.2.1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.2.4}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -53,7 +53,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.2.1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.2.4}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
@ -50,12 +50,12 @@ type StorageConfig struct {
|
||||
}
|
||||
|
||||
type StorageMediaConfig struct {
|
||||
Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE_MEDIA_BACKEND"`
|
||||
Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__MEDIA__BACKEND"`
|
||||
File StorageFileConfig `yaml:"file"`
|
||||
}
|
||||
|
||||
type StorageFileConfig struct {
|
||||
Path string `yaml:"path" env:"AUTHENTIK_STORAGE_MEDIA_FILE_PATH"`
|
||||
Path string `yaml:"path" env:"AUTHENTIK_STORAGE__MEDIA__FILE__PATH"`
|
||||
}
|
||||
|
||||
type ErrorReportingConfig struct {
|
||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.2.1"
|
||||
const VERSION = "2024.2.4"
|
||||
|
@ -55,6 +55,7 @@ function cleanup {
|
||||
}
|
||||
|
||||
function prepare_debug {
|
||||
source ${VENV_PATH}/bin/activate
|
||||
poetry install --no-ansi --no-interaction
|
||||
touch /unittest.xml
|
||||
chown authentik:authentik /unittest.xml
|
||||
|
@ -64,6 +64,7 @@ def release_lock(cursor: Cursor):
|
||||
"""Release database lock"""
|
||||
if not LOCKED:
|
||||
return
|
||||
LOGGER.info("releasing database lock")
|
||||
cursor.execute("SELECT pg_advisory_unlock(%s)", (ADV_LOCK_UID,))
|
||||
|
||||
|
||||
|
12
lifecycle/system_migrations/template_schema.py
Normal file
12
lifecycle/system_migrations/template_schema.py
Normal file
@ -0,0 +1,12 @@
|
||||
from lifecycle.migrate import BaseMigration
|
||||
|
||||
|
||||
class Migration(BaseMigration):
|
||||
def needs_migration(self) -> bool:
|
||||
self.cur.execute(
|
||||
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'template';"
|
||||
)
|
||||
return not bool(self.cur.rowcount)
|
||||
|
||||
def run(self):
|
||||
self.cur.execute("CREATE SCHEMA IF NOT EXISTS template; COMMIT;")
|
@ -113,7 +113,7 @@ filterwarnings = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2024.2.1"
|
||||
version = "2024.2.4"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2024.2.1
|
||||
version: 2024.2.4
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
|
@ -183,7 +183,6 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel {
|
||||
<ak-multi-select
|
||||
label=${msg("AdditionalScopes")}
|
||||
name="propertyMappings"
|
||||
required
|
||||
.options=${scopePairs}
|
||||
.values=${scopeValues}
|
||||
.errorMessages=${errors?.propertyMappings ?? []}
|
||||
|
@ -83,7 +83,6 @@ export class ApplicationWizardAuthenticationByRAC extends BaseProviderPanel {
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Property mappings")}
|
||||
?required=${true}
|
||||
name="propertyMappings"
|
||||
>
|
||||
<select class="pf-c-form-control" multiple>
|
||||
|
@ -194,7 +194,6 @@ export class ApplicationWizardProviderSamlConfiguration extends BaseProviderPane
|
||||
<ak-multi-select
|
||||
label=${msg("Property Mappings")}
|
||||
name="propertyMappings"
|
||||
required
|
||||
.options=${propertyPairs}
|
||||
.values=${pmValues}
|
||||
.richhelp=${html` <p class="pf-c-form__helper-text">
|
||||
|
@ -123,7 +123,6 @@ export class ApplicationWizardAuthenticationBySCIM extends BaseProviderPanel {
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-multi-select
|
||||
label=${msg("User Property Mappings")}
|
||||
required
|
||||
name="propertyMappings"
|
||||
.options=${propertyPairs}
|
||||
.values=${pmUserValues}
|
||||
@ -136,7 +135,6 @@ export class ApplicationWizardAuthenticationBySCIM extends BaseProviderPanel {
|
||||
></ak-multi-select>
|
||||
<ak-multi-select
|
||||
label=${msg("Group Property Mappings")}
|
||||
required
|
||||
name="propertyMappingsGroup"
|
||||
.options=${propertyPairs}
|
||||
.values=${pmGroupValues}
|
||||
|
@ -13,7 +13,7 @@ import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
|
||||
|
||||
import { ConnectionToken, Endpoint, RACProvider, RacApi } from "@goauthentik/api";
|
||||
import { ConnectionToken, RACProvider, RacApi } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-rac-connection-token-list")
|
||||
export class ConnectionTokenListPage extends Table<ConnectionToken> {
|
||||
@ -53,18 +53,18 @@ export class ConnectionTokenListPage extends Table<ConnectionToken> {
|
||||
return html`<ak-forms-delete-bulk
|
||||
objectLabel=${msg("Connection Token(s)")}
|
||||
.objects=${this.selectedElements}
|
||||
.metadata=${(item: Endpoint) => {
|
||||
.metadata=${(item: ConnectionToken) => {
|
||||
return [
|
||||
{ key: msg("Name"), value: item.name },
|
||||
{ key: msg("Host"), value: item.host },
|
||||
{ key: msg("Endpoint"), value: item.endpointObj.name },
|
||||
{ key: msg("User"), value: item.user.username },
|
||||
];
|
||||
}}
|
||||
.usedBy=${(item: Endpoint) => {
|
||||
.usedBy=${(item: ConnectionToken) => {
|
||||
return new RacApi(DEFAULT_CONFIG).racConnectionTokensUsedByList({
|
||||
connectionTokenUuid: item.pk,
|
||||
});
|
||||
}}
|
||||
.delete=${(item: Endpoint) => {
|
||||
.delete=${(item: ConnectionToken) => {
|
||||
return new RacApi(DEFAULT_CONFIG).racConnectionTokensDestroy({
|
||||
connectionTokenUuid: item.pk,
|
||||
});
|
||||
|
@ -123,11 +123,7 @@ export class EndpointForm extends ModelForm<Endpoint, string> {
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Property mappings")}
|
||||
?required=${true}
|
||||
name="propertyMappings"
|
||||
>
|
||||
<ak-form-element-horizontal label=${msg("Property mappings")} name="propertyMappings">
|
||||
<select class="pf-c-form-control" multiple>
|
||||
${this.propertyMappings?.results.map((mapping) => {
|
||||
let selected = false;
|
||||
|
@ -135,7 +135,6 @@ export class RACProviderFormPage extends ModelForm<RACProvider, number> {
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Property mappings")}
|
||||
?required=${true}
|
||||
name="propertyMappings"
|
||||
>
|
||||
<select class="pf-c-form-control" multiple>
|
||||
|
@ -87,7 +87,11 @@ export class RACProviderViewPage extends AKElement {
|
||||
<section slot="page-overview" data-tab-title="${msg("Overview")}">
|
||||
${this.renderTabOverview()}
|
||||
</section>
|
||||
<section slot="page-connections" data-tab-title="${msg("Connections")}">
|
||||
<section
|
||||
slot="page-connections"
|
||||
data-tab-title="${msg("Connections")}"
|
||||
class="pf-c-page__main-section pf-m-no-padding-mobile"
|
||||
>
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__body">
|
||||
<ak-rac-connection-token-list
|
||||
|
@ -191,7 +191,6 @@ export class SAMLProviderFormPage extends BaseProviderForm<SAMLProvider> {
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Property mappings")}
|
||||
?required=${true}
|
||||
name="propertyMappings"
|
||||
>
|
||||
<select class="pf-c-form-control" multiple>
|
||||
|
@ -151,7 +151,6 @@ export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("User Property Mappings")}
|
||||
?required=${true}
|
||||
name="propertyMappings"
|
||||
>
|
||||
<select class="pf-c-form-control" multiple>
|
||||
@ -185,7 +184,6 @@ export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Group Property Mappings")}
|
||||
?required=${true}
|
||||
name="propertyMappingsGroup"
|
||||
>
|
||||
<select class="pf-c-form-control" multiple>
|
||||
|
@ -253,7 +253,6 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("User Property Mappings")}
|
||||
?required=${true}
|
||||
name="propertyMappings"
|
||||
>
|
||||
<select class="pf-c-form-control" multiple>
|
||||
@ -292,7 +291,6 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Group Property Mappings")}
|
||||
?required=${true}
|
||||
name="propertyMappingsGroup"
|
||||
>
|
||||
<select class="pf-c-form-control" multiple>
|
||||
|
@ -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 = "2024.2.1";
|
||||
export const VERSION = "2024.2.4";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
@ -207,7 +207,7 @@ export class IdentificationStage extends BaseStage<
|
||||
renderInput(): TemplateResult {
|
||||
let type: "text" | "email" = "text";
|
||||
if (!this.challenge?.userFields || this.challenge.userFields.length === 0) {
|
||||
return html`<p>${msg("Select one of the sources below to login.")}</p>`;
|
||||
return html`<p>${msg("Select one of the options below to continue.")}</p>`;
|
||||
}
|
||||
const fields = (this.challenge?.userFields || []).sort();
|
||||
// Check if the field should be *only* email to set the input type
|
||||
|
@ -180,7 +180,7 @@ export class UserSettingsFlowExecutor
|
||||
`authentik/user/flows: unsupported stage type ${this.challenge.component}`,
|
||||
);
|
||||
return html`
|
||||
<a href="/if/flow/${this.flowSlug}" class="pf-c-button pf-m-primary">
|
||||
<a href="/if/flow/${this.flowSlug}/" class="pf-c-button pf-m-primary">
|
||||
${msg("Open settings")}
|
||||
</a>
|
||||
`;
|
||||
|
@ -134,17 +134,17 @@ To check if your config has been applied correctly, you can run the following co
|
||||
|
||||
These settings affect where media files are stored. Those files include applications and sources icons. By default, they are stored on disk in the `/media` directory of the authentik container. S3 storage is also supported.
|
||||
|
||||
- `AUTHENTIK_STORAGE_MEDIA_BACKEND`: Where to store files. Valid values are `file` and `s3`. For `file` storage, files are stored in a `/media` directory in the container. For `s3`, see below.
|
||||
- `AUTHENTIK_STORAGE_MEDIA_S3_REGION`: S3 region where the bucket has been created. May be omitted depending on which S3 provider you use. No default.
|
||||
- `AUTHENTIK_STORAGE_MEDIA_S3_USE__SSL`: Whether to use HTTPS when talking to the S3 storage providers. Defaults to `true`.
|
||||
- `AUTHENTIK_STORAGE_MEDIA_S3_ENDPOINT`: Endpoint to use to talk to the S3 storage provider. Override the previous region and use_ssl settings. Must be a valid URL in the form of `https://s3.provider`. No default.
|
||||
- `AUTHENTIK_STORAGE_MEDIA_S3_SESSION__PROFILE`: Profile to use when using AWS SDK authentication. No default. Supports hot-reloading.
|
||||
- `AUTHENTIK_STORAGE_MEDIA_S3_ACCESS__KEY`: Access key to authenticate to S3. May be omitted if using AWS SDK authentication. Supports hot-reloading.
|
||||
- `AUTHENTIK_STORAGE_MEDIA_S3_SECRET__KEY`: Secret key to authenticate to S3. May be omitted if using AWS SDK authentication. Supports hot-reloading.
|
||||
- `AUTHENTIK_STORAGE_MEDIA_S3_SECURITY__TOKEN`: Security token to authenticate to S3. May be omitted. Supports hot-reloading.
|
||||
- `AUTHENTIK_STORAGE_MEDIA_S3_BUCKET__NAME`: Name of the bucket to use to store files.
|
||||
- `AUTHENTIK_STORAGE_MEDIA_S3_CUSTOM__DOMAIN`: Domain to use to create URLs for users. Mainly useful for non-AWS providers. May include a port. Must include the bucket. Example: `s3.company:8080/authentik-media`.
|
||||
- `AUTHENTIK_STORAGE_MEDIA_S3_SECURE__URLS`: Whether URLS created for users use `http` or `https`. Defaults to `true`.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__BACKEND`: Where to store files. Valid values are `file` and `s3`. For `file` storage, files are stored in a `/media` directory in the container. For `s3`, see below.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__S3__REGION`: S3 region where the bucket has been created. May be omitted depending on which S3 provider you use. No default.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__S3__USE_SSL`: Whether to use HTTPS when talking to the S3 storage providers. Defaults to `true`.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__S3__ENDPOINT`: Endpoint to use to talk to the S3 storage provider. Override the previous region and use_ssl settings. Must be a valid URL in the form of `https://s3.provider`. No default.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__S3__SESSION_PROFILE`: Profile to use when using AWS SDK authentication. No default. Supports hot-reloading.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__S3__ACCESS_KEY`: Access key to authenticate to S3. May be omitted if using AWS SDK authentication. Supports hot-reloading.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__S3__SECRET_KEY`: Secret key to authenticate to S3. May be omitted if using AWS SDK authentication. Supports hot-reloading.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__S3__SECURITY_TOKEN`: Security token to authenticate to S3. May be omitted. Supports hot-reloading.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__S3__BUCKET_NAME`: Name of the bucket to use to store files.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__S3__CUSTOM_DOMAIN`: Domain to use to create URLs for users. Mainly useful for non-AWS providers. May include a port. Must include the bucket. Example: `s3.company:8080/authentik-media`.
|
||||
- `AUTHENTIK_STORAGE__MEDIA__S3__SECURE_URLS`: Whether URLS created for users use `http` or `https`. Defaults to `true`.
|
||||
|
||||
## authentik Settings
|
||||
|
||||
@ -269,59 +269,6 @@ Disable the inbuilt update-checker. Defaults to `false`.
|
||||
- Kubeconfig
|
||||
- Existence of a docker socket
|
||||
|
||||
### `AUTHENTIK_AVATARS`
|
||||
|
||||
Configure how authentik should show avatars for users. Following values can be set:
|
||||
|
||||
Default: `gravatar,initials`
|
||||
|
||||
- `none`: Disables per-user avatars and just shows a 1x1 pixel transparent picture
|
||||
- `gravatar`: Uses gravatar with the user's email address
|
||||
- `initials`: Generated avatars based on the user's name
|
||||
- Any URL: If you want to use images hosted on another server, you can set any URL.
|
||||
|
||||
Additionally, these placeholders can be used:
|
||||
|
||||
- `%(username)s`: The user's username
|
||||
- `%(mail_hash)s`: The email address, md5 hashed
|
||||
- `%(upn)s`: The user's UPN, if set (otherwise an empty string)
|
||||
|
||||
Starting with authentik 2022.8, you can also use an attribute path like `attributes.something.avatar`, which can be used in combination with the file field to allow users to upload custom avatars for themselves.
|
||||
|
||||
Starting with authentik 2023.2, multiple modes can be set, and authentik will fallback to the next mode when no avatar could be found. For example, setting this to `gravatar,initials` will attempt to get an avatar from Gravatar, and if the user has not configured on there, it will fallback to a generated avatar.
|
||||
|
||||
### `AUTHENTIK_DEFAULT_USER_CHANGE_NAME`
|
||||
|
||||
:::info
|
||||
Requires authentik 2021.12.5
|
||||
:::
|
||||
|
||||
Enable the ability for users to change their name, defaults to `true`.
|
||||
|
||||
### `AUTHENTIK_DEFAULT_USER_CHANGE_EMAIL`
|
||||
|
||||
:::info
|
||||
Requires authentik 2021.12.1
|
||||
:::
|
||||
|
||||
Enable the ability for users to change their Email address, defaults to `false`.
|
||||
|
||||
### `AUTHENTIK_DEFAULT_USER_CHANGE_USERNAME`
|
||||
|
||||
:::info
|
||||
Requires authentik 2021.12.1
|
||||
:::
|
||||
|
||||
Enable the ability for users to change their Usernames, defaults to `false`.
|
||||
|
||||
### `AUTHENTIK_GDPR_COMPLIANCE`
|
||||
|
||||
:::info
|
||||
Requires authentik 2021.12.1
|
||||
:::
|
||||
|
||||
When enabled, all the events caused by a user will be deleted upon the user's deletion. Defaults to `true`.
|
||||
|
||||
### `AUTHENTIK_DEFAULT_TOKEN_LENGTH`
|
||||
|
||||
:::info
|
||||
@ -330,28 +277,6 @@ Requires authentik 2022.4.1
|
||||
|
||||
Configure the length of generated tokens. Defaults to 60.
|
||||
|
||||
### `AUTHENTIK_IMPERSONATION`
|
||||
|
||||
:::info
|
||||
Requires authentik 2022.4.2
|
||||
:::
|
||||
|
||||
Globally enable/disable impersonation. Defaults to `true`.
|
||||
|
||||
### `AUTHENTIK_FOOTER_LINKS`
|
||||
|
||||
:::info
|
||||
Requires authentik 2021.12.1
|
||||
:::
|
||||
|
||||
This option configures the footer links on the flow executor pages.
|
||||
|
||||
The setting can be used as follows:
|
||||
|
||||
```
|
||||
AUTHENTIK_FOOTER_LINKS='[{"name": "Link Name","href":"https://goauthentik.io"}]'
|
||||
```
|
||||
|
||||
### `AUTHENTIK_LDAP__TASK_TIMEOUT_HOURS`
|
||||
|
||||
:::info
|
||||
|
@ -40,12 +40,13 @@ authentik:
|
||||
postgresql:
|
||||
password: "ThisIsNotASecurePassword"
|
||||
|
||||
ingress:
|
||||
# Specify kubernetes ingress controller class name
|
||||
ingressClassName: nginx | traefik | kong
|
||||
enabled: true
|
||||
hosts:
|
||||
- authentik.domain.tld
|
||||
server:
|
||||
ingress:
|
||||
# Specify kubernetes ingress controller class name
|
||||
ingressClassName: nginx | traefik | kong
|
||||
enabled: true
|
||||
hosts:
|
||||
- authentik.domain.tld
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
|
@ -60,28 +60,28 @@ AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoi
|
||||
Add the following to your `.env` file:
|
||||
|
||||
```env
|
||||
AUTHENTIK_STORAGE_MEDIA_BACKEND=s3
|
||||
AUTHENTIK_STORAGE_MEDIA_S3_ACCESS__KEY=access_key
|
||||
AUTHENTIK_STORAGE_MEDIA_S3_SECRET__KEY=secret_key
|
||||
AUTHENTIK_STORAGE_MEDIA_S3_BUCKET__NAME=authentik-media
|
||||
AUTHENTIK_STORAGE__MEDIA__BACKEND=s3
|
||||
AUTHENTIK_STORAGE__MEDIA__S3__ACCESS_KEY=access_key
|
||||
AUTHENTIK_STORAGE__MEDIA__S3__SECRET_KEY=secret_key
|
||||
AUTHENTIK_STORAGE__MEDIA__S3__BUCKET_NAME=authentik-media
|
||||
```
|
||||
|
||||
If you're using AWS S3 as your S3 provider, add the following:
|
||||
|
||||
```env
|
||||
AUTHENTIK_STORAGE_MEDIA_S3_REGION=us-east-1 # Use the region of the bucket
|
||||
AUTHENTIK_STORAGE__MEDIA__S3__REGION=us-east-1 # Use the region of the bucket
|
||||
```
|
||||
|
||||
If you're not using AWS S3 as your S3 provider, add the following:
|
||||
|
||||
```env
|
||||
AUTHENTIK_STORAGE_MEDIA_S3_ENDPOINT=https://s3.provider
|
||||
AUTHENTIK_STORAGE_MEDIA_S3_CUSTOM__DOMAIN=s3.provider/authentik-media
|
||||
AUTHENTIK_STORAGE__MEDIA__S3__ENDPOINT=https://s3.provider
|
||||
AUTHENTIK_STORAGE__MEDIA__S3__CUSTOM_DOMAIN=s3.provider/authentik-media
|
||||
```
|
||||
|
||||
The `ENDPOINT` setting specifies how authentik talks to the S3 provider.
|
||||
|
||||
The `CUSTOM__DOMAIN` setting specifies how URLs are constructed to be shown on the web interface. For example, an object stored at `application-icons/application.png` with a `CUSTOM__DOMAIN` setting of `s3.provider/authentik-media` will result in a URL of `https://s3.provider/authentik-media/application-icons/application.png`. You can also use subdomains for your buckets depending on what your S3 provider offers: `authentik-media.s3.provider`. Whether HTTPS is used is controlled by the `AUTHENTIK_STORAGE_MEDIA_S3_SECURE__URLS` which defaults to true.
|
||||
The `CUSTOM_DOMAIN` setting specifies how URLs are constructed to be shown on the web interface. For example, an object stored at `application-icons/application.png` with a `CUSTOM__DOMAIN` setting of `s3.provider/authentik-media` will result in a URL of `https://s3.provider/authentik-media/application-icons/application.png`. You can also use subdomains for your buckets depending on what your S3 provider offers: `authentik-media.s3.provider`. Whether HTTPS is used is controlled by the `AUTHENTIK_STORAGE__MEDIA__S3__SECURE_URLS` which defaults to true.
|
||||
|
||||
For more control over settings, refer to the [configuration reference](./configuration.mdx#media-storage-settings)
|
||||
|
||||
@ -94,11 +94,11 @@ The following section assumes that the local storage path is `/media` and the bu
|
||||
Follow the setup steps above, and then migrate the files from your local directory to s3:
|
||||
|
||||
```bash
|
||||
aws s3 sync /media s3://authentik-media
|
||||
aws s3 sync /media s3://authentik-media/media
|
||||
```
|
||||
|
||||
#### From s3 to file
|
||||
|
||||
```bash
|
||||
aws s3 sync s3://authentik-media /media
|
||||
aws s3 sync s3://authentik-media/media /media
|
||||
```
|
||||
|
27
website/docs/security/CVE-2024-37905.md
Normal file
27
website/docs/security/CVE-2024-37905.md
Normal file
@ -0,0 +1,27 @@
|
||||
# CVE-2024-37905
|
||||
|
||||
_Reported by [@m2a2](https://github.com/m2a2)_
|
||||
|
||||
## Improper Authorization for Token modification
|
||||
|
||||
### Summary
|
||||
|
||||
Due to insufficient permission checks it was possible for any authenticated user to elevate their permissions to a superuser by creating an API token and changing the user the token belonged to.
|
||||
|
||||
### Patches
|
||||
|
||||
authentik 2024.6.0, 2024.4.3 and 2024.2.4 fix this issue, for other versions the workaround can be used.
|
||||
|
||||
### Details
|
||||
|
||||
By setting a token's user ID to the ID of a higher privileged user, the token will inherit the higher privileged access to the API. This can be used to change the password of the affected user or to modify the authentik configuration in a potentially malicious way.
|
||||
|
||||
### Workarounds
|
||||
|
||||
As a workaround it is possible to block any requests to `/api/v3/core/tokens*` at the reverse-proxy/load-balancer level. Doing so prevents this issue from being exploited.
|
||||
|
||||
### For more information
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
|
23
website/docs/security/CVE-2024-38371.md
Normal file
23
website/docs/security/CVE-2024-38371.md
Normal file
@ -0,0 +1,23 @@
|
||||
# CVE-2024-38371
|
||||
|
||||
_Reported by Stefan Zwanenburg_
|
||||
|
||||
## Insufficient access control for OAuth2 Device Code flow
|
||||
|
||||
### Impact
|
||||
|
||||
Due to a bug, access restrictions assigned to an application were not checked when using the OAuth2 Device code flow. This could potentially allow users without the correct authorization to get OAuth tokens for an application, and access the application.
|
||||
|
||||
### Patches
|
||||
|
||||
authentik 2024.6.0, 2024.4.3 and 2024.2.4 fix this issue, for other versions the workaround can be used.
|
||||
|
||||
### Workarounds
|
||||
|
||||
As authentik flows are still used as part of the OAuth2 Device code flow, it is possible to add access control to the configured flows.
|
||||
|
||||
### For more information
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
|
@ -410,6 +410,8 @@ const docsSidebar = {
|
||||
},
|
||||
items: [
|
||||
"security/policy",
|
||||
"security/CVE-2024-38371",
|
||||
"security/CVE-2024-37905",
|
||||
"security/CVE-2024-23647",
|
||||
"security/CVE-2024-21637",
|
||||
"security/CVE-2023-48228",
|
||||
|
Reference in New Issue
Block a user