Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0d207bb
stages: use managed secrets for email and CAPTCHA
dominic-r Sep 14, 2026
8f10c15
api: retain legacy credential fields until frontend migration
dominic-r Sep 14, 2026
c3d4f5a
crypto: merge updated secrets dependencies into email-captcha
dominic-r Sep 15, 2026
1536530
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Sep 15, 2026
8c3afc6
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Sep 16, 2026
6c431fc
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Sep 16, 2026
1723273
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Sep 16, 2026
ca975c7
stages: use explicit email and captcha credential references
dominic-r Sep 20, 2026
cde8f29
sources/oauth: propagate the WeChat request fix
dominic-r Sep 20, 2026
dfa24ba
web/admin: keep credential forms aligned with this stack layer
dominic-r Sep 20, 2026
1065650
secrets: merge updated rotation-mfa-stages
dominic-r Sep 23, 2026
43f554a
blueprints: use private_key_ref in conditional CAPTCHA example
dominic-r Sep 23, 2026
d4e56c0
stages: regenerate email and CAPTCHA migrations without field renames
dominic-r Oct 5, 2026
8863229
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Oct 5, 2026
415d5e1
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Oct 5, 2026
0eea168
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Oct 5, 2026
a9edfdd
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Oct 5, 2026
bcba4df
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Oct 10, 2026
c4bd957
stages: keep email and CAPTCHA legacy columns in place
dominic-r Oct 10, 2026
c762dab
stages/authenticator_email: regenerate the API client for the stage_u…
dominic-r Oct 10, 2026
e125f43
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Oct 10, 2026
ad49d3d
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Oct 10, 2026
c82c668
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Oct 10, 2026
38e0ee6
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Oct 10, 2026
d12011d
Merge branch 'dominic/rotation-mfa-stages' into dominic/rotation-emai…
dominic-r Oct 10, 2026
12b40b0
stages/captcha: require the private key on the model and share the mi…
dominic-r Oct 10, 2026
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
21 changes: 18 additions & 3 deletions authentik/stages/authenticator_email/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class Meta:
"host",
"port",
"username",
"password",
"password_ref",
"use_tls",
"use_ssl",
"timeout",
Expand All @@ -31,15 +31,30 @@ class Meta:
"token_expiry",
"template",
]
extra_kwargs = {"password": {"write_only": True}}


class AuthenticatorEmailStageViewSet(UsedByMixin, ModelViewSet):
"""AuthenticatorEmailStage Viewset"""

queryset = AuthenticatorEmailStage.objects.all()
serializer_class = AuthenticatorEmailStageSerializer
filterset_fields = "__all__"
filterset_fields = [
"stage_uuid",
"name",
"configure_flow",
"friendly_name",
"use_global_settings",
"host",
"port",
"username",
"use_tls",
"use_ssl",
"timeout",
"from_address",
"subject",
"token_expiry",
"template",
]
ordering = ["name"]
search_fields = ["name"]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 5.2.17 on 2026-10-10 19:32

import django.db.models.deletion
from django.db import migrations, models

from authentik.crypto.secrets.migrations._credential_values import move_credentials

FIELDS = [("password", "password_ref", "text", "SMTP password")]


class Migration(migrations.Migration):

dependencies = [
("guardian", "0004_role_permissions"),
("authentik_rbac", "0011_initialpermissionspermission"),
("authentik_crypto_secrets", "0001_initial"),
("authentik_stages_authenticator_email", "0004_alter_emaildevice_stage"),
]

operations = [
migrations.AddField(
model_name="authenticatoremailstage",
name="password_ref",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="authenticator_email_stages",
to="authentik_crypto_secrets.secret",
verbose_name="SMTP password",
),
),
move_credentials("authentik_stages_authenticator_email", "AuthenticatorEmailStage", FIELDS),
]
12 changes: 11 additions & 1 deletion authentik/stages/authenticator_email/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,17 @@ class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
host = models.TextField(default="localhost")
port = models.IntegerField(default=25)
username = models.TextField(default="", blank=True)
# Legacy column, kept for downgrades. Remove in 2027.2.
password = models.TextField(default="", blank=True)
password_ref = models.ForeignKey(
"authentik_crypto_secrets.Secret",
verbose_name=_("SMTP password"),
on_delete=models.PROTECT,
null=True,
blank=True,
default=None,
related_name="authenticator_email_stages",
)
use_tls = models.BooleanField(default=False)
use_ssl = models.BooleanField(default=False)
timeout = models.IntegerField(default=10)
Expand Down Expand Up @@ -94,7 +104,7 @@ def backend(self) -> BaseEmailBackend:
host=self.host,
port=self.port,
username=self.username,
password=self.password,
password=self.password_ref.secret_value if self.password_ref else "",
use_tls=self.use_tls,
use_ssl=self.use_ssl,
timeout=self.timeout,
Expand Down
3 changes: 1 addition & 2 deletions authentik/stages/captcha/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Meta:
model = CaptchaStage
fields = StageSerializer.Meta.fields + [
"public_key",
"private_key",
"private_key_ref",
"js_url",
"api_url",
"request_content_type",
Expand All @@ -23,7 +23,6 @@ class Meta:
"score_max_threshold",
"error_on_invalid_score",
]
extra_kwargs = {"private_key": {"write_only": True}}


