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_CREATED = "model_created"
MODEL_UPDATED = "model_updated" MODEL_UPDATED = "model_updated"
MODEL_DELETED = "model_deleted" MODEL_DELETED = "model_deleted"
EMAIL_SENT = "email_sent"
UPDATE_AVAILABLE = "update_available" 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 return
self.context["geoip"] = GEOIP_READER.city(client_ip) self.context["geoip"] = GEOIP_READER.city(client_ip)
def __repr__(self) -> str:
return self.__str__()
def __str__(self): def __str__(self):
text = f"<PolicyRequest user={self.user}" text = f"<PolicyRequest user={self.user}"
if self.obj: if self.obj:

View File

@ -4,7 +4,7 @@
------------------------------------- */ ------------------------------------- */
* { * {
margin: 0; margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
} }
@ -91,7 +91,7 @@ body {
TYPOGRAPHY TYPOGRAPHY
------------------------------------- */ ------------------------------------- */
h1, h2, h3 { h1, h2, h3 {
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-family: Helvetica, Arial, sans-serif;
color: #000; color: #000;
margin: 40px 0 0; margin: 40px 0 0;
line-height: 1.2em; 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 django.utils.text import slugify
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.events.models import Event, EventAction
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
@ -26,6 +27,14 @@ def send_mails(stage: EmailStage, *messages: list[EmailMultiAlternatives]):
return promise 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( @CELERY_APP.task(
bind=True, bind=True,
autoretry_for=( autoretry_for=(
@ -68,6 +77,14 @@ def send_mail(
LOGGER.debug("Sending mail", to=message_object.to) LOGGER.debug("Sending mail", to=message_object.to)
stage.backend.send_messages([message_object]) 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( self.set_status(
TaskResult( TaskResult(
TaskResultStatus.SUCCESSFUL, TaskResultStatus.SUCCESSFUL,

View File

@ -8,6 +8,7 @@ from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from authentik.core.models import User from authentik.core.models import User
from authentik.events.models import Event, EventAction
from authentik.flows.markers import StageMarker from authentik.flows.markers import StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan 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(response.status_code, 200)
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik") 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): def test_send_error(self):
"""Test error during sending (sending will be retried)""" """Test error during sending (sending will be retried)"""

View File

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

View File

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

View File

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