Skip to content

Commit

Permalink
Merge branch 'main' into dev
Browse files Browse the repository at this point in the history
* main: (21 commits)
  web: bump API Client version (#11997)
  sources/kerberos: use new python-kadmin implementation (#11932)
  core: add ability to provide reason for impersonation (#11951)
  website/integrations:  update vcenter integration docs (#11768)
  core, web: update translations (#11995)
  website: bump postcss from 8.4.48 to 8.4.49 in /website (#11996)
  web: bump API Client version (#11992)
  blueprints: add default Password policy (#11793)
  stages/captcha: Run interactive captcha in Frame (#11857)
  core, web: update translations (#11979)
  core: bump packaging from 24.1 to 24.2 (#11985)
  core: bump ruff from 0.7.2 to 0.7.3 (#11986)
  core: bump msgraph-sdk from 1.11.0 to 1.12.0 (#11987)
  website: bump the docusaurus group in /website with 9 updates (#11988)
  website: bump postcss from 8.4.47 to 8.4.48 in /website (#11989)
  stages/password: use recovery flow from brand (#11953)
  core: bump golang.org/x/sync from 0.8.0 to 0.9.0 (#11962)
  web: bump cookie, swagger-client and express in /web (#11966)
  core, web: update translations (#11959)
  core: bump debugpy from 1.8.7 to 1.8.8 (#11961)
  ...
  • Loading branch information
kensternberg-authentik committed Nov 12, 2024
2 parents 5cc2c0f + 54bbdd5 commit 831797b
Show file tree
Hide file tree
Showing 70 changed files with 1,476 additions and 831 deletions.
3 changes: 2 additions & 1 deletion authentik/blueprints/tests/test_packaged.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ def tester(self: TestPackaged):
base = Path("blueprints/")
rel_path = Path(file_name).relative_to(base)
importer = Importer.from_string(BlueprintInstance(path=str(rel_path)).retrieve())
self.assertTrue(importer.validate()[0])
validation, logs = importer.validate()
self.assertTrue(validation, logs)
self.assertTrue(importer.apply())

return tester
Expand Down
15 changes: 13 additions & 2 deletions authentik/core/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,12 @@ def recovery_email(self, request: Request, pk: int) -> Response:

@permission_required("authentik_core.impersonate")
@extend_schema(
request=OpenApiTypes.NONE,
request=inline_serializer(
"ImpersonationSerializer",
{
"reason": CharField(required=True),
},
),
responses={
"204": OpenApiResponse(description="Successfully started impersonation"),
"401": OpenApiResponse(description="Access denied"),
Expand All @@ -679,6 +684,7 @@ def impersonate(self, request: Request, pk: int) -> Response:
LOGGER.debug("User attempted to impersonate", user=request.user)
return Response(status=401)
user_to_be = self.get_object()
reason = request.data.get("reason", "")
# Check both object-level perms and global perms
if not request.user.has_perm(
"authentik_core.impersonate", user_to_be
Expand All @@ -688,11 +694,16 @@ def impersonate(self, request: Request, pk: int) -> Response:
if user_to_be.pk == self.request.user.pk:
LOGGER.debug("User attempted to impersonate themselves", user=request.user)
return Response(status=401)
if not reason and request.tenant.impersonation_require_reason:
LOGGER.debug(
"User attempted to impersonate without providing a reason", user=request.user
)
return Response(status=401)

request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be

Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
Event.new(EventAction.IMPERSONATION_STARTED, reason=reason).from_http(request, user_to_be)

return Response(status=201)

Expand Down
32 changes: 26 additions & 6 deletions authentik/core/tests/test_impersonation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ def test_impersonate_simple(self):
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
)
),
data={"reason": "some reason"},
)

response = self.client.get(reverse("authentik_api:user-me"))
Expand All @@ -55,7 +56,8 @@ def test_impersonate_global(self):
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
)
),
data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 201)

Expand All @@ -75,7 +77,8 @@ def test_impersonate_scoped(self):
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
)
),
data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 201)

Expand All @@ -89,7 +92,8 @@ def test_impersonate_denied(self):
self.client.force_login(self.other_user)

response = self.client.post(
reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})
reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 403)

Expand All @@ -105,7 +109,8 @@ def test_impersonate_disabled(self):
self.client.force_login(self.user)

response = self.client.post(
reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk})
reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk}),
data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 401)

Expand All @@ -118,7 +123,22 @@ def test_impersonate_self(self):
self.client.force_login(self.user)

response = self.client.post(
reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})
reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 401)

response = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode())
self.assertEqual(response_body["user"]["username"], self.user.username)

def test_impersonate_reason_required(self):
"""test impersonation that user must provide reason"""
self.client.force_login(self.user)

response = self.client.post(
reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
data={"reason": ""},
)
self.assertEqual(response.status_code, 401)

Expand Down
2 changes: 1 addition & 1 deletion authentik/events/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def default_event_duration():
"""Default duration an Event is saved.
This is used as a fallback when no brand is available"""
try:
tenant = get_current_tenant()
tenant = get_current_tenant(only=["event_retention"])
return now() + timedelta_from_string(tenant.event_retention)
except Tenant.DoesNotExist:
return now() + timedelta(days=365)
Expand Down
4 changes: 4 additions & 0 deletions authentik/policies/password/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ def passes(self, request: PolicyRequest) -> PolicyResult:

def passes_static(self, password: str, request: PolicyRequest) -> PolicyResult:
"""Check static rules"""
error_message = self.error_message
if error_message == "":
error_message = _("Invalid password.")

if len(password) < self.length_min:
LOGGER.debug("password failed", check="static", reason="length")
return PolicyResult(False, self.error_message)
Expand Down
20 changes: 10 additions & 10 deletions authentik/sources/kerberos/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
from typing import Any

import gssapi
import kadmin
import pglock
from django.db import connection, models
from django.db.models.fields import b64decode
from django.http import HttpRequest
from django.shortcuts import reverse
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from kadmin import KAdmin
from kadmin.exceptions import PyKAdminException
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger

Expand All @@ -30,9 +31,8 @@
LOGGER = get_logger()


# python-kadmin leaks file descriptors. As such, this global is used to reuse
# existing kadmin connections instead of creating new ones, which results in less to no file
# descriptors leaks
# Creating kadmin connections is expensive. As such, this global is used to reuse
# existing kadmin connections instead of creating new ones
_kadmin_connections: dict[str, Any] = {}


Expand Down Expand Up @@ -198,13 +198,13 @@ def krb5_conf_path(self) -> str | None:
conf_path.write_text(self.krb5_conf)
return str(conf_path)

def _kadmin_init(self) -> "kadmin.KAdmin | None":
def _kadmin_init(self) -> KAdmin | None:
# kadmin doesn't use a ccache for its connection
# as such, we don't need to create a separate ccache for each source
if not self.sync_principal:
return None
if self.sync_password:
return kadmin.init_with_password(
return KAdmin.with_password(
self.sync_principal,
self.sync_password,
)
Expand All @@ -215,18 +215,18 @@ def _kadmin_init(self) -> "kadmin.KAdmin | None":
keytab_path.touch(mode=0o600)
keytab_path.write_bytes(b64decode(self.sync_keytab))
keytab = f"FILE:{keytab_path}"
return kadmin.init_with_keytab(
return KAdmin.with_keytab(
self.sync_principal,
keytab,
)
if self.sync_ccache:
return kadmin.init_with_ccache(
return KAdmin.with_ccache(
self.sync_principal,
self.sync_ccache,
)
return None

def connection(self) -> "kadmin.KAdmin | None":
def connection(self) -> KAdmin | None:
"""Get kadmin connection"""
if str(self.pk) not in _kadmin_connections:
kadm = self._kadmin_init()
Expand All @@ -246,7 +246,7 @@ def check_connection(self) -> dict[str, str]:
status["status"] = "no connection"
return status
status["principal_exists"] = kadm.principal_exists(self.sync_principal)
except kadmin.KAdminError as exc:
except PyKAdminException as exc:
status["status"] = str(exc)
return status

Expand Down
4 changes: 2 additions & 2 deletions authentik/sources/kerberos/signals.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""authentik kerberos source signals"""

import kadmin
from django.db.models.signals import post_save
from django.dispatch import receiver
from kadmin.exceptions import PyKAdminException
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger

Expand Down Expand Up @@ -48,7 +48,7 @@ def kerberos_sync_password(sender, user: User, password: str, **_):
source.connection().getprinc(user_source_connection.identifier).change_password(
password
)
except kadmin.KAdminError as exc:
except PyKAdminException as exc:
LOGGER.warning("failed to set Kerberos password", exc=exc, source=source)
Event.new(
EventAction.CONFIGURATION_ERROR,
Expand Down
6 changes: 3 additions & 3 deletions authentik/sources/kerberos/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

from typing import Any

import kadmin
from django.core.exceptions import FieldError
from django.db import IntegrityError, transaction
from kadmin import KAdmin
from structlog.stdlib import BoundLogger, get_logger

from authentik.core.expression.exceptions import (
Expand All @@ -30,7 +30,7 @@ class KerberosSync:

_source: KerberosSource
_logger: BoundLogger
_connection: "kadmin.KAdmin"
_connection: KAdmin
mapper: SourceMapper
user_manager: PropertyMappingManager
group_manager: PropertyMappingManager
Expand Down Expand Up @@ -161,7 +161,7 @@ def sync(self) -> int:

user_count = 0
with Krb5ConfContext(self._source):
for principal in self._connection.principals():
for principal in self._connection.list_principals(None):
if self._handle_principal(principal):
user_count += 1
return user_count
1 change: 1 addition & 0 deletions authentik/sources/kerberos/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def setUp(self):
)
self.user = User.objects.create(username=generate_id())
self.user.set_unusable_password()
self.user.save()
UserKerberosSourceConnection.objects.create(
source=self.source, user=self.user, identifier=self.realm.user_princ
)
Expand Down
3 changes: 3 additions & 0 deletions authentik/sources/kerberos/tests/test_spnego.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from base64 import b64decode, b64encode
from pathlib import Path
from sys import platform
from unittest import skipUnless

import gssapi
from django.urls import reverse
Expand Down Expand Up @@ -36,6 +38,7 @@ def test_api_read(self):
)
self.assertEqual(response.status_code, 200)

@skipUnless(platform.startswith("linux"), "Requires compatible GSSAPI implementation")
def test_source_login(self):
"""test login view"""
response = self.client.get(
Expand Down
1 change: 1 addition & 0 deletions authentik/stages/captcha/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Meta:
"private_key",
"js_url",
"api_url",
"interactive",
"score_min_threshold",
"score_max_threshold",
"error_on_invalid_score",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.9 on 2024-10-30 14:28

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("authentik_stages_captcha", "0003_captchastage_error_on_invalid_score_and_more"),
]

operations = [
migrations.AddField(
model_name="captchastage",
name="interactive",
field=models.BooleanField(default=False),
),
]
4 changes: 3 additions & 1 deletion authentik/stages/captcha/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@


class CaptchaStage(Stage):
"""Verify the user is human using Google's reCaptcha."""
"""Verify the user is human using Google's reCaptcha/other compatible CAPTCHA solutions."""

public_key = models.TextField(help_text=_("Public key, acquired your captcha Provider."))
private_key = models.TextField(help_text=_("Private key, acquired your captcha Provider."))

interactive = models.BooleanField(default=False)

score_min_threshold = models.FloatField(default=0.5) # Default values for reCaptcha
score_max_threshold = models.FloatField(default=1.0) # Default values for reCaptcha

Expand Down
9 changes: 6 additions & 3 deletions authentik/stages/captcha/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.http.response import HttpResponse
from django.utils.translation import gettext as _
from requests import RequestException
from rest_framework.fields import CharField
from rest_framework.fields import BooleanField, CharField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger

Expand All @@ -24,10 +24,12 @@
class CaptchaChallenge(WithUserInfoChallenge):
"""Site public key"""

site_key = CharField()
js_url = CharField()
component = CharField(default="ak-stage-captcha")

site_key = CharField(required=True)
js_url = CharField(required=True)
interactive = BooleanField(required=True)


def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str):
"""Validate captcha token"""
Expand Down Expand Up @@ -103,6 +105,7 @@ def get_challenge(self, *args, **kwargs) -> Challenge:
data={
"js_url": self.executor.current_stage.js_url,
"site_key": self.executor.current_stage.public_key,
"interactive": self.executor.current_stage.interactive,
}
)

Expand Down
1 change: 1 addition & 0 deletions authentik/stages/identification/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ def get_challenge(self) -> Challenge:
{
"js_url": current_stage.captcha_stage.js_url,
"site_key": current_stage.captcha_stage.public_key,
"interactive": current_stage.captcha_stage.interactive,
}
if current_stage.captcha_stage
else None
Expand Down
8 changes: 4 additions & 4 deletions authentik/stages/password/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
WithUserInfoChallenge,
)
from authentik.flows.exceptions import StageInvalidException
from authentik.flows.models import Flow, FlowDesignation, Stage
from authentik.flows.models import Flow, Stage
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
from authentik.lib.utils.reflection import path_to_class
Expand Down Expand Up @@ -141,11 +141,11 @@ def get_challenge(self) -> Challenge:
"allow_show_password": self.executor.current_stage.allow_show_password,
}
)
recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY)
if recovery_flow.exists():
recovery_flow: Flow | None = self.request.brand.flow_recovery
if recovery_flow:
recover_url = reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": recovery_flow.first().slug},
kwargs={"flow_slug": recovery_flow.slug},
)
challenge.initial_data["recovery_url"] = self.request.build_absolute_uri(recover_url)
return challenge
Expand Down
Loading

0 comments on commit 831797b

Please sign in to comment.