Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
66 changes: 66 additions & 0 deletions authentik/core/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,12 @@ class UserPasswordSetSerializer(PassiveSerializer):
password = CharField(required=True)


class UserPanicButtonSerializer(PassiveSerializer):
"""Payload to trigger panic button for a user"""

reason = CharField(required=True, help_text="Reason for triggering panic button")


class UserServiceAccountSerializer(PassiveSerializer):
"""Payload to create a service account"""

Expand Down Expand Up @@ -782,6 +788,66 @@ def impersonate_end(self, request: Request) -> Response:

return Response(status=204)

@permission_required(None, ["authentik_core.reset_user_password", "authentik_core.change_user"])
@extend_schema(
request=UserPanicButtonSerializer,
responses={
"204": OpenApiResponse(description="Successfully triggered panic button"),
"400": OpenApiResponse(description="Panic button feature is disabled"),
},
)
@action(detail=True, methods=["POST"], permission_classes=[IsAuthenticated])
@validate(UserPanicButtonSerializer)
def panic_button(self, request: Request, pk: int, body: UserPanicButtonSerializer) -> Response:
"""Trigger panic button for a user"""
from secrets import token_urlsafe

if not request.tenant.panic_button_enabled:
LOGGER.debug("Panic button feature is disabled")
return Response(
data={"non_field_errors": [_("Panic button feature is disabled.")]},
status=400,
)

user: User = self.get_object()
reason = body.validated_data["reason"]

if user.pk == request.user.pk:
LOGGER.debug("User attempted to trigger panic button on themselves", user=request.user)
return Response(
data={"non_field_errors": [_("Cannot trigger panic button on yourself.")]},
status=400,
)

with atomic():
user.is_active = False
new_password = token_urlsafe(32)
user.set_password(new_password)
user.save()

Session.objects.filter(authenticatedsession__user=user).delete()
LOGGER.info("Panic button triggered", user=user.username, triggered_by=request.user)

Event.new(
EventAction.PANIC_BUTTON_TRIGGERED,
reason=reason,
affected_user=user.username,
triggered_by=request.user.username,
).from_http(request, user)

from authentik.events.tasks import panic_button_notification

panic_button_notification.send_with_options(
args=(
user.pk,
request.user.pk,
reason,
),
kwargs={},
)

return Response(status=204)

