Skip to content

Commit

Permalink
Merge branch 'main' into web/hint-element
Browse files Browse the repository at this point in the history
* main: (22 commits)
  lifecycle: fix install_id migration not running (#7116)
  core: bump Go from 1.20 to 1.21 (#7117)
  providers/ldap: add windows adsi support (#7098)
  web: bump API Client version (#7113)
  translate: Updates for file web/xliff/en.xlf in zh-Hans on branch main (#7112)
  translate: Updates for file web/xliff/en.xlf in zh_CN on branch main (#7111)
  web: bump the wdio group in /tests/wdio with 4 updates (#7108)
  core/api: add uuid field to core api user http response (#7110)
  core: bump goauthentik.io/api/v3 from 3.2023083.4 to 3.2023083.5 (#7105)
  core: bump golang.org/x/oauth2 from 0.12.0 to 0.13.0 (#7106)
  web: bump the eslint group in /tests/wdio with 1 update (#7107)
  providers/proxy: improve SLO by backchannel logging out sessions (#7099)
  web: bump @rollup/plugin-node-resolve from 15.2.2 to 15.2.3 in /web (#7104)
  web: bump the eslint group in /web with 1 update (#7103)
  web: bump the storybook group in /web with 1 update (#7102)
  web: bump API Client version (#7101)
  providers/saml: add default RelayState value for IDP-initiated requests (#7100)
  lifecycle: improve reliability of system migrations (#7089)
  sources/ldap: fix attribute path resolution (#7090)
  root: Ignore the vendor folder (#7094)
  ...
  • Loading branch information
kensternberg-authentik committed Oct 9, 2023
2 parents 5d9a3ce + a22bc5a commit 9640039
Show file tree
Hide file tree
Showing 59 changed files with 765 additions and 542 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci-outpost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.52.2
version: v1.54.2
args: --timeout 5000s --verbose
skip-cache: true
test-unittest:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,6 @@ data/
.netlify
.ruff_cache
source_docs/

### Golang ###
/vendor/
1 change: 1 addition & 0 deletions authentik/core/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ class Meta:
"uid",
"path",
"type",
"uuid",
]
extra_kwargs = {
"name": {"allow_blank": True},
Expand Down
30 changes: 19 additions & 11 deletions authentik/lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@


def get_path_from_dict(root: dict, path: str, sep=".", default=None) -> Any:
"""Recursively walk through `root`, checking each part of `path` split by `sep`.
"""Recursively walk through `root`, checking each part of `path` separated by `sep`.
If at any point a dict does not exist, return default"""
for comp in path.split(sep):
if root and comp in root:
Expand All @@ -34,7 +34,19 @@ def get_path_from_dict(root: dict, path: str, sep=".", default=None) -> Any:
return root


@dataclass
def set_path_in_dict(root: dict, path: str, value: Any, sep="."):
"""Recursively walk through `root`, checking each part of `path` separated by `sep`
and setting the last value to `value`"""
# Walk each component of the path
path_parts = path.split(sep)
for comp in path_parts[:-1]:
if comp not in root:
root[comp] = {}
root = root.get(comp, {})
root[path_parts[-1]] = value


@dataclass(slots=True)
class Attr:
"""Single configuration attribute"""

Expand All @@ -55,6 +67,10 @@ class Source(Enum):
# to the config file containing this change or the file containing this value
source: Optional[str] = field(default=None)

def __post_init__(self):
if isinstance(self.value, Attr):
raise RuntimeError(f"config Attr with nested Attr for source {self.source}")


class AttrEncoder(JSONEncoder):
"""JSON encoder that can deal with `Attr` classes"""
Expand Down Expand Up @@ -227,15 +243,7 @@ def get_bool(self, path: str, default=False) -> bool:

def set(self, path: str, value: Any, sep="."):
"""Set value using same syntax as get()"""
# Walk sub_dicts before parsing path
root = self.raw
# Walk each component of the path
path_parts = path.split(sep)
for comp in path_parts[:-1]:
if comp not in root:
root[comp] = {}
root = root.get(comp, {})
root[path_parts[-1]] = Attr(value)
set_path_in_dict(self.raw, path, Attr(value), sep=sep)


CONFIG = ConfigLoader()
Expand Down
14 changes: 14 additions & 0 deletions authentik/outposts/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ class WebsocketMessageInstruction(IntEnum):
# Message sent by us to trigger an Update
TRIGGER_UPDATE = 2

# Provider specific message
PROVIDER_SPECIFIC = 3


@dataclass(slots=True)
class WebsocketMessage:
Expand Down Expand Up @@ -131,3 +134,14 @@ def event_update(self, event): # pragma: no cover
self.send_json(
asdict(WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE))
)

def event_provider_specific(self, event):
"""Event handler which can be called by provider-specific
implementations to send specific messages to the outpost"""
self.send_json(
asdict(
WebsocketMessage(
instruction=WebsocketMessageInstruction.PROVIDER_SPECIFIC, args=event
)
)
)
4 changes: 2 additions & 2 deletions authentik/outposts/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from typing import Any, Optional
from urllib.parse import urlparse

import yaml
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.cache import cache
Expand All @@ -16,6 +15,7 @@
from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME
from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION
from structlog.stdlib import get_logger
from yaml import safe_load

from authentik.events.monitored_tasks import (
MonitoredTask,
Expand Down Expand Up @@ -279,7 +279,7 @@ def outpost_connection_discovery(self: MonitoredTask):
with kubeconfig_path.open("r", encoding="utf8") as _kubeconfig:
KubernetesServiceConnection.objects.create(
name=kubeconfig_local_name,
kubeconfig=yaml.safe_load(_kubeconfig),
kubeconfig=safe_load(_kubeconfig),
)
unix_socket_path = urlparse(DEFAULT_UNIX_SOCKET).path
socket = Path(unix_socket_path)
Expand Down
4 changes: 4 additions & 0 deletions authentik/providers/proxy/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ class AuthentikProviderProxyConfig(ManagedAppConfig):
label = "authentik_providers_proxy"
verbose_name = "authentik Providers.Proxy"
default = True

def reconcile_load_providers_proxy_signals(self):
"""Load proxy signals"""
self.import_module("authentik.providers.proxy.signals")
20 changes: 20 additions & 0 deletions authentik/providers/proxy/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Proxy provider signals"""
from django.contrib.auth.signals import user_logged_out
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.http import HttpRequest

from authentik.core.models import AuthenticatedSession, User
from authentik.providers.proxy.tasks import proxy_on_logout


@receiver(user_logged_out)
def logout_proxy_revoke_direct(sender: type[User], request: HttpRequest, **_):
"""Catch logout by direct logout and forward to proxy providers"""
proxy_on_logout.delay(request.session.session_key)


@receiver(pre_delete, sender=AuthenticatedSession)
def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
"""Catch logout by expiring sessions being deleted"""
proxy_on_logout.delay(instance.session_key)
20 changes: 20 additions & 0 deletions authentik/providers/proxy/tasks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""proxy provider tasks"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db import DatabaseError, InternalError, ProgrammingError

from authentik.outposts.models import Outpost, OutpostState, OutpostType
from authentik.providers.proxy.models import ProxyProvider
from authentik.root.celery import CELERY_APP

Expand All @@ -13,3 +16,20 @@ def proxy_set_defaults():
for provider in ProxyProvider.objects.all():
provider.set_oauth_defaults()
provider.save()


@CELERY_APP.task()
def proxy_on_logout(session_id: str):
"""Update outpost instances connected to a single outpost"""
layer = get_channel_layer()
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
for state in OutpostState.for_outpost(outpost):
for channel in state.channel_ids:
async_to_sync(layer.send)(
channel,
{
"type": "event.provider.specific",
"sub_type": "logout",
"session_id": session_id,
},
)
1 change: 1 addition & 0 deletions authentik/providers/saml/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ class Meta:
"signing_kp",
"verification_kp",
"sp_binding",
"default_relay_state",
"url_download_metadata",
"url_sso_post",
"url_sso_redirect",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.6 on 2023-10-08 20:29

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("authentik_providers_saml", "0012_managed"),
]

operations = [
migrations.AddField(
model_name="samlprovider",
name="default_relay_state",
field=models.TextField(
blank=True,
default="",
help_text="Default relay_state value for IDP-initiated logins",
),
),
]
4 changes: 4 additions & 0 deletions authentik/providers/saml/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ class SAMLProvider(Provider):
verbose_name=_("Signing Keypair"),
)

default_relay_state = models.TextField(
default="", blank=True, help_text=_("Default relay_state value for IDP-initiated logins")
)

@property
def launch_url(self) -> Optional[str]:
"""Use IDP-Initiated SAML flow as launch URL"""
Expand Down
5 changes: 4 additions & 1 deletion authentik/providers/saml/processors/authn_request_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,7 @@ def parse_detached(

def idp_initiated(self) -> AuthNRequest:
"""Create IdP Initiated AuthNRequest"""
return AuthNRequest()
relay_state = None
if self.provider.default_relay_state != "":
relay_state = self.provider.default_relay_state
return AuthNRequest(relay_state=relay_state)
8 changes: 8 additions & 0 deletions authentik/providers/saml/tests/test_auth_n_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.crypto.models import CertificateKeyPair
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import get_request
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
from authentik.providers.saml.processors.assertion import AssertionProcessor
Expand Down Expand Up @@ -264,3 +265,10 @@ def test_request_attributes_invalid(self):
events.first().context["message"],
"Failed to evaluate property-mapping: 'test'",
)

def test_idp_initiated(self):
"""Test IDP-initiated login"""
self.provider.default_relay_state = generate_id()
request = AuthNRequestParser(self.provider).idp_initiated()
self.assertEqual(request.id, None)
self.assertEqual(request.relay_state, self.provider.default_relay_state)
4 changes: 2 additions & 2 deletions authentik/sources/ldap/sync/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.events.models import Event, EventAction
from authentik.lib.config import CONFIG
from authentik.lib.config import CONFIG, set_path_in_dict
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
Expand Down Expand Up @@ -164,7 +164,7 @@ def _build_object_properties(
if object_field.startswith("attributes."):
# Because returning a list might desired, we can't
# rely on self._flatten here. Instead, just save the result as-is
properties["attributes"][object_field.replace("attributes.", "")] = value
set_path_in_dict(properties, object_field, value)
else:
properties[object_field] = self._flatten(value)
except PropertyMappingExpressionException as exc:
Expand Down
8 changes: 2 additions & 6 deletions authentik/stages/user_write/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.flows.views.executor import FlowExecutorView
from authentik.lib.config import set_path_in_dict
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
Expand Down Expand Up @@ -44,12 +45,7 @@ def write_attribute(user: User, key: str, value: Any):
# this is just a sanity check to ensure that is removed
if parts[0] == "attributes":
parts = parts[1:]
attrs = user.attributes
for comp in parts[:-1]:
if comp not in attrs:
attrs[comp] = {}
attrs = attrs.get(comp)
attrs[parts[-1]] = value
set_path_in_dict(user.attributes, ".".join(parts), value)

def ensure_user(self) -> tuple[Optional[User], bool]:
"""Ensure a user exists"""
Expand Down
Loading

0 comments on commit 9640039

Please sign in to comment.