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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user