events: add EMAIL_SENT event, show sent emails in event log

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-06-09 10:27:34 +02:00
parent 2addf71f37
commit 2210497569
11 changed files with 234 additions and 21 deletions

View file

@ -0,0 +1,45 @@
# Generated by Django 3.2.3 on 2021-06-09 07:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0014_expiry"),
]
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"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("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"),
("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"),
]
),
),
]

View file

@ -77,6 +77,7 @@ class EventAction(models.TextChoices):
MODEL_CREATED = "model_created"
MODEL_UPDATED = "model_updated"
MODEL_DELETED = "model_deleted"
EMAIL_SENT = "email_sent"
UPDATE_AVAILABLE = "update_available"

View file

@ -0,0 +1,47 @@
# Generated by Django 3.2.3 on 2021-06-09 07:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_event_matcher", "0015_alter_eventmatcherpolicy_app"),
]
operations = [
migrations.AlterField(
model_name="eventmatcherpolicy",
name="action",
field=models.TextField(
blank=True,
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"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("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"),
("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"),
],
help_text="Match created events with this action type. When left empty, all action types will be matched.",
),
),
]

View file

@ -45,6 +45,9 @@ class PolicyRequest:
return
self.context["geoip"] = GEOIP_READER.city(client_ip)
def __repr__(self) -> str:
return self.__str__()
def __str__(self):
text = f"<PolicyRequest user={self.user}"
if self.obj:

View file

