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

@@ -14,6 +14,7 @@ class ApiKeysController < ApplicationController
@api_key = Current.session.user.api_keys.build(api_key_params) @api_key = Current.session.user.api_keys.build(api_key_params)
if @api_key.save if @api_key.save
SecurityMailer.api_key_created(Current.session.user, name: @api_key.name, **security_event_context).deliver_later
flash[:api_key_token] = @api_key.plaintext_token flash[:api_key_token] = @api_key.plaintext_token
redirect_to api_key_path(@api_key) redirect_to api_key_path(@api_key)
else else
@@ -31,6 +32,7 @@ class ApiKeysController < ApplicationController
def destroy def destroy
@api_key.revoke! @api_key.revoke!
SecurityMailer.api_key_revoked(@api_key.user, name: @api_key.name, **security_event_context).deliver_later
redirect_to api_keys_path, notice: "API key revoked." redirect_to api_keys_path, notice: "API key revoked."
end end

View File

@@ -14,6 +14,10 @@ class ApplicationController < ActionController::Base
private private
def security_event_context
{ip: request.remote_ip, user_agent: request.user_agent, occurred_at: Time.current}
end
# Remove a query parameter from a URL using proper URI parsing # Remove a query parameter from a URL using proper URI parsing
# More robust than regex - handles URL encoding, edge cases, etc. # More robust than regex - handles URL encoding, edge cases, etc.
# #

View File

@@ -20,6 +20,7 @@ class PasswordsController < ApplicationController
def update def update
if @user.update(params.permit(:password, :password_confirmation)) if @user.update(params.permit(:password, :password_confirmation))
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
@user.sessions.destroy_all @user.sessions.destroy_all
redirect_to signin_path, notice: "Password has been reset." redirect_to signin_path, notice: "Password has been reset."
else else

View File

@@ -15,6 +15,7 @@ class ProfilesController < ApplicationController
end end
if @user.update(password_params) if @user.update(password_params)
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
redirect_to profile_path, notice: "Password updated successfully." redirect_to profile_path, notice: "Password updated successfully."
else else
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
@@ -27,7 +28,15 @@ class ProfilesController < ApplicationController
return return
end end
old_email = @user.email_address
if @user.update(email_params) if @user.update(email_params)
new_email = @user.email_address
if old_email != new_email
context = security_event_context
[old_email, new_email].uniq.each do |recipient|
SecurityMailer.email_address_changed(@user, recipient: recipient, old_email: old_email, new_email: new_email, **context).deliver_later
end
end
redirect_to profile_path, notice: "Email updated successfully." redirect_to profile_path, notice: "Email updated successfully."
else else
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity

View File

@@ -103,6 +103,7 @@ class TotpController < ApplicationController
# Generate new backup codes and store BCrypt hashes # Generate new backup codes and store BCrypt hashes
plain_codes = @user.send(:generate_backup_codes) plain_codes = @user.send(:generate_backup_codes)
@user.save! @user.save!
SecurityMailer.backup_codes_regenerated(@user, **security_event_context).deliver_later
# Store plain codes temporarily in session for display # Store plain codes temporarily in session for display
session[:temp_backup_codes] = plain_codes session[:temp_backup_codes] = plain_codes
@@ -136,6 +137,7 @@ class TotpController < ApplicationController
end end
@user.disable_totp! @user.disable_totp!
SecurityMailer.totp_disabled(@user, **security_event_context).deliver_later
redirect_to profile_path, notice: "Two-factor authentication has been disabled." redirect_to profile_path, notice: "Two-factor authentication has been disabled."
end end

View File

