Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions authentik/core/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["password"] = CharField(required=False, allow_null=True)
self.fields["password_hash"] = CharField(required=False, allow_null=True)
self.fields["permissions"] = ListField(
required=False,
child=ChoiceField(choices=get_permission_choices()),
Expand All @@ -186,34 +187,58 @@ def create(self, validated_data: dict) -> User:
directly setting a password. However should be done via the `set_password`
method instead of directly setting it like rest_framework."""
password = validated_data.pop("password", None)
password_hash = validated_data.pop("password_hash", None)
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance: User = super().create(validated_data)
self._set_password(instance, password)
self._set_password(instance, password, password_hash)
instance.assign_perms_to_managed_role(perms_list)
return instance

def update(self, instance: User, validated_data: dict) -> User:
"""Same as `create` above, set the password directly if we're in a blueprint
context"""
password = validated_data.pop("password", None)
password_hash = validated_data.pop("password_hash", None)
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance = super().update(instance, validated_data)
self._set_password(instance, password)
self._set_password(instance, password, password_hash)
instance.assign_perms_to_managed_role(perms_list)
return instance

def _set_password(self, instance: User, password: str | None):
def _set_password(self, instance: User, password: str | None, password_hash: str | None = None):
"""Set password of user if we're in a blueprint context, and if it's an empty
string then use an unusable password"""
if SERIALIZER_CONTEXT_BLUEPRINT in self.context and password:
instance.set_password(password)
instance.save()
string then use an unusable password. Supports both plaintext password and
pre-hashed password via password_hash parameter."""
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
# password_hash takes precedence over password
if password_hash:
# Validate the hash format before setting
from django.contrib.auth.hashers import identify_hasher
from rest_framework.exceptions import ValidationError

try:
identify_hasher(password_hash)
except ValueError as exc:
LOGGER.exception("Failed to identify password hash format")
raise ValidationError(
"Invalid password hash format. Must be a valid Django password hash."
) from exc

# Directly set the hashed password without re-hashing
instance.password = password_hash
from django.utils.timezone import now

instance.password_change_date = now()
instance.save()
elif password:
instance.set_password(password)
instance.save()
if len(instance.password) == 0:
instance.set_unusable_password()
instance.save()
Expand Down
34 changes: 34 additions & 0 deletions authentik/core/management/commands/hash_password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""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")

if len(password) < 1:
raise CommandError("Password must be at least 1 character long")

try:
hashed = make_password(password)
if not hashed:
raise CommandError("Failed to hash password")
self.stdout.write(hashed)
except Exception as exc:
raise CommandError(f"Error hashing password: {exc}") from exc
103 changes: 103 additions & 0 deletions authentik/core/tests/test_hash_password_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""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_basic(self):
"""Test basic password hashing"""
out = StringIO()
call_command("hash_password", "test123", stdout=out)
hashed = out.getvalue().strip()

# Verify it's a valid hash
self.assertTrue(hashed.startswith("pbkdf2_sha256$"))
# Verify the hash can be validated
self.assertTrue(check_password("test123", hashed))

def test_hash_password_special_chars(self):
"""Test hashing password with special characters"""
out = StringIO()
password = "P@ssw0rd!#$%^&*(){}[]" # nosec
call_command("hash_password", password, stdout=out)
hashed = out.getvalue().strip()

self.assertTrue(hashed.startswith("pbkdf2_sha256$"))
self.assertTrue(check_password(password, hashed))

def test_hash_password_unicode(self):
"""Test hashing password with unicode characters"""
out = StringIO()
password = "пароль123" # nosec
call_command("hash_password", password, stdout=out)
hashed = out.getvalue().strip()

self.assertTrue(hashed.startswith("pbkdf2_sha256$"))
self.assertTrue(check_password(password, hashed))

def test_hash_password_long(self):
"""Test hashing a very long password"""
out = StringIO()
password = "a" * 1000
call_command("hash_password", password, stdout=out)
hashed = out.getvalue().strip()

self.assertTrue(hashed.startswith("pbkdf2_sha256$"))
self.assertTrue(check_password(password, hashed))

def test_hash_password_spaces(self):
"""Test hashing password with spaces"""
out = StringIO()
password = "my super secret password" # nosec
call_command("hash_password", password, stdout=out)
hashed = out.getvalue().strip()

self.assertTrue(hashed.startswith("pbkdf2_sha256$"))
self.assertTrue(check_password(password, 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))

def test_hash_different_passwords_different_hashes(self):
"""Test that different passwords produce different hashes"""
out1 = StringIO()
out2 = StringIO()

call_command("hash_password", "password1", stdout=out1)
call_command("hash_password", "password2", stdout=out2)

hash1 = out1.getvalue().strip()
hash2 = out2.getvalue().strip()

self.assertNotEqual(hash1, hash2)
self.assertTrue(check_password("password1", hash1))
self.assertTrue(check_password("password2", hash2))
self.assertFalse(check_password("password1", hash2))
self.assertFalse(check_password("password2", hash1))

def test_hash_same_password_different_hashes(self):
"""Test that same password produces different hashes (due to salt)"""
out1 = StringIO()
out2 = StringIO()

call_command("hash_password", "samepassword", stdout=out1)
call_command("hash_password", "samepassword", stdout=out2)

hash1 = out1.getvalue().strip()
hash2 = out2.getvalue().strip()

# Hashes should be different due to random salt
self.assertNotEqual(hash1, hash2)
# But both should validate the same password
self.assertTrue(check_password("samepassword", hash1))
self.assertTrue(check_password("samepassword", hash2))
2 changes: 2 additions & 0 deletions blueprints/system/bootstrap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ context:
group_name: authentik Admins
email: !Env [AUTHENTIK_BOOTSTRAP_EMAIL, "[email protected]"]
password: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD, null]
password_hash: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD_HASH, null]
token: !Env [AUTHENTIK_BOOTSTRAP_TOKEN, null]
entries:
- model: authentik_core.group
Expand All @@ -31,6 +32,7 @@ entries:
groups:
- !KeyOf admin-group
password: !Context password
password_hash: !Context password_hash
- model: authentik_core.token
state: created
conditions:
Expand Down
2 changes: 1 addition & 1 deletion lifecycle/ak
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ elif [[ "$1" == "worker" ]]; then
# If we have bootstrap credentials set, run bootstrap tasks outside of main server
# sync, so that we can sure the first start actually has working bootstrap
# credentials
if [[ -n "${AUTHENTIK_BOOTSTRAP_PASSWORD}" || -n "${AUTHENTIK_BOOTSTRAP_TOKEN}" ]]; then
if [[ -n "${AUTHENTIK_BOOTSTRAP_PASSWORD}" || -n "${AUTHENTIK_BOOTSTRAP_PASSWORD_HASH}" || -n "${AUTHENTIK_BOOTSTRAP_TOKEN}" ]]; then
python -m manage apply_blueprint system/bootstrap.yaml || true
fi
check_if_root "python -m manage worker --pid-file ${TMPDIR}/authentik-worker.pid $@"
Expand Down
48 changes: 48 additions & 0 deletions website/docs/install-config/automated-install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,44 @@ To install authentik automatically (skipping the Out-of-box experience), you can

