core: support hashed password in users API + automated install (#18686)

* core: add hash_password command and password_hash bootstrap support

* core: prevent hash format exposure in validation error

* core: remove redundant password length check

* core: remove extra blank lines from hash_password command

* core: add password_hash serializer tests, refine validation and imports

* core: add null password fields test, add hash warning to docs

* core: move hash validation to User.set_password_from_hash method

* core: emit password_changed signal in set_password_from_hash

* website: remove redundant hash security warning

* core: wrap conflict error message for translation

* core: wrap invalid hash error message for translation

* web, core: add set_password_hash API endpoint and admin UI

* core: simplify password_hash check to None comparison

* core: use None check for password conflict validation

* website: clarify Docker Compose $ escaping for .env vs compose.yml

* website: lint

* web: lint

* core: add nosec comment for empty password string in signal

* core: lint

* web: Fix Password Hash help text

* sources/kerberos,ldap: Gergo's review

* add testing for ^^ and type fix

* more general signal tests; not provider specific

* only used in tests

* add warning

* we can do this

* signals fix????

* core, web, website: review fixes

* style(docs): format automated install guide

* web: restore modal invoker import after rebase

Co-authored-by: Codex <codex@openai.com>

* fix generated clients

* core: trim hash password command tests

* core: add password hash permission

* core: cover service account password hashes

* web: remove password hash form

* core: regenerate password hash migration

* core: reuse password serializer for hashes

* docs: clarify hashed password imports

* Regenerate

* core: deduplicate user serializer writes

* core: deduplicate password update actions

* core: deduplicate password change signaling

* tests: reuse password hash API helper

* tests: reuse SSF credential assertions

* docs: centralize hashed password caveat

* core: name password hash signal source

* core: centralize password hash validation

* core: deduplicate serializer password saves

* docs: link source writeback caveats

* api: clarify password hash request field

* tests: deduplicate password hash API assertions

* web: reuse user display-name helper

* web: use existing user display formatter

* core: reuse reset password permission for hash endpoint

* core: keep separate password hash serializer

* tests: remove redundant password hash permission test

* 21745

Co-authored-by: Gergo <gergo@goauthentik.io>

* core: preserve empty password handling in user serializer

* core: inline blueprint user serializer fields

* Use password hash constant

* Simplify user serializer flow

* Inline password update handling

* Apply serializer cleanup

* Clean blueprint password handling

* Drop extra returns

* Split password hash signal

* Align hash signal receivers

* Remove stale password guards

* Inline password signal

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Gergo <gergo@goauthentik.io>
This commit is contained in:
Dominic R
2026-04-29 00:27:59 -04:00
committed by GitHub
parent 99250b0498
commit 899994027d
28 changed files with 759 additions and 62 deletions
+110 -26
View File
@@ -14,6 +14,7 @@ from django.utils.http import urlencode
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from django_filters.filters import ( from django_filters.filters import (
BooleanFilter, BooleanFilter,
CharFilter, CharFilter,
@@ -106,6 +107,10 @@ from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger() LOGGER = get_logger()
INVALID_PASSWORD_HASH_MESSAGE = gettext_lazy(
"Invalid password hash format. Must be a valid Django password hash."
)
class ParamUserSerializer(PassiveSerializer): class ParamUserSerializer(PassiveSerializer):
"""Partial serializer for query parameters to select a user""" """Partial serializer for query parameters to select a user"""
@@ -190,47 +195,79 @@ class UserSerializer(ModelSerializer):
return RoleSerializer(instance.roles, many=True).data return RoleSerializer(instance.roles, many=True).data
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Setting password and permissions directly is allowed only in blueprints."""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context: if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["password"] = CharField(required=False, allow_null=True) self.fields["password"] = CharField(required=False, allow_null=True)
self.fields["password_hash"] = CharField(required=False, allow_null=True)
self.fields["permissions"] = ListField( self.fields["permissions"] = ListField(
required=False, required=False,
child=ChoiceField(choices=get_permission_choices()), child=ChoiceField(choices=get_permission_choices()),
) )
def create(self, validated_data: dict) -> User: def create(self, validated_data: dict) -> User:
"""If this serializer is used in the blueprint context, we allow for """Create a user, with blueprint-only password and permission writes."""
directly setting a password. However should be done via the `set_password` is_blueprint = SERIALIZER_CONTEXT_BLUEPRINT in self.context
method instead of directly setting it like rest_framework.""" if is_blueprint:
password = validated_data.pop("password", None) password = validated_data.pop("password", None)
perms_qs = Permission.objects.filter( password_hash = validated_data.pop("password_hash", None)
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])] permissions = validated_data.pop("permissions", [])
).values_list("content_type__app_label", "codename") self._validate_password_inputs(password, password_hash)
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance: User = super().create(validated_data) instance: User = super().create(validated_data)
self._set_password(instance, password) if is_blueprint:
instance.assign_perms_to_managed_role(perms_list) 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 return instance
def update(self, instance: User, validated_data: dict) -> User: def update(self, instance: User, validated_data: dict) -> User:
"""Same as `create` above, set the password directly if we're in a blueprint """Update a user, with blueprint-only password and permission writes."""
context""" is_blueprint = SERIALIZER_CONTEXT_BLUEPRINT in self.context
password = validated_data.pop("password", None) if is_blueprint:
perms_qs = Permission.objects.filter( password = validated_data.pop("password", None)
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])] password_hash = validated_data.pop("password_hash", None)
).values_list("content_type__app_label", "codename") permissions = validated_data.pop("permissions", [])
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)] self._validate_password_inputs(password, password_hash)
instance = super().update(instance, validated_data) instance = super().update(instance, validated_data)
self._set_password(instance, password) if is_blueprint:
instance.assign_perms_to_managed_role(perms_list) 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 return instance
def _set_password(self, instance: User, password: str | None): def _validate_password_inputs(self, password: str | None, password_hash: str | None):
"""Set password of user if we're in a blueprint context, and if it's an empty """Validate mutually-exclusive password inputs before any model mutation."""
string then use an unusable password""" if password is not None and password_hash is not None:
if SERIALIZER_CONTEXT_BLUEPRINT in self.context and password: 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.set_password(password)
instance.save() 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: if len(instance.password) == 0:
instance.set_unusable_password() instance.set_unusable_password()
instance.save() instance.save()
@@ -399,6 +436,12 @@ class UserPasswordSetSerializer(PassiveSerializer):
password = CharField(required=True) password = CharField(required=True)
class UserPasswordHashSetSerializer(PassiveSerializer):
"""Payload to set a users' password hash directly"""
password = CharField(required=True)
class UserServiceAccountSerializer(PassiveSerializer): class UserServiceAccountSerializer(PassiveSerializer):
"""Payload to create a service account""" """Payload to create a service account"""
@@ -742,6 +785,11 @@ class UserViewSet(
self.request.session.modified = True self.request.session.modified = True
return Response(serializer.initial_data) 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") @permission_required("authentik_core.reset_user_password")
@extend_schema( @extend_schema(
request=UserPasswordSetSerializer, request=UserPasswordSetSerializer,
@@ -765,9 +813,45 @@ class UserViewSet(
except (ValidationError, IntegrityError) as exc: except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password", exc=exc) LOGGER.debug("Failed to set password", exc=exc)
return Response(status=400) return Response(status=400)
if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session: self._update_session_hash_after_password_change(request, user)
LOGGER.debug("Updating session hash after password change") return Response(status=204)
update_session_auth_hash(self.request, user)
@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) return Response(status=204)
@permission_required("authentik_core.reset_user_password") @permission_required("authentik_core.reset_user_password")
@@ -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
+28 -1
View File
@@ -10,7 +10,7 @@ from uuid import uuid4
import pgtrigger import pgtrigger
from deepmerge import always_merger 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 AbstractUser, Permission
from django.contrib.auth.models import UserManager as DjangoUserManager from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession from django.contrib.sessions.base_session import AbstractBaseSession
@@ -560,6 +560,33 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
self.password_change_date = now() self.password_change_date = now()
return super().set_password(raw_password) 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: def check_password(self, raw_password: str) -> bool:
""" """
Return a boolean of whether the raw_password was correct. Handles Return a boolean of whether the raw_password was correct. Handles
+5 -1
View File
@@ -16,7 +16,11 @@ LOGGER = get_logger()
@receiver(post_startup) @receiver(post_startup)
def post_startup_setup_bootstrap(sender, **_): 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 return
LOGGER.info("Configuring authentik through bootstrap environment variables") LOGGER.info("Configuring authentik through bootstrap environment variables")
content = BlueprintInstance(path=BOOTSTRAP_BLUEPRINT).retrieve() content = BlueprintInstance(path=BOOTSTRAP_BLUEPRINT).retrieve()
+2
View File
@@ -24,6 +24,8 @@ from authentik.root.ws.consumer import build_device_group
# Arguments: user: User, password: str # Arguments: user: User, password: str
password_changed = Signal() password_changed = Signal()
# Arguments: user: User, request: HttpRequest | None
password_hash_changed = Signal()
# Arguments: credentials: dict[str, any], request: HttpRequest, # Arguments: credentials: dict[str, any], request: HttpRequest,
# stage: Stage, context: dict[str, any] # stage: Stage, context: dict[str, any]
login_failed = Signal() login_failed = Signal()
@@ -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))
+18
View File
@@ -1,6 +1,7 @@
from http import HTTPStatus from http import HTTPStatus
from os import environ from os import environ
from django.contrib.auth.hashers import make_password
from django.urls import reverse from django.urls import reverse
from authentik.blueprints.tests import apply_blueprint from authentik.blueprints.tests import apply_blueprint
@@ -16,6 +17,7 @@ from authentik.tenants.flags import patch_flag
class TestSetup(FlowTestCase): class TestSetup(FlowTestCase):
def tearDown(self): def tearDown(self):
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD", None) environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD", None)
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD_HASH", None)
environ.pop("AUTHENTIK_BOOTSTRAP_TOKEN", None) environ.pop("AUTHENTIK_BOOTSTRAP_TOKEN", None)
@patch_flag(Setup, True) @patch_flag(Setup, True)
@@ -154,3 +156,19 @@ class TestSetup(FlowTestCase):
token = Token.objects.filter(identifier="authentik-bootstrap-token").first() token = Token.objects.filter(identifier="authentik-bootstrap-token").first()
self.assertEqual(token.intent, TokenIntents.INTENT_API) self.assertEqual(token.intent, TokenIntents.INTENT_API)
self.assertEqual(token.key, environ["AUTHENTIK_BOOTSTRAP_TOKEN"]) 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))
+104 -1
View File
@@ -1,8 +1,15 @@
"""user tests""" """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.models import User
from authentik.core.signals import password_changed, password_hash_changed
from authentik.events.models import Event from authentik.events.models import Event
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
@@ -33,3 +40,99 @@ class TestUsers(TestCase):
self.assertEqual(Event.objects.count(), 1) self.assertEqual(Event.objects.count(), 1)
user.ak_groups.all() user.ak_groups.all()
self.assertEqual(Event.objects.count(), 1) 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)
+61
View File
@@ -3,6 +3,7 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from json import loads from json import loads
from django.contrib.auth.hashers import make_password
from django.urls.base import reverse from django.urls.base import reverse
from django.utils.timezone import now from django.utils.timezone import now
from rest_framework.test import APITestCase 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.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage 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): class TestUsersAPI(APITestCase):
"""Test Users API""" """Test Users API"""
@@ -34,6 +38,20 @@ class TestUsersAPI(APITestCase):
self.admin = create_test_admin_user() self.admin = create_test_admin_user()
self.user = create_test_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): def test_filter_type(self):
"""Test API filtering by type""" """Test API filtering by type"""
self.client.force_login(self.admin) self.client.force_login(self.admin)
@@ -113,6 +131,26 @@ class TestUsersAPI(APITestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertJSONEqual(response.content, {"password": ["This field may not be blank."]}) 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): def test_recovery(self):
"""Test user recovery link""" """Test user recovery link"""
flow = create_test_flow( flow = create_test_flow(
@@ -261,6 +299,29 @@ class TestUsersAPI(APITestCase):
self.assertTrue(token_filter.exists()) self.assertTrue(token_filter.exists())
self.assertTrue(token_filter.first().expiring) 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): def test_service_account_no_expire(self):
"""Service account creation without token expiration""" """Service account creation without token expiration"""
self.client.force_login(self.admin) self.client.force_login(self.admin)
+13 -4
View File
@@ -12,7 +12,7 @@ from authentik.core.models import (
User, User,
UserTypes, 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 ( from authentik.enterprise.providers.ssf.models import (
EventTypes, EventTypes,
SSFProvider, SSFProvider,
@@ -84,14 +84,13 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi
) )
@receiver(password_changed) def _send_password_credential_change(user: User, change_type: str):
def ssf_password_changed_cred_change(sender, user: User, password: str | None, **_):
"""Credential change trigger (password changed)""" """Credential change trigger (password changed)"""
send_ssf_events( send_ssf_events(
EventTypes.CAEP_CREDENTIAL_CHANGE, EventTypes.CAEP_CREDENTIAL_CHANGE,
{ {
"credential_type": "password", "credential_type": "password",
"change_type": "revoke" if password is None else "update", "change_type": change_type,
}, },
sub_id={ sub_id={
"format": "complex", "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 = { device_type_map = {
StaticDevice: "pin", StaticDevice: "pin",
TOTPDevice: "pin", TOTPDevice: "pin",
@@ -1,5 +1,6 @@
from uuid import uuid4 from uuid import uuid4
from django.contrib.auth.hashers import make_password
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -52,6 +53,21 @@ class TestSignals(APITestCase):
) )
self.assertEqual(res.status_code, 201, res.content) 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): def test_signal_logout(self):
"""Test user logout""" """Test user logout"""
user = create_test_user() user = create_test_user()
@@ -79,19 +95,25 @@ class TestSignals(APITestCase):
user.set_password(generate_id()) user.set_password(generate_id())
user.save() user.save()
stream = Stream.objects.filter(provider=self.provider).first() self._assert_password_credential_change(user, "update")
self.assertIsNotNone(stream)
event = StreamEvent.objects.filter(stream=stream).first() def test_signal_password_change_from_hash(self):
self.assertIsNotNone(event) """Test user password change from a pre-hashed password."""
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED) user = create_test_user()
event_payload = event.payload["events"][ self.client.force_login(user)
"https://schemas.openid.net/secevent/caep/event-type/credential-change" user.set_password_from_hash(make_password(generate_id()))
] user.save()
self.assertEqual(event_payload["change_type"], "update")
self.assertEqual(event_payload["credential_type"], "password") self._assert_password_credential_change(user, "update")
self.assertEqual(event.payload["sub_id"]["format"], "complex")
self.assertEqual(event.payload["sub_id"]["user"]["format"], "email") def test_signal_password_revoke(self):
self.assertEqual(event.payload["sub_id"]["user"]["email"], user.email) """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): def test_signal_authenticator_added(self):
"""Test authenticator creation signal""" """Test authenticator creation signal"""
+9 -2
View File
@@ -11,7 +11,7 @@ from django.http import HttpRequest
from rest_framework.request import Request from rest_framework.request import Request
from authentik.core.models import AuthenticatedSession, User 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.events.models import Event, EventAction
from authentik.flows.models import Stage from authentik.flows.models import Stage
from authentik.flows.planner import ( 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) @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""" """Log password change"""
Event.new(EventAction.PASSWORD_SET).from_http(request, user=user) Event.new(EventAction.PASSWORD_SET).from_http(request, user=user)
+13 -1
View File
@@ -2,6 +2,7 @@
from urllib.parse import urlencode from urllib.parse import urlencode
from django.contrib.auth.hashers import make_password
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from django.views.debug import SafeExceptionReporterFilter 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.brands.models import Brand
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_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.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
@@ -213,3 +214,14 @@ class TestEvents(TestCase):
event = Event.new("unittest", foo="foo bar \u0000 baz") event = Event.new("unittest", foo="foo bar \u0000 baz")
event.save() event.save()
self.assertEqual(event.context["foo"], "foo bar baz") 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)
+8
View File
@@ -5537,6 +5537,14 @@
"minLength": 1, "minLength": 1,
"title": "Password" "title": "Password"
}, },
"password_hash": {
"type": [
"string",
"null"
],
"minLength": 1,
"title": "Password hash"
},
"permissions": { "permissions": {
"type": "array", "type": "array",
"items": { "items": {
+2
View File
@@ -11,6 +11,7 @@ context:
group_name: authentik Admins group_name: authentik Admins
email: !Env [AUTHENTIK_BOOTSTRAP_EMAIL, "root@example.com"] email: !Env [AUTHENTIK_BOOTSTRAP_EMAIL, "root@example.com"]
password: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD, null] password: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD, null]
password_hash: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD_HASH, null]
token: !Env [AUTHENTIK_BOOTSTRAP_TOKEN, null] token: !Env [AUTHENTIK_BOOTSTRAP_TOKEN, null]
entries: entries:
- model: authentik_core.group - model: authentik_core.group
@@ -31,6 +32,7 @@ entries:
groups: groups:
- !KeyOf admin-group - !KeyOf admin-group
password: !Context password password: !Context password
password_hash: !Context password_hash
- model: authentik_core.token - model: authentik_core.token
state: created state: created
conditions: conditions:
+78
View File
@@ -54,6 +54,7 @@ import type {
User, User,
UserAccountRequest, UserAccountRequest,
UserConsent, UserConsent,
UserPasswordHashSetRequest,
UserPasswordSetRequest, UserPasswordSetRequest,
UserPath, UserPath,
UserRecoveryEmailRequest, UserRecoveryEmailRequest,
@@ -104,6 +105,7 @@ import {
UserAccountRequestToJSON, UserAccountRequestToJSON,
UserConsentFromJSON, UserConsentFromJSON,
UserFromJSON, UserFromJSON,
UserPasswordHashSetRequestToJSON,
UserPasswordSetRequestToJSON, UserPasswordSetRequestToJSON,
UserPathFromJSON, UserPathFromJSON,
UserRecoveryEmailRequestToJSON, UserRecoveryEmailRequestToJSON,
@@ -508,6 +510,11 @@ export interface CoreUsersSetPasswordCreateRequest {
userPasswordSetRequest: UserPasswordSetRequest; userPasswordSetRequest: UserPasswordSetRequest;
} }
export interface CoreUsersSetPasswordHashCreateRequest {
id: number;
userPasswordHashSetRequest: UserPasswordHashSetRequest;
}
export interface CoreUsersUpdateRequest { export interface CoreUsersUpdateRequest {
id: number; id: number;
userRequest: UserRequest; userRequest: UserRequest;
@@ -5288,6 +5295,77 @@ export class CoreApi extends runtime.BaseAPI {
await this.coreUsersSetPasswordCreateRaw(requestParameters, initOverrides); await this.coreUsersSetPasswordCreateRaw(requestParameters, initOverrides);
} }
/**
* Creates request options for coreUsersSetPasswordHashCreate without sending the request
*/
async coreUsersSetPasswordHashCreateRequestOpts(
requestParameters: CoreUsersSetPasswordHashCreateRequest,
): Promise<runtime.RequestOpts> {
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<runtime.ApiResponse<void>> {
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<void> {
await this.coreUsersSetPasswordHashCreateRaw(requestParameters, initOverrides);
}
/** /**
* Creates request options for coreUsersUpdate without sending the request * Creates request options for coreUsersUpdate without sending the request
*/ */
@@ -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"],
};
}
+1
View File
@@ -842,6 +842,7 @@ export * from "./UserLogoutStageRequest";
export * from "./UserMatchingModeEnum"; export * from "./UserMatchingModeEnum";
export * from "./UserOAuthSourceConnection"; export * from "./UserOAuthSourceConnection";
export * from "./UserOAuthSourceConnectionRequest"; export * from "./UserOAuthSourceConnectionRequest";
export * from "./UserPasswordHashSetRequest";
export * from "./UserPasswordSetRequest"; export * from "./UserPasswordSetRequest";
export * from "./UserPath"; export * from "./UserPath";
export * from "./UserPlexSourceConnection"; export * from "./UserPlexSourceConnection";
+44
View File
@@ -4522,6 +4522,41 @@ paths:
description: Bad request description: Bad request
'403': '403':
$ref: '#/components/responses/GenericErrorResponse' $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/: /core/users/{id}/used_by/:
get: get:
operationId: core_users_used_by_list operationId: core_users_used_by_list
@@ -57588,6 +57623,15 @@ components:
- identifier - identifier
- source - source
- user - user
UserPasswordHashSetRequest:
type: object
description: Payload to set a users' password hash directly
properties:
password:
type: string
minLength: 1
required:
- password
UserPasswordSetRequest: UserPasswordSetRequest:
type: object type: object
description: Payload to set a users' password directly description: Payload to set a users' password directly
@@ -3,6 +3,7 @@ import "#elements/forms/ModalForm";
import "#elements/sync/SyncObjectForm"; import "#elements/sync/SyncObjectForm";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
@@ -75,7 +76,7 @@ export class GoogleWorkspaceProviderUserList extends Table<GoogleWorkspaceProvid
} }
protected override rowLabel(item: GoogleWorkspaceProviderUser): string { protected override rowLabel(item: GoogleWorkspaceProviderUser): string {
return item.userObj.name || item.userObj.username; return formatUserDisplayName(item.userObj);
} }
protected columns: TableColumn[] = [ protected columns: TableColumn[] = [
@@ -3,6 +3,7 @@ import "#elements/forms/ModalForm";
import "#elements/sync/SyncObjectForm"; import "#elements/sync/SyncObjectForm";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
@@ -75,7 +76,7 @@ export class MicrosoftEntraProviderUserList extends Table<MicrosoftEntraProvider
} }
protected override rowLabel(item: MicrosoftEntraProviderUser): string { protected override rowLabel(item: MicrosoftEntraProviderUser): string {
return item.userObj.name || item.userObj.username; return formatUserDisplayName(item.userObj);
} }
protected columns: TableColumn[] = [ protected columns: TableColumn[] = [
@@ -4,6 +4,7 @@ import "#elements/sync/SyncObjectForm";
import "#admin/common/ak-flow-search/ak-flow-search-no-default"; import "#admin/common/ak-flow-search/ak-flow-search-no-default";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
@@ -73,7 +74,7 @@ export class SCIMProviderUserList extends Table<SCIMProviderUser> {
} }
protected override rowLabel(item: SCIMProviderUser): string { protected override rowLabel(item: SCIMProviderUser): string {
return item.userObj.name || item.userObj.username; return formatUserDisplayName(item.userObj);
} }
protected columns: TableColumn[] = [ protected columns: TableColumn[] = [
@@ -1,4 +1,5 @@
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
@@ -25,7 +26,7 @@ export class SCIMSourceUserList extends Table<SCIMSourceUser> {
} }
protected override rowLabel(item: SCIMSourceUser): string { protected override rowLabel(item: SCIMSourceUser): string {
return item.userObj.name || item.userObj.username; return formatUserDisplayName(item.userObj);
} }
protected columns: TableColumn[] = [ protected columns: TableColumn[] = [
+3 -1
View File
@@ -1,3 +1,5 @@
import { formatUserDisplayName } from "#common/users";
import { modalInvoker } from "#elements/dialogs"; import { modalInvoker } from "#elements/dialogs";
import { LitFC } from "#elements/types"; import { LitFC } from "#elements/types";
@@ -52,7 +54,7 @@ export const RecoveryButtons: LitFC<RecoveryButtonsProps> = ({
class="pf-c-button pf-m-secondary ${buttonClasses || ""}" class="pf-c-button pf-m-secondary ${buttonClasses || ""}"
type="button" type="button"
${modalInvoker(UserPasswordForm, { ${modalInvoker(UserPasswordForm, {
headline: msg(str`Update ${user.name || user.username}'s password`), headline: msg(str`Update ${formatUserDisplayName(user)}'s password`),
username: user.username, username: user.username,
email: user.email, email: user.email,
instancePk: user.pk, instancePk: user.pk,
@@ -45,6 +45,33 @@ For example:
password: this-should-be-a-long-value 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` ### `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. 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.
@@ -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. 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` ### `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/<id>/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` ### `AUTHENTIK_BOOTSTRAP_TOKEN`
@@ -22,15 +69,24 @@ Set the email address for the default `akadmin` user.
## Kubernetes ## 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 ```yaml
authentik: authentik:
bootstrap_token: test bootstrap_password_hash: "pbkdf2_sha256$1000000$xKKFuYtJEE27km09BD49x2$4+Z6j3utmouPF5mik0Z21L2P0og5IlmMmIJ46Tj3zCM="
bootstrap_password: test 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 ```yaml
global: global:
@@ -39,4 +95,4 @@ global:
name: _some-secret_ 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.
@@ -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: 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. - 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 ## Kerberos source property mappings
@@ -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. - **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. - **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. - **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. - **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**. - **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**.