Notify users out-of-band when security settings change

Previously only TOTP-enabled triggered an email. Every other
security-relevant change — password change, TOTP disable, passkey
add/remove, API key create/revoke, email address change, backup-code
regeneration — happened silently, so an attacker on a stolen session
could quietly drop 2FA or hijack the email with no signal to the
account holder.

Add SecurityMailer with one method per event. Each email carries the
request IP, user-agent, and timestamp so the user can spot unfamiliar
activity. Email-address changes notify both the old and new addresses
with directional language; the old-address copy explicitly warns that
whoever made the change can now receive password reset emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-05-02 23:52:12 +10:00
parent 09e9b32e46
commit cc93f72f0a
26 changed files with 345 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
<hr>
<p>
This action was recorded at <strong><%= @occurred_at.to_fs(:long) %></strong>
from IP <strong><%= @ip %></strong>
using <strong><%= @user_agent.presence || "an unknown client" %></strong>.
</p>
<p>
If you did <strong>not</strong> perform this action, reset your password
immediately and contact your administrator.
</p>

View File

@@ -0,0 +1,7 @@
---
This action was recorded at <%= @occurred_at.to_fs(:long) %>
from IP <%= @ip %>
using <%= @user_agent.presence || "an unknown client" %>.
If you did not perform this action, reset your password immediately
and contact your administrator.

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
A new API key (<strong><%= @api_key_name %></strong>) was just created
on your Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A new API key ("<%= @api_key_name %>") was just created on your Clinch
account (<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
The API key <strong><%= @api_key_name %></strong> was just revoked
on your Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
The API key "<%= @api_key_name %>" was just revoked on your Clinch
account (<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,9 @@
<p>Hello,</p>
<p>
A new set of two-factor backup codes was generated on your Clinch
account (<strong><%= @user.email_address %></strong>).
Any previous backup codes are now invalid.
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A new set of two-factor backup codes was generated on your Clinch account
(<%= @user.email_address %>). Any previous backup codes are now invalid.
<%= render "event_metadata" %>

View File

@@ -0,0 +1,22 @@
<p>Hello,</p>
<% if @recipient == @new_email %>
<p>
The email address on your Clinch account is now
<strong><%= @new_email %></strong>.
It was previously <strong><%= @old_email %></strong>.
</p>
<% else %>
<p>
The email address on your Clinch account was changed away from this
address (<strong><%= @old_email %></strong>) to
<strong><%= @new_email %></strong>.
</p>
<p>
If this was <strong>not</strong> you, contact your administrator
immediately — whoever made the change can now receive password
reset emails for the account.
</p>
<% end %>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,14 @@
Hello,
<% if @recipient == @new_email %>
The email address on your Clinch account is now <%= @new_email %>.
It was previously <%= @old_email %>.
<% else %>
The email address on your Clinch account was changed away from this
address (<%= @old_email %>) to <%= @new_email %>.
If this was not you, contact your administrator immediately — whoever
made the change can now receive password reset emails for the account.
<% end %>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
A new passkey (<strong><%= @nickname %></strong>) was just added to your
Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A new passkey ("<%= @nickname %>") was just added to your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
A passkey (<strong><%= @nickname %></strong>) was just removed from your
Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A passkey ("<%= @nickname %>") was just removed from your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
The password on your Clinch account
(<strong><%= @user.email_address %></strong>) was just changed.
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,5 @@
Hello,
The password on your Clinch account (<%= @user.email_address %>) was just changed.
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
Two-factor authentication was just <strong>disabled</strong> on your
Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
Two-factor authentication was just disabled on your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>