@extend_schema(
responses={
200: inline_serializer(
Expand Down
49 changes: 49 additions & 0 deletions authentik/events/migrations/0014_alter_event_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated by Django 5.2.9 on 2025-12-04 18:05

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("authentik_events", "0013_delete_systemtask"),
]

operations = [
migrations.AlterField(
model_name="event",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("secret_view", "Secret View"),
("secret_rotate", "Secret Rotate"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("panic_button_triggered", "Panic Button Triggered"),
("flow_execution", "Flow Execution"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("system_exception", "System Exception"),
("configuration_error", "Configuration Error"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("email_sent", "Email Sent"),
("update_available", "Update Available"),
("custom_", "Custom Prefix"),
]
),
),
]
2 changes: 2 additions & 0 deletions authentik/events/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ class EventAction(models.TextChoices):
IMPERSONATION_STARTED = "impersonation_started"
IMPERSONATION_ENDED = "impersonation_ended"

PANIC_BUTTON_TRIGGERED = "panic_button_triggered"

FLOW_EXECUTION = "flow_execution"
POLICY_EXECUTION = "policy_execution"
POLICY_EXCEPTION = "policy_exception"
Expand Down
87 changes: 87 additions & 0 deletions authentik/events/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,90 @@ def notification_cleanup():
notifications.delete()
LOGGER.debug("Expired notifications", amount=amount)
self.info(f"Expired {amount} Notifications")


@actor(description=_("Send panic button notification emails."))
def panic_button_notification(affected_user_pk: int, triggered_by_pk: int, reason: str):
"""Send email notifications when panic button is triggered"""
from django.db.models import Q

from authentik.brands.models import Brand
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant

affected_user = User.objects.filter(pk=affected_user_pk).first()
triggered_by = User.objects.filter(pk=triggered_by_pk).first()
if not affected_user or not triggered_by:
LOGGER.warning("panic button notification: users not found")
return

tenant = Tenant.objects.first()
if not tenant:
LOGGER.warning("panic button notification: tenant not found")
return

email_stages = EmailStage.objects.all()
if not email_stages.exists():
LOGGER.warning("panic button notification: no email stage configured")
return

email_stage = email_stages.first()
template_context = {
"affected_user": affected_user,
"triggered_by": triggered_by,
"reason": reason,
}

if tenant.panic_button_notify_user and affected_user.email:
user_message = TemplateEmailMessage(
subject=_("Security Alert: Your Account Has Been Locked"),
to=[(affected_user.name, affected_user.email)],
template_name="email/panic_button.html",
language="en",
template_context=template_context,
)
send_mails(email_stage, user_message)
LOGGER.info(
"panic button notification sent to user",
affected_user=affected_user.username,
)

if tenant.panic_button_notify_admins:
admin_users = User.objects.filter(
Q(is_superuser=True) | Q(ak_groups__is_superuser=True)
).distinct()
admin_recipients = []
for admin in admin_users:
if admin.email and admin.pk != affected_user_pk:
admin_recipients.append((admin.name, admin.email))

if admin_recipients:
admin_message = TemplateEmailMessage(
subject=_("Security Alert: Panic Button Triggered"),
to=admin_recipients,
template_name="email/panic_button_admin.html",
language="en",
template_context=template_context,
)
send_mails(email_stage, admin_message)
LOGGER.info(
"panic button notification sent to admins",
affected_user=affected_user.username,
admin_count=len(admin_recipients),
)

if tenant.panic_button_notify_security and tenant.panic_button_security_email:
security_message = TemplateEmailMessage(
subject=_("SECURITY ALERT: Panic Button Triggered"),
to=[("Security Team", tenant.panic_button_security_email)],
template_name="email/panic_button_security.html",
language="en",
template_context=template_context,
)
send_mails(email_stage, security_message)
LOGGER.info(
"panic button notification sent to security",
affected_user=affected_user.username,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Generated by Django 5.2.9 on 2025-12-04 18:05

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("authentik_policies_event_matcher", "0023_alter_eventmatcherpolicy_action_and_more"),
]

operations = [
migrations.AlterField(
model_name="eventmatcherpolicy",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("secret_view", "Secret View"),
("secret_rotate", "Secret Rotate"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("panic_button_triggered", "Panic Button Triggered"),
("flow_execution", "Flow Execution"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("system_exception", "System Exception"),
("configuration_error", "Configuration Error"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("email_sent", "Email Sent"),
("update_available", "Update Available"),
("custom_", "Custom Prefix"),
],
default=None,
help_text="Match created events with this action type. When left empty, all action types will be matched.",
null=True,
),
),
]
38 changes: 38 additions & 0 deletions authentik/stages/email/templates/email/panic_button.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{% extends "email/base.html" %}

{% load i18n %}

{% block content %}
<tr>
<td align="center">
<h1 style="color: #c9190b;">
{% trans "Security Alert" %}
</h1>
</td>
</tr>
<tr>
<td align="center">
<table border="0">
<tr>
<td align="center" style="max-width: 400px; padding: 20px 0; color: #212124;">
<p>{% trans "Your account has been locked for security reasons." %}</p>
<p>{% trans "The following actions have been taken:" %}</p>
<ul style="text-align: left; max-width: 350px; margin: 10px auto;">
<li>{% trans "Your account has been locked" %}</li>
<li>{% trans "Your password has been reset" %}</li>
<li>{% trans "All active sessions have been terminated" %}</li>
</ul>
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}

{% block sub_content %}
<tr>
<td style="padding: 20px; font-size: 12px; color: #212124;" align="center">
{% trans "if the dev didn't mess up the perms and a regular user can send a POST to the endpoint, please contact your administrator immediately." %}
</td>
</tr>
{% endblock %}
49 changes: 49 additions & 0 deletions authentik/stages/email/templates/email/panic_button_admin.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{% extends "email/base.html" %}

{% load i18n %}

{% block content %}
<tr>
<td align="center">
<h1 style="color: #c9190b;">
{% trans "Panic Button Triggered" %}
</h1>
</td>
</tr>
<tr>
<td align="center">
<table border="0">
<tr>
<td align="center" style="max-width: 500px; padding: 20px 0; color: #212124;">
<p><strong>{% trans "User Account Emergency Lockout" %}</strong></p>
<table style="margin: 15px auto; text-align: left; border-collapse: collapse;">
<tr>
<td style="padding: 8px; font-weight: bold;">{% trans "Affected User:" %}</td>
<td style="padding: 8px;">{{ affected_user.name }} ({{ affected_user.username }})</td>
</tr>
<tr style="background-color: #f5f5f5;">
<td style="padding: 8px; font-weight: bold;">{% trans "Triggered By:" %}</td>
<td style="padding: 8px;">{{ triggered_by.name }} ({{ triggered_by.username }})</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; vertical-align: top;">{% trans "Reason:" %}</td>
<td style="padding: 8px;">{{ reason }}</td>
</tr>
</table>
<p style="margin-top: 15px;">
{% trans "Actions taken: Account locked, password reset, all sessions terminated." %}
</p>
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}

{% block sub_content %}
<tr>
<td style="padding: 20px; font-size: 12px; color: #212124;" align="center">
{% trans "This event has been logged in the audit trail." %}
</td>
</tr>
{% endblock %}
Loading
Loading