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)
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
redirect_to api_key_path(@api_key)
else
@@ -31,6 +32,7 @@ class ApiKeysController < ApplicationController
def destroy
@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."
end

View File

@@ -14,6 +14,10 @@ class ApplicationController < ActionController::Base
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
# More robust than regex - handles URL encoding, edge cases, etc.
#

View File

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

View File

@@ -15,6 +15,7 @@ class ProfilesController < ApplicationController
end
if @user.update(password_params)
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
redirect_to profile_path, notice: "Password updated successfully."
else
render :show, status: :unprocessable_entity
@@ -27,7 +28,15 @@ class ProfilesController < ApplicationController
return
end
old_email = @user.email_address
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."
else
render :show, status: :unprocessable_entity

View File

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

View File

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