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>
121 lines
3.9 KiB
Ruby
121 lines
3.9 KiB
Ruby
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 "suspicious_passkey_used warns about a blocked clone sign-in" do
|
|
email = SecurityMailer.suspicious_passkey_used(@user, nickname: "Yubikey-5", **CONTEXT)
|
|
|
|
assert_equal [@user.email_address], email.to
|
|
assert_match(/blocked/i, email.subject)
|
|
assert_bodies_contain email, "Yubikey-5"
|
|
assert_bodies_match email, /clon/i
|
|
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
|