class CaptchaStageViewSet(UsedByMixin, ModelViewSet):
Expand Down
35 changes: 35 additions & 0 deletions authentik/stages/captcha/migrations/0006_managed_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 5.2.17 on 2026-10-10 19:32

import django.db.models.deletion
from django.db import migrations, models

from authentik.crypto.secrets.migrations._credential_values import move_credentials

FIELDS = [("private_key", "private_key_ref", "text", "captcha private key")]


class Migration(migrations.Migration):

dependencies = [
("guardian", "0004_role_permissions"),
("authentik_rbac", "0011_initialpermissionspermission"),
("authentik_crypto_secrets", "0001_initial"),
("authentik_stages_captcha", "0005_captchastage_request_content_type"),
]

operations = [
migrations.AddField(
model_name="captchastage",
name="private_key_ref",
field=models.ForeignKey(
default=None,
help_text="Private key, acquired your captcha Provider.",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="captcha_stages",
to="authentik_crypto_secrets.secret",
verbose_name="Private key",
),
),
move_credentials("authentik_stages_captcha", "CaptchaStage", FIELDS),
]
10 changes: 10 additions & 0 deletions authentik/stages/captcha/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,17 @@ class CaptchaStage(Stage):
"""Verify the user is human using Google's reCaptcha/other compatible CAPTCHA solutions."""

public_key = models.TextField(help_text=_("Public key, acquired your captcha Provider."))
# Legacy column, kept for downgrades. Remove in 2027.2.
private_key = models.TextField(help_text=_("Private key, acquired your captcha Provider."))
private_key_ref = models.ForeignKey(
"authentik_crypto_secrets.Secret",
verbose_name=_("Private key"),
help_text=_("Private key, acquired your captcha Provider."),
on_delete=models.PROTECT,
null=True,
default=None,
related_name="captcha_stages",
)

interactive = models.BooleanField(default=False)

Expand Down
2 changes: 1 addition & 1 deletion authentik/stages/captcha/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class CaptchaChallenge(WithUserInfoChallenge):
def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str, key: str | None = None):
"""Validate captcha token"""
payload = {
"secret": key or stage.private_key,
"secret": key or stage.private_key_ref.secret_value,
"response": token,
"remoteip": remote_ip,
}
Expand Down
16 changes: 14 additions & 2 deletions authentik/stages/captcha/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from requests_mock import Mocker

from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.crypto.secrets.tests.utils import create_test_secret
from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import FlowPlan
Expand Down Expand Up @@ -32,10 +33,21 @@ def setUp(self):
self.stage: CaptchaStage = CaptchaStage.objects.create(
name="captcha",
public_key=RECAPTCHA_PUBLIC_KEY,
private_key=RECAPTCHA_PRIVATE_KEY,
private_key_ref=create_test_secret(RECAPTCHA_PRIVATE_KEY),
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)

def test_api_requires_secret(self):
"""Creating a stage without a secret reference must fail, matching the
previously-required private_key"""
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:captchastage-list"),
data={"name": generate_id(), "public_key": RECAPTCHA_PUBLIC_KEY},
)
self.assertEqual(response.status_code, 400)
self.assertIn("private_key_ref", response.json())

@Mocker()
def test_valid(self, mock: Mocker):
"""Test valid captcha"""
Expand Down Expand Up @@ -93,7 +105,7 @@ def test_valid_json_content_type(self, mock: Mocker):
@Mocker()
def test_valid_override(self, mock: Mocker):
"""Test valid captcha"""
self.stage.private_key = generate_id()
self.stage.private_key_ref.replace_value(generate_id())
self.stage.public_key = generate_id()
mock.post(
"https://www.recaptcha.net/recaptcha/api/siteverify",
Expand Down
3 changes: 1 addition & 2 deletions authentik/stages/email/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class Meta:
"host",
"port",
"username",
"password",
"password_ref",
"use_tls",
"use_ssl",
"timeout",
Expand All @@ -47,7 +47,6 @@ class Meta:
"recovery_max_attempts",
"recovery_cache_timeout",
]
extra_kwargs = {"password": {"write_only": True}}


class EmailStageViewSet(UsedByMixin, ModelViewSet):
Expand Down
35 changes: 35 additions & 0 deletions authentik/stages/email/migrations/0007_managed_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 5.2.17 on 2026-10-10 19:32

import django.db.models.deletion
from django.db import migrations, models

from authentik.crypto.secrets.migrations._credential_values import move_credentials

FIELDS = [("password", "password_ref", "text", "SMTP password")]


class Migration(migrations.Migration):