@ -4,7 +4,7 @@
------------------------------------- */
* {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-family: Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
}
@ -91,7 +91,7 @@ body {
TYPOGRAPHY
------------------------------------- */
h1, h2, h3 {
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
font-family: Helvetica, Arial, sans-serif;
color: #000;
margin: 40px 0 0;
line-height: 1.2em;

View file

@ -9,6 +9,7 @@ from django.core.mail.utils import DNS_NAME
from django.utils.text import slugify
from structlog.stdlib import get_logger
from authentik.events.models import Event, EventAction
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
from authentik.root.celery import CELERY_APP
from authentik.stages.email.models import EmailStage
@ -26,6 +27,14 @@ def send_mails(stage: EmailStage, *messages: list[EmailMultiAlternatives]):
return promise
def get_email_body(email: EmailMultiAlternatives) -> str:
"""Get the email's body. Will return HTML alt if set, otherwise plain text body"""
for alt_content, alt_type in email.alternatives:
if alt_type == "text/html":
return alt_content
return email.body
@CELERY_APP.task(
bind=True,
autoretry_for=(
@ -68,6 +77,14 @@ def send_mail(
LOGGER.debug("Sending mail", to=message_object.to)
stage.backend.send_messages([message_object])
Event.new(
EventAction.EMAIL_SENT,
message=(f"Email to {', '.join(message_object.to)} sent"),
subject=message_object.subject,
body=get_email_body(message_object),
from_email=message_object.from_email,
to_email=message_object.to,
).save()
self.set_status(
TaskResult(
TaskResultStatus.SUCCESSFUL,

View file

@ -8,6 +8,7 @@ from django.test import Client, TestCase
from django.urls import reverse
from authentik.core.models import User
from authentik.events.models import Event, EventAction
from authentik.flows.markers import StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
@ -55,6 +56,13 @@ class TestEmailStageSending(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik")
events = Event.objects.filter(action=EventAction.EMAIL_SENT)
self.assertEqual(len(events), 1)
event = events.first()
self.assertEqual(event.context["message"], "Email to test@beryju.org sent")
self.assertEqual(event.context["subject"], "authentik")
self.assertEqual(event.context["to_email"], ["test@beryju.org"])
self.assertEqual(event.context["from_email"], "system@authentik.local")
def test_send_error(self):
"""Test error during sending (sending will be retried)"""

View file

@ -15385,6 +15385,7 @@ components:
- model_created
- model_updated
- model_deleted
- email_sent
- update_available
- custom_
type: string

View file

@ -1276,6 +1276,10 @@ msgstr "Email"
msgid "Email address"
msgstr "Email address"
#: src/pages/events/EventInfo.ts
msgid "Email info:"
msgstr "Email info:"
#: src/flows/stages/identification/IdentificationStage.ts
msgid "Email or username"
msgstr "Email or username"
@ -1634,6 +1638,10 @@ msgstr "Forward auth (single application)"
msgid "Friendly Name"
msgstr "Friendly Name"
#: src/pages/events/EventInfo.ts
msgid "From"
msgstr "From"
#: src/pages/stages/email/EmailStageForm.ts
msgid "From address"
msgstr "From address"
@ -2149,6 +2157,10 @@ msgstr "Maximum age (in days)"
msgid "Members"
msgstr "Members"
#: src/pages/events/EventInfo.ts
msgid "Message"
msgstr "Message"
#: src/pages/applications/ApplicationCheckAccessForm.ts
#: src/pages/events/EventInfo.ts
#: src/pages/policies/PolicyTestForm.ts
@ -3391,6 +3403,7 @@ msgstr "Status: Enabled"
msgid "Stop impersonation"
msgstr "Stop impersonation"
#: src/pages/events/EventInfo.ts
#: src/pages/stages/email/EmailStageForm.ts
msgid "Subject"
msgstr "Subject"
@ -3865,6 +3878,10 @@ msgstr "Timeout"
msgid "Title"
msgstr "Title"
#: src/pages/events/EventInfo.ts
msgid "To"
msgstr "To"
#: src/pages/sources/ldap/LDAPSourceForm.ts
msgid "To use SSL instead, use 'ldaps://' and disable this option."
msgstr "To use SSL instead, use 'ldaps://' and disable this option."

View file

@ -1268,6 +1268,10 @@ msgstr ""
msgid "Email address"
msgstr ""
#:
msgid "Email info:"
msgstr ""
#:
msgid "Email or username"
msgstr ""
@ -1626,6 +1630,10 @@ msgstr ""
msgid "Friendly Name"
msgstr ""
#:
msgid "From"
msgstr ""
#:
msgid "From address"
msgstr ""
@ -2141,6 +2149,10 @@ msgstr ""
msgid "Members"
msgstr ""
#:
msgid "Message"
msgstr ""
#:
#:
#:
@ -3383,6 +3395,7 @@ msgstr ""
msgid "Stop impersonation"
msgstr ""
#:
#:
msgid "Subject"
msgstr ""
@ -3853,6 +3866,10 @@ msgstr ""
msgid "Title"
msgstr ""
#:
msgid "To"
msgstr ""
#:
msgid "To use SSL instead, use 'ldaps://' and disable this option."
msgstr ""

View file

@ -1,7 +1,7 @@
import { t } from "@lingui/macro";
import { css, CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element";
import { until } from "lit-html/directives/until";
import { FlowsApi } from "authentik-api";
import { ActionEnum, FlowsApi } from "authentik-api";
import "../../elements/Spinner";
import "../../elements/Expand";
import { PFSize } from "../../elements/Spinner";
@ -32,6 +32,10 @@ export class EventInfo extends LitElement {
.pf-l-flex__item {
min-width: 25%;
}
iframe {
width: 100%;
height: 50rem;
}
`
];
}
@ -76,6 +80,50 @@ export class EventInfo extends LitElement {
</dl>`;
}
getEmailInfo(context: EventContext): TemplateResult {
if (context === null) {
return html`<span>-</span>`;
}
return html`<dl class="pf-c-description-list pf-m-horizontal">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${t`Message`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${context.message}</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${t`Subject`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${context.subject}</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${t`From`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${context.from_email}</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${t`To`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${(context.to_email as string[]).map(to => {
return html`<li>${to}</li>`;
})}
</div>
</dd>
</div>
</dl>`;
}
defaultResponse(): TemplateResult {
return html`<div class="pf-l-flex">
<div class="pf-l-flex__item">
@ -94,14 +142,14 @@ export class EventInfo extends LitElement {
return html`<ak-spinner size=${PFSize.Medium}></ak-spinner>`;
}
switch (this.event?.action) {
case "model_created":
case "model_updated":
case "model_deleted":
case ActionEnum.ModelCreated:
case ActionEnum.ModelUpdated:
case ActionEnum.ModelDeleted:
return html`
<h3>${t`Affected model:`}</h3>
${this.getModelInfo(this.event.context?.model as EventContext)}
`;
case "authorize_application":
case ActionEnum.AuthorizeApplication:
return html`<div class="pf-l-flex">
<div class="pf-l-flex__item">
<h3>${t`Authorized application:`}</h3>
@ -118,15 +166,17 @@ export class EventInfo extends LitElement {
</div>
</div>
<ak-expand>${this.defaultResponse()}</ak-expand>`;
case "login_failed":
return html`
<h3>${t`Attempted to log in as ${this.event.context.username}`}</h3>
<ak-expand>${this.defaultResponse()}</ak-expand>`;
case "secret_view":
case ActionEnum.EmailSent:
return html`<h3>${t`Email info:`}</h3>
${this.getEmailInfo(this.event.context)}
<ak-expand>
<iframe srcdoc=${this.event.context.body}></iframe>
</ak-expand>`;
case ActionEnum.SecretView:
return html`
<h3>${t`Secret:`}</h3>
${this.getModelInfo(this.event.context.secret as EventContext)}`;
case "property_mapping_exception":
case ActionEnum.PropertyMappingException:
return html`<div class="pf-l-flex">
<div class="pf-l-flex__item">
<h3>${t`Exception`}</h3>
@ -138,7 +188,7 @@ export class EventInfo extends LitElement {
</div>
</div>
<ak-expand>${this.defaultResponse()}</ak-expand>`;
case "policy_exception":
case ActionEnum.PolicyException:
return html`<div class="pf-l-flex">
<div class="pf-l-flex__item">
<h3>${t`Binding`}</h3>
@ -157,7 +207,7 @@ export class EventInfo extends LitElement {
</div>
</div>
<ak-expand>${this.defaultResponse()}</ak-expand>`;
case "policy_execution":
case ActionEnum.PolicyExecution:
return html`<div class="pf-l-flex">
<div class="pf-l-flex__item">
<h3>${t`Binding`}</h3>
@ -185,16 +235,19 @@ export class EventInfo extends LitElement {
</div>
</div>
<ak-expand>${this.defaultResponse()}</ak-expand>`;
case "configuration_error":
case ActionEnum.ConfigurationError:
return html`<h3>${this.event.context.message}</h3>
<ak-expand>${this.defaultResponse()}</ak-expand>`;
case "update_available":
case ActionEnum.UpdateAvailable:
return html`<h3>${t`New version available!`}</h3>
<a target="_blank" href="https://github.com/goauthentik/authentik/releases/tag/version%2F${this.event.context.new_version}">${this.event.context.new_version}</a>
`;
<a
target="_blank"
href="https://github.com/goauthentik/authentik/releases/tag/version%2F${this.event.context.new_version}">
${this.event.context.new_version}
</a>`;
// Action types which typically don't record any extra context.
// If context is not empty, we fall to the default response.
case "login":
case ActionEnum.Login:
if ("using_source" in this.event.context) {
return html`<div class="pf-l-flex">
<div class="pf-l-flex__item">
@ -204,7 +257,11 @@ export class EventInfo extends LitElement {
</div>`;
}
return this.defaultResponse();
case "logout":
case ActionEnum.LoginFailed:
return html`
<h3>${t`Attempted to log in as ${this.event.context.username}`}</h3>
<ak-expand>${this.defaultResponse()}</ak-expand>`;
case ActionEnum.Logout:
if (this.event.context === {}) {
return html`<span>${t`No additional data available.`}</span>`;
}