mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
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:
+110
-26
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Generated
+78
@@ -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"],
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+1
@@ -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
@@ -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[] = [
|
||||||
|
|||||||
@@ -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**.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user