Skip to content
Open
2 changes: 1 addition & 1 deletion authentik/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async def aauthenticate(
return user

def set_method(self, method: str, request: HttpRequest | None, **kwargs):
"""Set method data on current flow, if possbiel"""
"""Set method data on current flow, if possible"""
if not request:
return
# Since we can't directly pass other variables to signals, and we want to log the method
Expand Down
6 changes: 6 additions & 0 deletions authentik/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,9 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):

objects = UserManager()

# Used to track password hash changes outside the user changing their passwords
password_hash_changed = False

class Meta:
verbose_name = _("User")
verbose_name_plural = _("Users")
Expand Down Expand Up @@ -394,9 +397,12 @@ def check_password(self, raw_password: str) -> bool:
"""

def setter(raw_password):
old_password_hash = self.password
self.set_password(raw_password, signal=False)
# Password hash upgrades shouldn't be considered password changes.
self._password = None
self.password_hash_changed = self.password != old_password_hash

self.save(update_fields=["password"])

return check_password(raw_password, self.password, setter)
Expand Down
1 change: 1 addition & 0 deletions authentik/events/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PASSWORD_HASH_UPGRADE_REASON = "Password hash upgraded" # noqa # nosec
10 changes: 9 additions & 1 deletion authentik/events/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from authentik.blueprints.v1.importer import excluded_models
from authentik.core.models import Group, User
from authentik.events.constants import PASSWORD_HASH_UPGRADE_REASON
from authentik.events.models import Event, EventAction, Notification
from authentik.events.utils import model_to_dict
from authentik.lib.sentry import should_ignore_exception
Expand Down Expand Up @@ -202,8 +203,15 @@ def post_save_handler(
return
user = self.get_user(request)

context = {}
if isinstance(instance, User) and instance.password_hash_changed:
context["reason"] = PASSWORD_HASH_UPGRADE_REASON
instance.password_hash_changed = False

action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
thread = EventNewThread(
action, request, user=user, model=model_to_dict(instance), **context
)
thread.kwargs.update(thread_kwargs or {})
thread.run()

Expand Down
84 changes: 84 additions & 0 deletions authentik/events/tests/test_audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from unittest.mock import PropertyMock, patch

from django.contrib.auth.hashers import PBKDF2PasswordHasher
from django.urls import reverse
from rest_framework.test import APITestCase

from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.events.constants import PASSWORD_HASH_UPGRADE_REASON
from authentik.events.models import Event, EventAction
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.lib.generators import generate_id
from authentik.stages.identification.models import IdentificationStage, UserFields
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.models import PasswordStage


class TestAudit(APITestCase):
"""Test audit middleware"""

def setUp(self) -> None:
self.user = create_test_admin_user()
# Set up a flow for authentication
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
self.stage = IdentificationStage.objects.create(
name="identification",
user_fields=[UserFields.USERNAME],
pretend_user_exists=False,
)
pw_stage = PasswordStage.objects.create(name="password", backends=[BACKEND_INBUILT])
self.stage.password_stage = pw_stage
self.stage.save()
FlowStageBinding.objects.create(
target=self.flow,
stage=self.stage,
order=0,
)

def test_password_hash_updated(self):
"""
When Django is updated, it's possible that the password hash is also updated.

Due to an increase in the password hash rounds.
When this happens, we should log a MODEL_UPDATED event with a reason
explaining the password hash upgrade.
"""
with patch.object(
PBKDF2PasswordHasher,
"iterations",
new_callable=PropertyMock,
return_value=PBKDF2PasswordHasher.iterations + 100_000,
):
# During authentication,
# Django should detect that the hash needs to be updated and update it
form_data = {"uid_field": self.user.username, "password": self.user.username}
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
response = self.client.post(url, form_data)
self.assertEqual(response.status_code, 200)

events = Event.objects.filter(
action=EventAction.MODEL_UPDATED,
context__model__app="authentik_core",
context__model__model_name="user",
context__model__pk=self.user.pk,
context__reason=PASSWORD_HASH_UPGRADE_REASON,
)
self.assertTrue(events.exists())

def test_set_password_no_password_upgrade_reason(self):
"""Ensure that setting a password is not detected as a password hash upgrade."""
self.client.login(username=self.user.username, password=self.user.username)
response = self.client.post(
reverse("authentik_api:user-set-password", kwargs={"pk": self.user.pk}),
data={"password": generate_id()},
)
self.assertEqual(response.status_code, 204)

events = Event.objects.filter(
action=EventAction.MODEL_UPDATED,
context__model__app="authentik_core",
context__model__model_name="user",
context__model__pk=self.user.pk,
context__reason=PASSWORD_HASH_UPGRADE_REASON,
)
self.assertFalse(events.exists())
2 changes: 1 addition & 1 deletion scripts/test_docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ echo PG_PASS="$(openssl rand -base64 36 | tr -d '\n')" >.env
echo AUTHENTIK_SECRET_KEY="$(openssl rand -base64 60 | tr -d '\n')" >>.env
export COMPOSE_PROJECT_NAME="authentik-test-${AUTHENTIK_TAG}"

if [[ -v BUILD ]]; then
if [ -n "${BUILD:-}" ]; then
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When trying to test with docker. I discovered that this syntax wasn't POSIX compliant and didn't work on macos (with bash 3.2)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can confirm the same with zsh 5.9 (arm64-apple-darwin25.0)

echo AUTHENTIK_IMAGE="${AUTHENTIK_IMAGE}" >>.env
echo AUTHENTIK_TAG="${AUTHENTIK_TAG}" >>.env

Expand Down