@@ -91,6 +91,8 @@ class WebauthnController < ApplicationController
backup_state: backup_state backup_state: backup_state
) )
SecurityMailer.passkey_added(user, nickname: @webauthn_credential.nickname, **security_event_context).deliver_later
render json: { render json: {
success: true, success: true,
message: "Passkey '#{nickname}' registered successfully", message: "Passkey '#{nickname}' registered successfully",
@@ -109,8 +111,11 @@ class WebauthnController < ApplicationController
# Remove a passkey # Remove a passkey
def destroy def destroy
nickname = @webauthn_credential.nickname nickname = @webauthn_credential.nickname
user = @webauthn_credential.user
@webauthn_credential.destroy @webauthn_credential.destroy
SecurityMailer.passkey_removed(user, nickname: nickname, **security_event_context).deliver_later
respond_to do |format| respond_to do |format|
format.html { format.html {
redirect_to profile_path, redirect_to profile_path,

View File

@@ -0,0 +1,59 @@
class SecurityMailer < ApplicationMailer
SUBJECT_PREFIX = "[Clinch security alert] ".freeze
def password_changed(user, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
mail subject: "#{SUBJECT_PREFIX}Your password was changed", to: user.email_address
end
def totp_disabled(user, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
mail subject: "#{SUBJECT_PREFIX}Two-factor authentication was disabled", to: user.email_address
end
def backup_codes_regenerated(user, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
mail subject: "#{SUBJECT_PREFIX}Two-factor backup codes were regenerated", to: user.email_address
end
def passkey_added(user, nickname:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@nickname = nickname
mail subject: "#{SUBJECT_PREFIX}A passkey was added to your account", to: user.email_address
end
def passkey_removed(user, nickname:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@nickname = nickname
mail subject: "#{SUBJECT_PREFIX}A passkey was removed from your account", to: user.email_address
end
def api_key_created(user, name:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@api_key_name = name
mail subject: "#{SUBJECT_PREFIX}An API key was created on your account", to: user.email_address
end
def api_key_revoked(user, name:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@api_key_name = name
mail subject: "#{SUBJECT_PREFIX}An API key was revoked on your account", to: user.email_address
end
def email_address_changed(user, recipient:, old_email:, new_email:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@recipient = recipient
@old_email = old_email
@new_email = new_email
mail subject: "#{SUBJECT_PREFIX}Your account email address was changed", to: recipient
end
private
def assign_context(user, ip, user_agent, occurred_at)
@user = user
@ip = ip
@user_agent = user_agent
@occurred_at = occurred_at
end
end

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" %>

View File

@@ -0,0 +1,111 @@
require "test_helper"
class SecurityMailerTest < ActionMailer::TestCase
CONTEXT = {
ip: "203.0.113.42",
user_agent: "Mozilla/5.0 (TestBrowser)",
occurred_at: Time.utc(2026, 5, 2, 13, 37)
}.freeze
def setup
@user = User.create!(email_address: "security_mailer_test@example.com", password: "password123")
end
def teardown
@user.destroy
end
test "password_changed names the user and includes request metadata" do
email = SecurityMailer.password_changed(@user, **CONTEXT)
assert_equal [@user.email_address], email.to
assert_match(/password was changed/i, email.subject)
assert_bodies_contain email, @user.email_address
assert_bodies_contain email, "203.0.113.42"
assert_bodies_contain email, "TestBrowser"
end
test "totp_disabled describes the change" do
email = SecurityMailer.totp_disabled(@user, **CONTEXT)
assert_equal [@user.email_address], email.to
assert_match(/two-factor.*disabled/i, email.subject)
assert_bodies_contain email, "203.0.113.42"
end
test "backup_codes_regenerated mentions previous codes are invalid" do
email = SecurityMailer.backup_codes_regenerated(@user, **CONTEXT)
assert_match(/backup codes/i, email.subject)
assert_bodies_match email, /previous backup codes are now invalid/i
end
test "passkey_added includes the nickname" do
email = SecurityMailer.passkey_added(@user, nickname: "Yubikey-5", **CONTEXT)
assert_match(/passkey.*added/i, email.subject)
assert_bodies_contain email, "Yubikey-5"
end
test "passkey_removed includes the nickname" do
email = SecurityMailer.passkey_removed(@user, nickname: "Old MacBook", **CONTEXT)
assert_match(/passkey.*removed/i, email.subject)
assert_bodies_contain email, "Old MacBook"
end
test "api_key_created includes the key name" do
email = SecurityMailer.api_key_created(@user, name: "CI bot", **CONTEXT)
assert_match(/api key.*created/i, email.subject)
assert_bodies_contain email, "CI bot"
end
test "api_key_revoked includes the key name" do
email = SecurityMailer.api_key_revoked(@user, name: "Old token", **CONTEXT)
assert_match(/api key.*revoked/i, email.subject)
assert_bodies_contain email, "Old token"
end
test "email_address_changed sent to new address confirms the new value" do
email = SecurityMailer.email_address_changed(@user,
recipient: "new@example.com",
old_email: "old@example.com",
new_email: "new@example.com",
**CONTEXT)
assert_equal ["new@example.com"], email.to
assert_bodies_contain email, "new@example.com"
assert_bodies_contain email, "old@example.com"
assert_bodies_no_match email, /reset emails for the account/
end
test "email_address_changed sent to old address warns about reset emails" do
email = SecurityMailer.email_address_changed(@user,
recipient: "old@example.com",
old_email: "old@example.com",
new_email: "new@example.com",
**CONTEXT)
assert_equal ["old@example.com"], email.to
assert_bodies_match email, /reset emails for the account/
end
private
def assert_bodies_contain(email, fragment)
assert_match fragment, email.text_part.body.to_s, "expected text body to contain #{fragment.inspect}"
assert_match fragment, email.html_part.body.to_s, "expected html body to contain #{fragment.inspect}"
end
def assert_bodies_match(email, regex)
assert_match regex, email.text_part.body.to_s
assert_match regex, email.html_part.body.to_s
end
def assert_bodies_no_match(email, regex)
refute_match regex, email.text_part.body.to_s
refute_match regex, email.html_part.body.to_s
end
end