### `AUTHENTIK_BOOTSTRAP_PASSWORD`

:::note
For improved security, consider using a [hashed password](#authentik_bootstrap_password_hash) to avoid storing plaintext passwords in environment variables.
:::

Configure the default password for the `akadmin` user. Only read on the first startup. Can be used for any flow executor.

### `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH`

Configure the default password for the `akadmin` user using a pre-hashed password. Only read on the first startup.

This is useful when you want to avoid storing plaintext passwords in environment variables or configuration files.

To generate a hash, run this command before your initial deployment:

```bash
# In local development environment
uv run ak hash_password your-password

# Or using a temporary Docker container
docker run --rm -it ghcr.io/goauthentik/server:<The latest release at this time> ak hash_password your-password

# Or on an existing authentik installation
docker exec -it authentik-worker ak hash_password your-password
```

If both `AUTHENTIK_BOOTSTRAP_PASSWORD` and `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH` are set, the hash takes precedence.

:::warning Docker Compose
When using Docker Compose with a `.env` file, escape the `$` characters in the password hash by doubling them (`$$`):

```bash
# .env file
AUTHENTIK_BOOTSTRAP_PASSWORD_HASH="pbkdf2_sha256$$870000$$salt$$hash..."
```

This prevents Docker Compose from interpreting `$` as variable references.
:::

### `AUTHENTIK_BOOTSTRAP_TOKEN`

Create a token for the default `akadmin` user. Only read on the first startup. The string you specify for this variable is the token key you can use to authenticate yourself to the API.
Expand Down Expand Up @@ -36,3 +72,15 @@ global:
```

where _some-secret_ contains the environment variables as in the documentation above.

### Using password hash with Helm values

For improved security, you can use a pre-hashed password directly in your Helm values:

```yaml
authentik:
bootstrap_password_hash: "pbkdf2_sha256$870000$abc123$xyz..."
bootstrap_email: "[email protected]"
```

Or store the password hash in a secret and reference it via `envFrom` (same method as shown above for storing password and token in a secret).
Loading