Two problems with sign-count clone detection: - suspicious_sign_count? flagged the case where both the stored and presented counts are 0. Most synced passkeys (Apple/Google) report 0 every time, so every legitimate sign-in was flagged — drowning real signals in noise. Per WebAuthn §6.1.1 a 0 counter means "no counter"; only flag when BOTH counts are non-zero and the new one does not advance. - On a suspicious count the controller only logged a warning and then continued to authenticate and overwrite the stored counter. A cloned credential therefore worked indefinitely. webauthn_verify now rejects the sign-in (no session, no counter update) and emails the user via a new SecurityMailer#suspicious_passkey_used. Tests cover the corrected classification (synced/first-use/normal vs equal/ decreasing) and the new alert email. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
66 lines
2.5 KiB
Ruby
66 lines
2.5 KiB
Ruby
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 suspicious_passkey_used(user, nickname:, ip:, user_agent:, occurred_at:)
|
|
assign_context(user, ip, user_agent, occurred_at)
|
|
@nickname = nickname
|
|
mail subject: "#{SUBJECT_PREFIX}A passkey sign-in was blocked", 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
|