dependencies = [
("guardian", "0004_role_permissions"),
("authentik_rbac", "0011_initialpermissionspermission"),
("authentik_crypto_secrets", "0001_initial"),
("authentik_stages_email", "0006_emailstage_recovery_cache_timeout_and_more"),
]

operations = [
migrations.AddField(
model_name="emailstage",
name="password_ref",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="email_stages",
to="authentik_crypto_secrets.secret",
verbose_name="SMTP password",
),
),
move_credentials("authentik_stages_email", "EmailStage", FIELDS),
]
12 changes: 11 additions & 1 deletion authentik/stages/email/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,17 @@ class EmailStage(Stage):
host = models.TextField(default="localhost")
port = models.IntegerField(default=25)
username = models.TextField(default="", blank=True)
# Legacy column, kept for downgrades. Remove in 2027.2.
password = models.TextField(default="", blank=True)
password_ref = models.ForeignKey(
"authentik_crypto_secrets.Secret",
verbose_name=_("SMTP password"),
on_delete=models.PROTECT,
null=True,
blank=True,
default=None,
related_name="email_stages",
)
use_tls = models.BooleanField(default=False)
use_ssl = models.BooleanField(default=False)
timeout = models.IntegerField(default=10)
Expand Down Expand Up @@ -147,7 +157,7 @@ def backend(self) -> BaseEmailBackend:
host=self.host,
port=self.port,
username=self.username,
password=self.password,
password=self.password_ref.secret_value if self.password_ref else "",
use_tls=self.use_tls,
use_ssl=self.use_ssl,
timeout=self.timeout,
Expand Down
3 changes: 2 additions & 1 deletion authentik/stages/email/tests/test_management_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.test import TestCase

from authentik.core.tests.utils import create_test_admin_user
from authentik.crypto.secrets.tests.utils import create_test_secret
from authentik.stages.email.models import EmailStage


Expand Down Expand Up @@ -58,7 +59,7 @@ def test_test_email_command_uses_stage_settings(self):
host="stage.authentik.local",
port=587,
username="stage-user",
password="stage-password", # nosec
password_ref=create_test_secret("stage-password"), # nosec
use_tls=True,
)

Expand Down
9 changes: 5 additions & 4 deletions authentik/stages/identification/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from rest_framework.exceptions import ValidationError

from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.crypto.secrets.tests.utils import create_test_secret
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER
from authentik.flows.tests import FlowTestCase
Expand Down Expand Up @@ -271,7 +272,7 @@ def test_valid_with_captcha(self, mock: Mocker):
captcha_stage = CaptchaStage.objects.create(
name="captcha",
public_key=RECAPTCHA_PUBLIC_KEY,
private_key=RECAPTCHA_PRIVATE_KEY,
private_key_ref=create_test_secret(RECAPTCHA_PRIVATE_KEY),
)
self.stage.captcha_stage = captcha_stage
self.stage.save()
Expand All @@ -296,7 +297,7 @@ def test_valid_with_captcha_override(self, mock: Mocker):
captcha_stage = CaptchaStage.objects.create(
name="captcha",
public_key=configured_site_key,
private_key=generate_id(),
private_key_ref=create_test_secret(generate_id()),
)
self.stage.captcha_stage = captcha_stage
self.stage.save()
Expand Down Expand Up @@ -343,7 +344,7 @@ def test_invalid_with_captcha(self, mock: Mocker):
captcha_stage = CaptchaStage.objects.create(
name="captcha",
public_key=RECAPTCHA_PUBLIC_KEY,
private_key=RECAPTCHA_PRIVATE_KEY,
private_key_ref=create_test_secret(RECAPTCHA_PRIVATE_KEY),
)

self.stage.captcha_stage = captcha_stage
Expand Down Expand Up @@ -395,7 +396,7 @@ def test_invalid_with_captcha_retriable(self, mock: Mocker):
captcha_stage = CaptchaStage.objects.create(
name="captcha",
public_key=RECAPTCHA_PUBLIC_KEY,
private_key=RECAPTCHA_PRIVATE_KEY,
private_key_ref=create_test_secret(RECAPTCHA_PRIVATE_KEY),
)

self.stage.captcha_stage = captcha_stage
Expand Down
8 changes: 7 additions & 1 deletion blueprints/example/flows-login-conditional-captcha.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ entries:
name: default-authentication-login
id: default-authentication-login
model: authentik_stages_user_login.userloginstage
- identifiers:
name: default-authentication-flow-captcha private key
id: default-authentication-flow-captcha-private-key
model: authentik_crypto_secrets.secret
attrs:
value: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
- identifiers:
name: default-authentication-flow-captcha
id: default-authentication-flow-captcha
model: authentik_stages_captcha.captchastage
attrs:
public_key: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
private_key: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
private_key_ref: !KeyOf default-authentication-flow-captcha-private-key
- identifiers:
name: default-authentication-identification
id: default-authentication-identification
Expand Down
Loading
Loading