mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 21:04:12 +00:00
feat(auth): add WebAuthn MFA credentials (#1628)
* feat(auth): add WebAuthn MFA credentials * fix(auth): harden WebAuthn MFA review paths * fix(auth): polish WebAuthn error handling * fix(auth): handle duplicate WebAuthn credential races * fix(auth): permit WebAuthn credential params * fix(auth): trim WebAuthn registration controller cleanup * fix(auth): tighten WebAuthn MFA handling * fix(auth): pin WebAuthn relying party config
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
require "test_helper"
|
||||
require "webauthn/fake_client"
|
||||
|
||||
class MfaControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@@ -64,6 +65,21 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_select "form[action=?]", verify_mfa_path
|
||||
end
|
||||
|
||||
test "verify shows WebAuthn option when credentials are registered" do
|
||||
@user.setup_mfa!
|
||||
@user.enable_mfa!
|
||||
register_webauthn_credential
|
||||
sign_out
|
||||
|
||||
post sessions_path, params: { email: @user.email, password: user_password_test }
|
||||
get verify_mfa_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "button", text: I18n.t("mfa.verify.webauthn_button")
|
||||
assert_select "[data-webauthn-authentication-error-fallback-value=?]", I18n.t("mfa.verify_webauthn.invalid_credential")
|
||||
assert_select "p[data-webauthn-authentication-target='error'][aria-live='assertive'][aria-atomic='true'][aria-hidden='true']"
|
||||
end
|
||||
|
||||
test "verify_code authenticates with valid TOTP" do
|
||||
@user.setup_mfa!
|
||||
@user.enable_mfa!
|
||||
@@ -105,9 +121,114 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_not Session.exists?(user_id: @user.id)
|
||||
end
|
||||
|
||||
test "webauthn_options require a pending MFA session" do
|
||||
post webauthn_options_mfa_path, as: :json
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "verify_webauthn authenticates with a registered credential" do
|
||||
@user.setup_mfa!
|
||||
@user.enable_mfa!
|
||||
client = register_webauthn_credential
|
||||
stored_credential = @user.webauthn_credentials.first
|
||||
sign_out
|
||||
|
||||
post sessions_path, params: { email: @user.email, password: user_password_test }
|
||||
post webauthn_options_mfa_path, as: :json
|
||||
assert_response :success
|
||||
|
||||
options = JSON.parse(response.body)
|
||||
assertion = client.get(
|
||||
challenge: options.fetch("challenge"),
|
||||
rp_id: "www.example.com",
|
||||
allow_credentials: [ stored_credential.credential_id ]
|
||||
)
|
||||
|
||||
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
|
||||
|
||||
assert_response :success
|
||||
assert_equal root_path, JSON.parse(response.body).fetch("redirect_url")
|
||||
assert Session.exists?(user_id: @user.id)
|
||||
assert stored_credential.reload.last_used_at.present?
|
||||
assert_operator stored_credential.sign_count, :>, 0
|
||||
end
|
||||
|
||||
test "verify_webauthn authenticates with configured relying party id" do
|
||||
with_webauthn_config(rp_id: "example.test", allowed_origins: [ "https://app.example.test" ]) do
|
||||
@user.setup_mfa!
|
||||
@user.enable_mfa!
|
||||
client = register_webauthn_credential(origin: "https://app.example.test", rp_id: "example.test")
|
||||
stored_credential = @user.webauthn_credentials.first
|
||||
sign_out
|
||||
|
||||
post sessions_path, params: { email: @user.email, password: user_password_test }
|
||||
post webauthn_options_mfa_path, as: :json
|
||||
assert_response :success
|
||||
|
||||
options = JSON.parse(response.body)
|
||||
assert_equal "example.test", options.fetch("rpId")
|
||||
assertion = client.get(
|
||||
challenge: options.fetch("challenge"),
|
||||
rp_id: "example.test",
|
||||
allow_credentials: [ stored_credential.credential_id ]
|
||||
)
|
||||
|
||||
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
|
||||
|
||||
assert_response :success
|
||||
assert_equal root_path, JSON.parse(response.body).fetch("redirect_url")
|
||||
end
|
||||
end
|
||||
|
||||
test "verify_webauthn rejects invalid credentials" do
|
||||
@user.setup_mfa!
|
||||
@user.enable_mfa!
|
||||
client = register_webauthn_credential
|
||||
stored_credential = @user.webauthn_credentials.first
|
||||
sign_out
|
||||
|
||||
post sessions_path, params: { email: @user.email, password: user_password_test }
|
||||
post webauthn_options_mfa_path, as: :json
|
||||
options = JSON.parse(response.body)
|
||||
assertion = client.get(
|
||||
challenge: options.fetch("challenge"),
|
||||
rp_id: "www.example.com",
|
||||
allow_credentials: [ stored_credential.credential_id ]
|
||||
)
|
||||
assertion["id"] = "invalid"
|
||||
|
||||
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_not Session.exists?(user_id: @user.id)
|
||||
end
|
||||
|
||||
test "verify_webauthn rejects malformed credential payloads" do
|
||||
@user.setup_mfa!
|
||||
@user.enable_mfa!
|
||||
register_webauthn_credential
|
||||
sign_out
|
||||
|
||||
post sessions_path, params: { email: @user.email, password: user_password_test }
|
||||
post webauthn_options_mfa_path, as: :json
|
||||
assert_response :success
|
||||
|
||||
post verify_webauthn_mfa_path, params: { credential: [] }, as: :json
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal I18n.t("mfa.verify_webauthn.invalid_credential"), JSON.parse(response.body).fetch("error")
|
||||
assert_not Session.exists?(user_id: @user.id)
|
||||
end
|
||||
|
||||
test "disable removes MFA" do
|
||||
@user.setup_mfa!
|
||||
@user.enable_mfa!
|
||||
@user.webauthn_credentials.create!(
|
||||
nickname: "YubiKey",
|
||||
credential_id: "disable-mfa-credential",
|
||||
public_key: "public-key"
|
||||
)
|
||||
|
||||
delete disable_mfa_path
|
||||
|
||||
@@ -115,5 +236,35 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_not @user.reload.otp_required?
|
||||
assert_nil @user.otp_secret
|
||||
assert_empty @user.otp_backup_codes
|
||||
assert_empty @user.webauthn_credentials
|
||||
end
|
||||
|
||||
private
|
||||
def register_webauthn_credential(origin: "http://www.example.com", rp_id: "www.example.com")
|
||||
client = WebAuthn::FakeClient.new(origin)
|
||||
|
||||
post options_settings_webauthn_credentials_path, as: :json
|
||||
options = JSON.parse(response.body)
|
||||
credential = client.create(challenge: options.fetch("challenge"), rp_id: rp_id)
|
||||
post settings_webauthn_credentials_path, params: {
|
||||
webauthn_credential: { nickname: "MacBook Touch ID" },
|
||||
credential: credential
|
||||
}, as: :json
|
||||
assert_response :success
|
||||
|
||||
client
|
||||
end
|
||||
|
||||
def with_webauthn_config(rp_id:, allowed_origins:)
|
||||
config = Rails.application.config.x.webauthn
|
||||
previous_rp_id = config.rp_id
|
||||
previous_allowed_origins = config.allowed_origins
|
||||
config.rp_id = rp_id
|
||||
config.allowed_origins = allowed_origins
|
||||
|
||||
yield
|
||||
ensure
|
||||
config.rp_id = previous_rp_id
|
||||
config.allowed_origins = previous_allowed_origins
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user