diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 9380f4c54f..059e211c45 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -14,6 +14,7 @@ from django.utils.http import urlencode from django.utils.text import slugify from django.utils.timezone import now from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy from django_filters.filters import ( BooleanFilter, CharFilter, @@ -106,6 +107,10 @@ from authentik.stages.email.utils import TemplateEmailMessage LOGGER = get_logger() +INVALID_PASSWORD_HASH_MESSAGE = gettext_lazy( + "Invalid password hash format. Must be a valid Django password hash." +) + class ParamUserSerializer(PassiveSerializer): """Partial serializer for query parameters to select a user""" @@ -190,47 +195,79 @@ class UserSerializer(ModelSerializer): return RoleSerializer(instance.roles, many=True).data def __init__(self, *args, **kwargs): + """Setting password and permissions directly is allowed only in blueprints.""" super().__init__(*args, **kwargs) if SERIALIZER_CONTEXT_BLUEPRINT in self.context: self.fields["password"] = CharField(required=False, allow_null=True) + self.fields["password_hash"] = CharField(required=False, allow_null=True) self.fields["permissions"] = ListField( required=False, child=ChoiceField(choices=get_permission_choices()), ) def create(self, validated_data: dict) -> User: - """If this serializer is used in the blueprint context, we allow for - directly setting a password. However should be done via the `set_password` - method instead of directly setting it like rest_framework.""" - password = validated_data.pop("password", None) - perms_qs = Permission.objects.filter( - codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])] - ).values_list("content_type__app_label", "codename") - perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)] + """Create a user, with blueprint-only password and permission writes.""" + is_blueprint = SERIALIZER_CONTEXT_BLUEPRINT in self.context + if is_blueprint: + password = validated_data.pop("password", None) + password_hash = validated_data.pop("password_hash", None) + permissions = validated_data.pop("permissions", []) + self._validate_password_inputs(password, password_hash) + instance: User = super().create(validated_data) - self._set_password(instance, password) - instance.assign_perms_to_managed_role(perms_list) + if is_blueprint: + self._set_password(instance, password, password_hash) + perms_qs = Permission.objects.filter( + codename__in=[permission.split(".")[1] for permission in permissions] + ).values_list("content_type__app_label", "codename") + perms_list = [f"{ct}.{name}" for ct, name in perms_qs] + instance.assign_perms_to_managed_role(perms_list) + self._ensure_password_not_empty(instance) return instance def update(self, instance: User, validated_data: dict) -> User: - """Same as `create` above, set the password directly if we're in a blueprint - context""" - password = validated_data.pop("password", None) - perms_qs = Permission.objects.filter( - codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])] - ).values_list("content_type__app_label", "codename") - perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)] + """Update a user, with blueprint-only password and permission writes.""" + is_blueprint = SERIALIZER_CONTEXT_BLUEPRINT in self.context + if is_blueprint: + password = validated_data.pop("password", None) + password_hash = validated_data.pop("password_hash", None) + permissions = validated_data.pop("permissions", []) + self._validate_password_inputs(password, password_hash) + instance = super().update(instance, validated_data) - self._set_password(instance, password) - instance.assign_perms_to_managed_role(perms_list) + if is_blueprint: + self._set_password(instance, password, password_hash) + perms_qs = Permission.objects.filter( + codename__in=[permission.split(".")[1] for permission in permissions] + ).values_list("content_type__app_label", "codename") + perms_list = [f"{ct}.{name}" for ct, name in perms_qs] + instance.assign_perms_to_managed_role(perms_list) + self._ensure_password_not_empty(instance) return instance - def _set_password(self, instance: User, password: str | None): - """Set password of user if we're in a blueprint context, and if it's an empty - string then use an unusable password""" - if SERIALIZER_CONTEXT_BLUEPRINT in self.context and password: + def _validate_password_inputs(self, password: str | None, password_hash: str | None): + """Validate mutually-exclusive password inputs before any model mutation.""" + if password is not None and password_hash is not None: + raise ValidationError(_("Cannot set both password and password_hash. Use only one.")) + if password_hash is None: + return + try: + User.validate_password_hash(password_hash) + except ValueError as exc: + LOGGER.warning("Failed to identify password hash format", exc_info=exc) + raise ValidationError(INVALID_PASSWORD_HASH_MESSAGE) from exc + + def _set_password(self, instance: User, password: str | None, password_hash: str | None = None): + """Set password from plain text or hash.""" + if password_hash is not None: + instance.set_password_from_hash(password_hash) + instance.save() + elif password: instance.set_password(password) instance.save() + + def _ensure_password_not_empty(self, instance: User): + """Store an explicit unusable password instead of an empty password field.""" if len(instance.password) == 0: instance.set_unusable_password() instance.save() @@ -399,6 +436,12 @@ class UserPasswordSetSerializer(PassiveSerializer): password = CharField(required=True) +class UserPasswordHashSetSerializer(PassiveSerializer): + """Payload to set a users' password hash directly""" + + password = CharField(required=True) + + class UserServiceAccountSerializer(PassiveSerializer): """Payload to create a service account""" @@ -742,6 +785,11 @@ class UserViewSet( self.request.session.modified = True return Response(serializer.initial_data) + def _update_session_hash_after_password_change(self, request: Request, user: User): + if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session: + LOGGER.debug("Updating session hash after password change") + update_session_auth_hash(self.request, user) + @permission_required("authentik_core.reset_user_password") @extend_schema( request=UserPasswordSetSerializer, @@ -765,9 +813,45 @@ class UserViewSet( except (ValidationError, IntegrityError) as exc: LOGGER.debug("Failed to set password", exc=exc) return Response(status=400) - if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session: - LOGGER.debug("Updating session hash after password change") - update_session_auth_hash(self.request, user) + self._update_session_hash_after_password_change(request, user) + return Response(status=204) + + @permission_required("authentik_core.reset_user_password") + @extend_schema( + request=UserPasswordHashSetSerializer, + responses={ + 204: OpenApiResponse(description="Successfully changed password"), + 400: OpenApiResponse(description="Bad request"), + }, + ) + @action( + detail=True, + methods=["POST"], + permission_classes=[IsAuthenticated], + ) + @validate(UserPasswordHashSetSerializer) + def set_password_hash( + self, request: Request, pk: int, body: UserPasswordHashSetSerializer + ) -> Response: + """Set a user's password from a pre-hashed Django password value. + + Submit the Django password hash in the shared ``password`` request field. + + This updates authentik's local password verifier only. It does not attempt + to propagate the password change to LDAP or Kerberos because no raw password + is available from the request payload. + """ + user: User = self.get_object() + try: + user.set_password_from_hash(body.validated_data["password"], request=request) + user.save() + except ValueError as exc: + LOGGER.debug("Failed to set password hash", exc=exc) + return Response(data={"password": [INVALID_PASSWORD_HASH_MESSAGE]}, status=400) + except (ValidationError, IntegrityError) as exc: + LOGGER.debug("Failed to set password hash", exc=exc) + return Response(status=400) + self._update_session_hash_after_password_change(request, user) return Response(status=204) @permission_required("authentik_core.reset_user_password") diff --git a/authentik/core/management/commands/hash_password.py b/authentik/core/management/commands/hash_password.py new file mode 100644 index 0000000000..165a0970a5 --- /dev/null +++ b/authentik/core/management/commands/hash_password.py @@ -0,0 +1,28 @@ +"""Hash password using Django's password hashers""" + +from django.contrib.auth.hashers import make_password +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + """Hash a password using Django's password hashers""" + + help = "Hash a password for use with AUTHENTIK_BOOTSTRAP_PASSWORD_HASH" + + def add_arguments(self, parser): + parser.add_argument( + "password", + type=str, + help="Password to hash", + ) + + def handle(self, *args, **options): + password = options["password"] + + if not password: + raise CommandError("Password cannot be empty") + try: + hashed = make_password(password) + self.stdout.write(hashed) + except ValueError as exc: + raise CommandError(f"Error hashing password: {exc}") from exc diff --git a/authentik/core/models.py b/authentik/core/models.py index 96133530fd..d549ae7471 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -10,7 +10,7 @@ from uuid import uuid4 import pgtrigger from deepmerge import always_merger -from django.contrib.auth.hashers import check_password +from django.contrib.auth.hashers import check_password, identify_hasher from django.contrib.auth.models import AbstractUser, Permission from django.contrib.auth.models import UserManager as DjangoUserManager from django.contrib.sessions.base_session import AbstractBaseSession @@ -560,6 +560,33 @@ class User(SerializerModel, AttributesMixin, AbstractUser): self.password_change_date = now() return super().set_password(raw_password) + @staticmethod + def validate_password_hash(password_hash: str): + """Validate that the value is a recognized Django password hash.""" + identify_hasher(password_hash) # Raises ValueError if invalid + + def set_password_from_hash(self, password_hash: str, signal=True, sender=None, request=None): + """Set password directly from a pre-hashed value. + + Unlike set_password(), this does not hash the input again. The provided value + must already be a valid Django password hash, and it is stored directly on the + user after validation. + + Because no raw password is available, downstream password sync integrations + such as LDAP and Kerberos cannot be updated from this code path. + + Raises ValueError if the hash format is not recognized. + """ + self.validate_password_hash(password_hash) + if self.pk and signal: + from authentik.core.signals import password_hash_changed + + if not sender: + sender = self + password_hash_changed.send(sender=sender, user=self, request=request) + self.password = password_hash + self.password_change_date = now() + def check_password(self, raw_password: str) -> bool: """ Return a boolean of whether the raw_password was correct. Handles diff --git a/authentik/core/setup/signals.py b/authentik/core/setup/signals.py index 8d9ec68614..cd910761c6 100644 --- a/authentik/core/setup/signals.py +++ b/authentik/core/setup/signals.py @@ -16,7 +16,11 @@ LOGGER = get_logger() @receiver(post_startup) def post_startup_setup_bootstrap(sender, **_): - if not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD") and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN"): + if ( + not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD") + and not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD_HASH") + and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN") + ): return LOGGER.info("Configuring authentik through bootstrap environment variables") content = BlueprintInstance(path=BOOTSTRAP_BLUEPRINT).retrieve() diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 05cfaf2eb3..ff32007ddd 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -24,6 +24,8 @@ from authentik.root.ws.consumer import build_device_group # Arguments: user: User, password: str password_changed = Signal() +# Arguments: user: User, request: HttpRequest | None +password_hash_changed = Signal() # Arguments: credentials: dict[str, any], request: HttpRequest, # stage: Stage, context: dict[str, any] login_failed = Signal() diff --git a/authentik/core/tests/test_hash_password_command.py b/authentik/core/tests/test_hash_password_command.py new file mode 100644 index 0000000000..6b165b8acd --- /dev/null +++ b/authentik/core/tests/test_hash_password_command.py @@ -0,0 +1,28 @@ +"""Tests for hash_password management command.""" + +from io import StringIO + +from django.contrib.auth.hashers import check_password +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase + + +class TestHashPasswordCommand(TestCase): + """Test hash_password management command.""" + + def test_hash_password(self): + """Test hashing a password.""" + out = StringIO() + call_command("hash_password", "test123", stdout=out) + hashed = out.getvalue().strip() + + self.assertTrue(hashed.startswith("pbkdf2_sha256$")) + self.assertTrue(check_password("test123", hashed)) + + def test_hash_password_empty_fails(self): + """Test that empty password raises error.""" + with self.assertRaises(CommandError) as ctx: + call_command("hash_password", "") + + self.assertIn("Password cannot be empty", str(ctx.exception)) diff --git a/authentik/core/tests/test_setup.py b/authentik/core/tests/test_setup.py index 30fbe415e4..e8303ecc87 100644 --- a/authentik/core/tests/test_setup.py +++ b/authentik/core/tests/test_setup.py @@ -1,6 +1,7 @@ from http import HTTPStatus from os import environ +from django.contrib.auth.hashers import make_password from django.urls import reverse from authentik.blueprints.tests import apply_blueprint @@ -16,6 +17,7 @@ from authentik.tenants.flags import patch_flag class TestSetup(FlowTestCase): def tearDown(self): environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD", None) + environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD_HASH", None) environ.pop("AUTHENTIK_BOOTSTRAP_TOKEN", None) @patch_flag(Setup, True) @@ -154,3 +156,19 @@ class TestSetup(FlowTestCase): token = Token.objects.filter(identifier="authentik-bootstrap-token").first() self.assertEqual(token.intent, TokenIntents.INTENT_API) self.assertEqual(token.key, environ["AUTHENTIK_BOOTSTRAP_TOKEN"]) + + def test_setup_bootstrap_env_password_hash(self): + """Test setup with password hash env var""" + User.objects.filter(username="akadmin").delete() + Setup.set(False) + + password = generate_id() + password_hash = make_password(password) + environ["AUTHENTIK_BOOTSTRAP_PASSWORD_HASH"] = password_hash + pre_startup.send(sender=self) + post_startup.send(sender=self) + + self.assertTrue(Setup.get()) + user = User.objects.get(username="akadmin") + self.assertEqual(user.password, password_hash) + self.assertTrue(user.check_password(password)) diff --git a/authentik/core/tests/test_users.py b/authentik/core/tests/test_users.py index 8900058e48..fd4b2b2d5c 100644 --- a/authentik/core/tests/test_users.py +++ b/authentik/core/tests/test_users.py @@ -1,8 +1,15 @@ """user tests""" -from django.test.testcases import TestCase +from unittest.mock import patch +from django.contrib.auth.hashers import make_password +from django.test.testcases import TestCase +from rest_framework.exceptions import ValidationError + +from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT +from authentik.core.api.users import UserSerializer from authentik.core.models import User +from authentik.core.signals import password_changed, password_hash_changed from authentik.events.models import Event from authentik.lib.generators import generate_id @@ -33,3 +40,99 @@ class TestUsers(TestCase): self.assertEqual(Event.objects.count(), 1) user.ak_groups.all() self.assertEqual(Event.objects.count(), 1) + + def test_set_password_from_hash_signal_skips_source_sync_receivers(self): + """Test hash password updates do not expose a raw password to sync receivers.""" + user = User.objects.create( + username=generate_id(), + attributes={"distinguishedName": "cn=test,ou=users,dc=example,dc=com"}, + ) + password_changed_captured = [] + password_hash_changed_captured = [] + dispatch_uid = generate_id() + hash_dispatch_uid = generate_id() + + def password_changed_receiver(sender, **kwargs): + password_changed_captured.append(kwargs) + + def password_hash_changed_receiver(sender, **kwargs): + password_hash_changed_captured.append(kwargs) + + password_changed.connect(password_changed_receiver, dispatch_uid=dispatch_uid) + password_hash_changed.connect( + password_hash_changed_receiver, dispatch_uid=hash_dispatch_uid + ) + try: + with ( + patch( + "authentik.sources.ldap.signals.LDAPSource.objects.filter" + ) as ldap_sources_filter, + patch( + "authentik.sources.kerberos.signals." + "UserKerberosSourceConnection.objects.select_related" + ) as kerberos_connections_select, + ): + user.set_password_from_hash(make_password("new-password")) # nosec + user.save() + finally: + password_changed.disconnect(dispatch_uid=dispatch_uid) + password_hash_changed.disconnect(dispatch_uid=hash_dispatch_uid) + + self.assertEqual(password_changed_captured, []) + self.assertEqual(len(password_hash_changed_captured), 1) + ldap_sources_filter.assert_not_called() + kerberos_connections_select.assert_not_called() + + +class TestUserSerializerPasswordHash(TestCase): + """Test UserSerializer password_hash support in blueprint context.""" + + def test_password_hash_sets_password_directly(self): + """Test a valid password hash is stored without re-hashing.""" + password = "test-password-123" # nosec + password_hash = make_password(password) + serializer = UserSerializer( + data={ + "username": generate_id(), + "name": "Test User", + "password_hash": password_hash, + }, + context={SERIALIZER_CONTEXT_BLUEPRINT: True}, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + + self.assertEqual(user.password, password_hash) + self.assertTrue(user.check_password(password)) + self.assertIsNotNone(user.password_change_date) + + def test_password_hash_rejects_invalid_format(self): + """Test invalid password hash values are rejected.""" + serializer = UserSerializer( + data={ + "username": generate_id(), + "name": "Test User", + "password_hash": "not-a-valid-hash", + }, + context={SERIALIZER_CONTEXT_BLUEPRINT: True}, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + with self.assertRaises(ValidationError) as ctx: + serializer.save() + + self.assertIn("Invalid password hash format", str(ctx.exception)) + + def test_password_hash_ignored_outside_blueprint_context(self): + """Test password_hash is not accepted by the regular serializer.""" + serializer = UserSerializer( + data={ + "username": generate_id(), + "name": "Test User", + "password_hash": make_password("test"), # nosec + } + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertNotIn("password_hash", serializer.validated_data) diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index ef7069827f..67749fd984 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from json import loads +from django.contrib.auth.hashers import make_password from django.urls.base import reverse from django.utils.timezone import now from rest_framework.test import APITestCase @@ -26,6 +27,9 @@ from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignatio from authentik.lib.generators import generate_id, generate_key from authentik.stages.email.models import EmailStage +INVALID_PASSWORD_HASH = "not-a-valid-hash" +INVALID_PASSWORD_HASH_ERROR = "Invalid password hash format. Must be a valid Django password hash." + class TestUsersAPI(APITestCase): """Test Users API""" @@ -34,6 +38,20 @@ class TestUsersAPI(APITestCase): self.admin = create_test_admin_user() self.user = create_test_user() + def _set_password_hash(self, user: User, password_hash: str, client=None): + return (client or self.client).post( + reverse("authentik_api:user-set-password-hash", kwargs={"pk": user.pk}), + data={"password": password_hash}, + ) + + def _assert_password_hash_set( + self, user: User, password: str, password_hash: str, response + ) -> None: + self.assertEqual(response.status_code, 204, response.data) + user.refresh_from_db() + self.assertEqual(user.password, password_hash) + self.assertTrue(user.check_password(password)) + def test_filter_type(self): """Test API filtering by type""" self.client.force_login(self.admin) @@ -113,6 +131,26 @@ class TestUsersAPI(APITestCase): self.assertEqual(response.status_code, 400) self.assertJSONEqual(response.content, {"password": ["This field may not be blank."]}) + def test_set_password_hash(self): + """Test setting a user's password from a hash.""" + self.client.force_login(self.admin) + password = generate_key() + password_hash = make_password(password) + response = self._set_password_hash(self.user, password_hash) + + self._assert_password_hash_set(self.user, password, password_hash, response) + + def test_set_password_hash_invalid(self): + """Test invalid password hashes are rejected.""" + self.client.force_login(self.admin) + response = self._set_password_hash(self.user, INVALID_PASSWORD_HASH) + + self.assertEqual(response.status_code, 400) + self.assertJSONEqual( + response.content, + {"password": [INVALID_PASSWORD_HASH_ERROR]}, + ) + def test_recovery(self): """Test user recovery link""" flow = create_test_flow( @@ -261,6 +299,29 @@ class TestUsersAPI(APITestCase): self.assertTrue(token_filter.exists()) self.assertTrue(token_filter.first().expiring) + def test_service_account_set_password_hash(self): + """Service account password hash can be set through the API.""" + self.client.force_login(self.admin) + response = self.client.post( + reverse("authentik_api:user-service-account"), + data={ + "name": "test-sa", + "create_group": False, + }, + ) + self.assertEqual(response.status_code, 200, response.data) + body = loads(response.content) + + user = User.objects.get(pk=body["user_pk"]) + self.assertEqual(user.type, UserTypes.SERVICE_ACCOUNT) + self.assertFalse(user.has_usable_password()) + + password = generate_key() + password_hash = make_password(password) + response = self._set_password_hash(user, password_hash) + + self._assert_password_hash_set(user, password, password_hash, response) + def test_service_account_no_expire(self): """Service account creation without token expiration""" self.client.force_login(self.admin) diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index 0a1ac35c31..aaaa336d65 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -12,7 +12,7 @@ from authentik.core.models import ( User, UserTypes, ) -from authentik.core.signals import password_changed +from authentik.core.signals import password_changed, password_hash_changed from authentik.enterprise.providers.ssf.models import ( EventTypes, SSFProvider, @@ -84,14 +84,13 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi ) -@receiver(password_changed) -def ssf_password_changed_cred_change(sender, user: User, password: str | None, **_): +def _send_password_credential_change(user: User, change_type: str): """Credential change trigger (password changed)""" send_ssf_events( EventTypes.CAEP_CREDENTIAL_CHANGE, { "credential_type": "password", - "change_type": "revoke" if password is None else "update", + "change_type": change_type, }, sub_id={ "format": "complex", @@ -103,6 +102,16 @@ def ssf_password_changed_cred_change(sender, user: User, password: str | None, * ) +@receiver(password_hash_changed) +@receiver(password_changed) +def ssf_password_changed_cred_change(signal, sender, user: User, password: str | None = None, **_): + """Credential change trigger (password changed)""" + if signal is password_hash_changed: + _send_password_credential_change(user, "update") + return + _send_password_credential_change(user, "revoke" if password is None else "update") + + device_type_map = { StaticDevice: "pin", TOTPDevice: "pin", diff --git a/authentik/enterprise/providers/ssf/tests/test_signals.py b/authentik/enterprise/providers/ssf/tests/test_signals.py index 72c5423d3d..5bf1454a85 100644 --- a/authentik/enterprise/providers/ssf/tests/test_signals.py +++ b/authentik/enterprise/providers/ssf/tests/test_signals.py @@ -1,5 +1,6 @@ from uuid import uuid4 +from django.contrib.auth.hashers import make_password from django.urls import reverse from rest_framework.test import APITestCase @@ -52,6 +53,21 @@ class TestSignals(APITestCase): ) self.assertEqual(res.status_code, 201, res.content) + def _assert_password_credential_change(self, user, change_type: str): + stream = Stream.objects.filter(provider=self.provider).first() + self.assertIsNotNone(stream) + event = StreamEvent.objects.filter(stream=stream).first() + self.assertIsNotNone(event) + self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED) + event_payload = event.payload["events"][ + "https://schemas.openid.net/secevent/caep/event-type/credential-change" + ] + self.assertEqual(event_payload["change_type"], change_type) + self.assertEqual(event_payload["credential_type"], "password") + self.assertEqual(event.payload["sub_id"]["format"], "complex") + self.assertEqual(event.payload["sub_id"]["user"]["format"], "email") + self.assertEqual(event.payload["sub_id"]["user"]["email"], user.email) + def test_signal_logout(self): """Test user logout""" user = create_test_user() @@ -79,19 +95,25 @@ class TestSignals(APITestCase): user.set_password(generate_id()) user.save() - stream = Stream.objects.filter(provider=self.provider).first() - self.assertIsNotNone(stream) - event = StreamEvent.objects.filter(stream=stream).first() - self.assertIsNotNone(event) - self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED) - event_payload = event.payload["events"][ - "https://schemas.openid.net/secevent/caep/event-type/credential-change" - ] - self.assertEqual(event_payload["change_type"], "update") - self.assertEqual(event_payload["credential_type"], "password") - self.assertEqual(event.payload["sub_id"]["format"], "complex") - self.assertEqual(event.payload["sub_id"]["user"]["format"], "email") - self.assertEqual(event.payload["sub_id"]["user"]["email"], user.email) + self._assert_password_credential_change(user, "update") + + def test_signal_password_change_from_hash(self): + """Test user password change from a pre-hashed password.""" + user = create_test_user() + self.client.force_login(user) + user.set_password_from_hash(make_password(generate_id())) + user.save() + + self._assert_password_credential_change(user, "update") + + def test_signal_password_revoke(self): + """Test explicit password revoke.""" + user = create_test_user() + self.client.force_login(user) + user.set_password(None) + user.save() + + self._assert_password_credential_change(user, "revoke") def test_signal_authenticator_added(self): """Test authenticator creation signal""" diff --git a/authentik/events/signals.py b/authentik/events/signals.py index 5f0c2a43c4..b4291672d3 100644 --- a/authentik/events/signals.py +++ b/authentik/events/signals.py @@ -11,7 +11,7 @@ from django.http import HttpRequest from rest_framework.request import Request from authentik.core.models import AuthenticatedSession, User -from authentik.core.signals import login_failed, password_changed +from authentik.core.signals import login_failed, password_changed, password_hash_changed from authentik.events.models import Event, EventAction from authentik.flows.models import Stage from authentik.flows.planner import ( @@ -112,8 +112,15 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_ ) +@receiver(password_hash_changed) @receiver(password_changed) -def on_password_changed(sender, user: User, password: str, request: HttpRequest | None, **_): +def on_password_changed( + sender, + user: User, + password: str | None = None, + request: HttpRequest | None = None, + **_, +): """Log password change""" Event.new(EventAction.PASSWORD_SET).from_http(request, user=user) diff --git a/authentik/events/tests/test_event.py b/authentik/events/tests/test_event.py index 979dac0611..345d78052d 100644 --- a/authentik/events/tests/test_event.py +++ b/authentik/events/tests/test_event.py @@ -2,6 +2,7 @@ from urllib.parse import urlencode +from django.contrib.auth.hashers import make_password from django.contrib.contenttypes.models import ContentType from django.test import RequestFactory, TestCase from django.views.debug import SafeExceptionReporterFilter @@ -10,7 +11,7 @@ from guardian.shortcuts import get_anonymous_user from authentik.brands.models import Brand from authentik.core.models import Group, User from authentik.core.tests.utils import create_test_user -from authentik.events.models import Event +from authentik.events.models import Event, EventAction from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN from authentik.lib.generators import generate_id @@ -213,3 +214,14 @@ class TestEvents(TestCase): event = Event.new("unittest", foo="foo bar \u0000 baz") event.save() self.assertEqual(event.context["foo"], "foo bar baz") + + def test_password_set_signal_on_set_password_from_hash(self): + """Changing password from hash should still emit an audit event.""" + user = create_test_user() + old_count = Event.objects.filter(action=EventAction.PASSWORD_SET, user__pk=user.pk).count() + + user.set_password_from_hash(make_password(generate_id())) + user.save() + + new_count = Event.objects.filter(action=EventAction.PASSWORD_SET, user__pk=user.pk).count() + self.assertEqual(new_count, old_count + 1) diff --git a/blueprints/schema.json b/blueprints/schema.json index 6a04b567bd..7d9463cc7f 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -5537,6 +5537,14 @@ "minLength": 1, "title": "Password" }, + "password_hash": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "title": "Password hash" + }, "permissions": { "type": "array", "items": { diff --git a/blueprints/system/bootstrap.yaml b/blueprints/system/bootstrap.yaml index f35eb6c37b..625dff275e 100644 --- a/blueprints/system/bootstrap.yaml +++ b/blueprints/system/bootstrap.yaml @@ -11,6 +11,7 @@ context: group_name: authentik Admins email: !Env [AUTHENTIK_BOOTSTRAP_EMAIL, "root@example.com"] password: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD, null] + password_hash: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD_HASH, null] token: !Env [AUTHENTIK_BOOTSTRAP_TOKEN, null] entries: - model: authentik_core.group @@ -31,6 +32,7 @@ entries: groups: - !KeyOf admin-group password: !Context password + password_hash: !Context password_hash - model: authentik_core.token state: created conditions: diff --git a/packages/client-ts/src/apis/CoreApi.ts b/packages/client-ts/src/apis/CoreApi.ts index 4c05001ec7..467d21d679 100644 --- a/packages/client-ts/src/apis/CoreApi.ts +++ b/packages/client-ts/src/apis/CoreApi.ts @@ -54,6 +54,7 @@ import type { User, UserAccountRequest, UserConsent, + UserPasswordHashSetRequest, UserPasswordSetRequest, UserPath, UserRecoveryEmailRequest, @@ -104,6 +105,7 @@ import { UserAccountRequestToJSON, UserConsentFromJSON, UserFromJSON, + UserPasswordHashSetRequestToJSON, UserPasswordSetRequestToJSON, UserPathFromJSON, UserRecoveryEmailRequestToJSON, @@ -508,6 +510,11 @@ export interface CoreUsersSetPasswordCreateRequest { userPasswordSetRequest: UserPasswordSetRequest; } +export interface CoreUsersSetPasswordHashCreateRequest { + id: number; + userPasswordHashSetRequest: UserPasswordHashSetRequest; +} + export interface CoreUsersUpdateRequest { id: number; userRequest: UserRequest; @@ -5288,6 +5295,77 @@ export class CoreApi extends runtime.BaseAPI { await this.coreUsersSetPasswordCreateRaw(requestParameters, initOverrides); } + /** + * Creates request options for coreUsersSetPasswordHashCreate without sending the request + */ + async coreUsersSetPasswordHashCreateRequestOpts( + requestParameters: CoreUsersSetPasswordHashCreateRequest, + ): Promise { + if (requestParameters["id"] == null) { + throw new runtime.RequiredError( + "id", + 'Required parameter "id" was null or undefined when calling coreUsersSetPasswordHashCreate().', + ); + } + + if (requestParameters["userPasswordHashSetRequest"] == null) { + throw new runtime.RequiredError( + "userPasswordHashSetRequest", + 'Required parameter "userPasswordHashSetRequest" was null or undefined when calling coreUsersSetPasswordHashCreate().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters["Content-Type"] = "application/json"; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("authentik", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/core/users/{id}/set_password_hash/`; + urlPath = urlPath.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters["id"]))); + + return { + path: urlPath, + method: "POST", + headers: headerParameters, + query: queryParameters, + body: UserPasswordHashSetRequestToJSON(requestParameters["userPasswordHashSetRequest"]), + }; + } + + /** + * Set a user\'s password from a pre-hashed Django password value. Submit the Django password hash in the shared ``password`` request field. This updates authentik\'s local password verifier only. It does not attempt to propagate the password change to LDAP or Kerberos because no raw password is available from the request payload. + */ + async coreUsersSetPasswordHashCreateRaw( + requestParameters: CoreUsersSetPasswordHashCreateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = + await this.coreUsersSetPasswordHashCreateRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.VoidApiResponse(response); + } + + /** + * Set a user\'s password from a pre-hashed Django password value. Submit the Django password hash in the shared ``password`` request field. This updates authentik\'s local password verifier only. It does not attempt to propagate the password change to LDAP or Kerberos because no raw password is available from the request payload. + */ + async coreUsersSetPasswordHashCreate( + requestParameters: CoreUsersSetPasswordHashCreateRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + await this.coreUsersSetPasswordHashCreateRaw(requestParameters, initOverrides); + } + /** * Creates request options for coreUsersUpdate without sending the request */ diff --git a/packages/client-ts/src/models/UserPasswordHashSetRequest.ts b/packages/client-ts/src/models/UserPasswordHashSetRequest.ts new file mode 100644 index 0000000000..3bd15866e5 --- /dev/null +++ b/packages/client-ts/src/models/UserPasswordHashSetRequest.ts @@ -0,0 +1,70 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.5.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * Payload to set a users' password hash directly + * @export + * @interface UserPasswordHashSetRequest + */ +export interface UserPasswordHashSetRequest { + /** + * + * @type {string} + * @memberof UserPasswordHashSetRequest + */ + password: string; +} + +/** + * Check if a given object implements the UserPasswordHashSetRequest interface. + */ +export function instanceOfUserPasswordHashSetRequest( + value: object, +): value is UserPasswordHashSetRequest { + if (!("password" in value) || value["password"] === undefined) return false; + return true; +} + +export function UserPasswordHashSetRequestFromJSON(json: any): UserPasswordHashSetRequest { + return UserPasswordHashSetRequestFromJSONTyped(json, false); +} + +export function UserPasswordHashSetRequestFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): UserPasswordHashSetRequest { + if (json == null) { + return json; + } + return { + password: json["password"], + }; +} + +export function UserPasswordHashSetRequestToJSON(json: any): UserPasswordHashSetRequest { + return UserPasswordHashSetRequestToJSONTyped(json, false); +} + +export function UserPasswordHashSetRequestToJSONTyped( + value?: UserPasswordHashSetRequest | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + password: value["password"], + }; +} diff --git a/packages/client-ts/src/models/index.ts b/packages/client-ts/src/models/index.ts index 9688dc8cb3..43ada952b1 100644 --- a/packages/client-ts/src/models/index.ts +++ b/packages/client-ts/src/models/index.ts @@ -842,6 +842,7 @@ export * from "./UserLogoutStageRequest"; export * from "./UserMatchingModeEnum"; export * from "./UserOAuthSourceConnection"; export * from "./UserOAuthSourceConnectionRequest"; +export * from "./UserPasswordHashSetRequest"; export * from "./UserPasswordSetRequest"; export * from "./UserPath"; export * from "./UserPlexSourceConnection"; diff --git a/schema.yml b/schema.yml index 0b0d2b6885..8667febd50 100644 --- a/schema.yml +++ b/schema.yml @@ -4522,6 +4522,41 @@ paths: description: Bad request '403': $ref: '#/components/responses/GenericErrorResponse' + /core/users/{id}/set_password_hash/: + post: + operationId: core_users_set_password_hash_create + description: |- + Set a user's password from a pre-hashed Django password value. + + Submit the Django password hash in the shared ``password`` request field. + + This updates authentik's local password verifier only. It does not attempt + to propagate the password change to LDAP or Kerberos because no raw password + is available from the request payload. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User. + required: true + tags: + - core + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserPasswordHashSetRequest' + required: true + security: + - authentik: [] + responses: + '204': + description: Successfully changed password + '400': + description: Bad request + '403': + $ref: '#/components/responses/GenericErrorResponse' /core/users/{id}/used_by/: get: operationId: core_users_used_by_list @@ -57588,6 +57623,15 @@ components: - identifier - source - user + UserPasswordHashSetRequest: + type: object + description: Payload to set a users' password hash directly + properties: + password: + type: string + minLength: 1 + required: + - password UserPasswordSetRequest: type: object description: Payload to set a users' password directly diff --git a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderUserList.ts b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderUserList.ts index c9071e7d1b..7918ab3d8d 100644 --- a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderUserList.ts +++ b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderUserList.ts @@ -3,6 +3,7 @@ import "#elements/forms/ModalForm"; import "#elements/sync/SyncObjectForm"; import { DEFAULT_CONFIG } from "#common/api/config"; +import { formatUserDisplayName } from "#common/users"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { SlottedTemplateResult } from "#elements/types"; @@ -75,7 +76,7 @@ export class GoogleWorkspaceProviderUserList extends Table { } protected override rowLabel(item: SCIMProviderUser): string { - return item.userObj.name || item.userObj.username; + return formatUserDisplayName(item.userObj); } protected columns: TableColumn[] = [ diff --git a/web/src/admin/sources/scim/SCIMSourceUsers.ts b/web/src/admin/sources/scim/SCIMSourceUsers.ts index 43d2c92664..249690a9ac 100644 --- a/web/src/admin/sources/scim/SCIMSourceUsers.ts +++ b/web/src/admin/sources/scim/SCIMSourceUsers.ts @@ -1,4 +1,5 @@ import { DEFAULT_CONFIG } from "#common/api/config"; +import { formatUserDisplayName } from "#common/users"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { SlottedTemplateResult } from "#elements/types"; @@ -25,7 +26,7 @@ export class SCIMSourceUserList extends Table { } protected override rowLabel(item: SCIMSourceUser): string { - return item.userObj.name || item.userObj.username; + return formatUserDisplayName(item.userObj); } protected columns: TableColumn[] = [ diff --git a/web/src/admin/users/recovery.ts b/web/src/admin/users/recovery.ts index 548cba4477..d437e303f1 100644 --- a/web/src/admin/users/recovery.ts +++ b/web/src/admin/users/recovery.ts @@ -1,3 +1,5 @@ +import { formatUserDisplayName } from "#common/users"; + import { modalInvoker } from "#elements/dialogs"; import { LitFC } from "#elements/types"; @@ -52,7 +54,7 @@ export const RecoveryButtons: LitFC = ({ class="pf-c-button pf-m-secondary ${buttonClasses || ""}" type="button" ${modalInvoker(UserPasswordForm, { - headline: msg(str`Update ${user.name || user.username}'s password`), + headline: msg(str`Update ${formatUserDisplayName(user)}'s password`), username: user.username, email: user.email, instancePk: user.pk, diff --git a/website/docs/customize/blueprints/v1/models.mdx b/website/docs/customize/blueprints/v1/models.mdx index b78b034294..f900b8bb49 100644 --- a/website/docs/customize/blueprints/v1/models.mdx +++ b/website/docs/customize/blueprints/v1/models.mdx @@ -45,6 +45,33 @@ For example: password: this-should-be-a-long-value ``` +### `password_hash` + +In blueprints, a user's password can also be set using the `password_hash` field. The value must be a valid Django password hash, such as one generated with the `hash_password` management command. + +Use `password_hash` when you need to import or bootstrap an existing hash without exposing the raw password to authentik: + +```bash +docker compose run --rm server hash_password 'your-password' +``` + +For example: + +```yaml +# [...] +- model: authentik_core.user + state: present + identifiers: + username: test-user + attrs: + name: test user + password_hash: pbkdf2_sha256$1000000$xKKFuYtJEE27km09BD49x2$4+Z6j3utmouPF5mik0Z21L2P0og5IlmMmIJ46Tj3zCM= +``` + +`password` and `password_hash` are mutually exclusive; setting both on the same user causes blueprint validation to fail. + +`password_hash` follows the [hashed-password import behavior](../../../install-config/automated-install.mdx#authentik_bootstrap_password_hash): it updates only authentik's local password verifier and does not propagate to LDAP or Kerberos integrations. + ### `permissions` The `permissions` field can be used to set global permissions for a user. A full list of possible permissions is included in the JSON schema for blueprints. diff --git a/website/docs/install-config/automated-install.mdx b/website/docs/install-config/automated-install.mdx index e5371842d5..588191b00d 100644 --- a/website/docs/install-config/automated-install.mdx +++ b/website/docs/install-config/automated-install.mdx @@ -8,9 +8,56 @@ To install authentik automatically (skipping the Out-of-box experience), you can These can't be defined using the file-based syntax (`file://`), so you can't pass them in as secrets in a Docker Compose installation. ::: +### `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH` + +Configure the default password for the `akadmin` user using a pre-hashed Django password value. Only read on the first startup. + +This stores the hash directly as authentik's local password verifier. Because authentik never sees the raw password, hashed-password imports do not propagate the password to LDAP or Kerberos integrations, even when password writeback is enabled. + +To generate a hash, run this command before your initial deployment: + +```bash +docker compose run --rm server hash_password 'your-password' +``` + +The generated hash includes a random salt, so running the command multiple times for the same password produces different output. Use the complete output as the value of `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH`. + +:::warning Escaping `$` in Docker Compose +Password hashes contain `$` characters which Docker Compose interprets as variable references. + +**In `.env` files**, use single quotes to prevent interpolation: + +```bash +AUTHENTIK_BOOTSTRAP_PASSWORD_HASH='pbkdf2_sha256$1000000$xKKFuYtJEE27km09BD49x2$4+Z6j3utmouPF5mik0Z21L2P0og5IlmMmIJ46Tj3zCM=' +``` + +**In `docker-compose.yml`** (inline environment), escape each `$` with `$$`: + +```yaml +services: + worker: + environment: + AUTHENTIK_BOOTSTRAP_PASSWORD_HASH: "pbkdf2_sha256$$1000000$$xKKFuYtJEE27km09BD49x2$$4+Z6j3utmouPF5mik0Z21L2P0og5IlmMmIJ46Tj3zCM=" +``` + +See the Docker Compose documentation on [`.env` file interpolation](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/) and [Compose file interpolation](https://docs.docker.com/reference/compose-file/interpolation/) for details. +::: + ### `AUTHENTIK_BOOTSTRAP_PASSWORD` -Configure the default password for the `akadmin` user. Only read on the first startup. Can be used for any flow executor. +:::warning +This option stores plaintext passwords in environment variables. Use [`AUTHENTIK_BOOTSTRAP_PASSWORD_HASH`](#authentik_bootstrap_password_hash) instead. +::: + +Configure the default password for the `akadmin` user. Only read on the first startup. + +Setting both `AUTHENTIK_BOOTSTRAP_PASSWORD` and `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH` will result in an error. + +### Other hashed-password import paths + +For post-install automation, hashed passwords can also be set via blueprints with the `password_hash` user attribute, or via the `/api/v3/core/users//set_password_hash/` API endpoint with the hash provided in the `password` field. The API endpoint requires the `authentik_core.reset_user_password` permission and can target regular users or service accounts. + +These paths share the same local-verifier-only behavior as `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH`. ### `AUTHENTIK_BOOTSTRAP_TOKEN` @@ -22,15 +69,24 @@ Set the email address for the default `akadmin` user. ## Kubernetes -In the Helm values, set the `akadmin` user password and token: +In the Helm values, set the `akadmin` user password hash and token: ```yaml authentik: - bootstrap_token: test - bootstrap_password: test + bootstrap_password_hash: "pbkdf2_sha256$1000000$xKKFuYtJEE27km09BD49x2$4+Z6j3utmouPF5mik0Z21L2P0og5IlmMmIJ46Tj3zCM=" + bootstrap_token: "your-token-here" + bootstrap_email: "admin@authentik.company" ``` -To store the password and token in a secret, use: +:::note Helm escaping +When using password hashes in quoted YAML strings as shown above, no escaping of `$` characters is required. The `$` character only needs escaping when: + +- Using Helm templating syntax (e.g., `{{ .Values.something }}`) where `$` has special meaning +- Referencing values from environment variable substitution in your values file + +::: + +Or store the password hash in a secret and reference it via `envFrom`: ```yaml global: @@ -39,4 +95,4 @@ global: name: _some-secret_ ``` -where _some-secret_ contains the environment variables as in the documentation above. +where _some-secret_ contains the environment variables as documented above. diff --git a/website/docs/users-sources/sources/protocols/kerberos/index.md b/website/docs/users-sources/sources/protocols/kerberos/index.md index 4aa9f20b9b..00909d0d5b 100644 --- a/website/docs/users-sources/sources/protocols/kerberos/index.md +++ b/website/docs/users-sources/sources/protocols/kerberos/index.md @@ -99,7 +99,7 @@ If not specified, the server name defaults to trying out all entries in the keyt There are some extra settings you can configure: - Update internal password on login: when a user logs in to authentik using the Kerberos source as a password backend, their internal authentik password will be updated to match the one from Kerberos. -- Use password writeback: when a user changes their password in authentik, their Kerberos password is automatically updated to match the one from authentik. This is only available if synchronization is configured. +- Use password writeback: when a user changes their password in authentik, their Kerberos password is automatically updated to match the one from authentik. This is only available if synchronization is configured, and requires authentik to receive the raw password; [hashed-password imports](../../../../install-config/automated-install.mdx#authentik_bootstrap_password_hash) are not written back to Kerberos. ## Kerberos source property mappings diff --git a/website/docs/users-sources/sources/protocols/ldap/index.md b/website/docs/users-sources/sources/protocols/ldap/index.md index 5eb2abee69..ca7dedd3df 100644 --- a/website/docs/users-sources/sources/protocols/ldap/index.md +++ b/website/docs/users-sources/sources/protocols/ldap/index.md @@ -17,7 +17,7 @@ To create or edit a source in authentik, open the Admin interface and navigate t - **Enabled**: Toggle this option on to allow authentik to use the defined LDAP source. - **Update internal password on login**: When the user logs in to authentik using the LDAP password backend, the password is stored as a hashed value in authentik. Toggle off (default setting) if you do not want to store the hashed passwords in authentik. - **Sync users**: Enable or disable user synchronization between authentik and the LDAP source. -- **User password writeback**: Enable this option if you want to write password changes that are made in authentik back to LDAP. +- **User password writeback**: Enable this option if you want to write password changes that are made in authentik back to LDAP. This requires authentik to receive the raw password; [hashed-password imports](../../../../install-config/automated-install.mdx#authentik_bootstrap_password_hash) are not written back to LDAP. - **Sync groups**: Enable/disable group synchronization between authentik and the LDAP source. - **Delete Not Found Objects**: :ak-version[2025.6] This option synchronizes user and group deletions from LDAP sources to authentik. User deletion requires enabling **Sync users** and group deletion requires enabling **Sync groups**.