mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 21:14:56 +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:
@@ -115,6 +115,12 @@ REDIS_URL=redis://localhost:6379/1
|
||||
# This is the domain that your Sure instance will be hosted at. It is used to generate links in emails and other places.
|
||||
APP_DOMAIN=
|
||||
|
||||
# WebAuthn / passkey MFA configuration
|
||||
# RP ID is usually the registrable domain (example.com), not a full URL.
|
||||
# Allowed origins are full HTTPS origins where users access Sure.
|
||||
WEBAUTHN_RP_ID=
|
||||
WEBAUTHN_ALLOWED_ORIGINS=
|
||||
|
||||
# OpenID Connect configuration
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
|
||||
@@ -55,6 +55,11 @@ OIDC_CLIENT_SECRET=
|
||||
OIDC_ISSUER=
|
||||
OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback
|
||||
|
||||
# WebAuthn / passkey MFA development defaults
|
||||
# RP ID must match the domain where credentials are registered.
|
||||
WEBAUTHN_RP_ID=localhost
|
||||
WEBAUTHN_ALLOWED_ORIGINS=http://localhost:3000
|
||||
|
||||
# Langfuse config
|
||||
LANGFUSE_PUBLIC_KEY =
|
||||
LANGFUSE_SECRET_KEY =
|
||||
|
||||
@@ -26,6 +26,10 @@ OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback
|
||||
|
||||
# WebAuthn / passkey MFA test defaults
|
||||
WEBAUTHN_RP_ID=www.example.com
|
||||
WEBAUTHN_ALLOWED_ORIGINS=http://www.example.com
|
||||
|
||||
# ================
|
||||
# Data Providers
|
||||
# ---------------------------------------------------------------------------------
|
||||
|
||||
1
Gemfile
1
Gemfile
@@ -82,6 +82,7 @@ gem "snaptrade", "~> 2.0"
|
||||
gem "httparty"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 3.0"
|
||||
gem "webauthn", "~> 3.4"
|
||||
gem "activerecord-import"
|
||||
gem "rubyzip", "~> 2.3"
|
||||
gem "pdf-reader", "~> 2.12"
|
||||
|
||||
23
Gemfile.lock
23
Gemfile.lock
@@ -86,6 +86,7 @@ GEM
|
||||
after_commit_everywhere (1.6.0)
|
||||
activerecord (>= 4.2)
|
||||
activesupport
|
||||
android_key_attestation (0.3.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
@@ -135,6 +136,7 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
cbor (0.5.10.2)
|
||||
cgi (0.5.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
@@ -142,6 +144,9 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (2.5.5)
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
countries (8.0.3)
|
||||
unaccent (~> 0.3)
|
||||
crack (1.0.0)
|
||||
@@ -482,6 +487,9 @@ GEM
|
||||
tzinfo
|
||||
validate_url
|
||||
webfinger (~> 2.0)
|
||||
openssl (4.0.1)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.2)
|
||||
pagy (9.3.5)
|
||||
@@ -694,6 +702,8 @@ GEM
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
safety_net_attestation (0.5.0)
|
||||
jwt (>= 2.0, < 4.0)
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
@@ -772,6 +782,10 @@ GEM
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
thor (1.4.0)
|
||||
timeout (0.6.1)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
trailblazer-option (0.1.2)
|
||||
tsort (0.2.0)
|
||||
ttfunk (1.8.0)
|
||||
@@ -805,6 +819,14 @@ GEM
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webauthn (3.4.3)
|
||||
android_key_attestation (~> 0.3.0)
|
||||
bindata (~> 2.4)
|
||||
cbor (~> 0.5.9)
|
||||
cose (~> 1.1)
|
||||
openssl (>= 2.2)
|
||||
safety_net_attestation (~> 0.5.0)
|
||||
tpm-key_attestation (~> 0.14.0)
|
||||
webfinger (2.1.3)
|
||||
activesupport
|
||||
faraday (~> 2.0)
|
||||
@@ -929,6 +951,7 @@ DEPENDENCIES
|
||||
vernier
|
||||
view_component
|
||||
web-console
|
||||
webauthn (~> 3.4)
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
|
||||
31
app/controllers/concerns/webauthn_relying_party.rb
Normal file
31
app/controllers/concerns/webauthn_relying_party.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module WebauthnRelyingParty
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
def webauthn_relying_party
|
||||
webauthn_config = Rails.application.config.x.webauthn
|
||||
|
||||
WebAuthn::RelyingParty.new(
|
||||
name: "Sure",
|
||||
id: webauthn_config.rp_id,
|
||||
allowed_origins: webauthn_config.allowed_origins,
|
||||
# Accept consumer passkeys/security keys without attesting device vendor
|
||||
# identity; this keeps MFA registration broad for self-hosted users.
|
||||
verify_attestation_statement: false
|
||||
)
|
||||
end
|
||||
|
||||
def webauthn_credential_payload
|
||||
payload = params.require(:credential)
|
||||
payload = JSON.parse(payload) if payload.is_a?(String)
|
||||
|
||||
payload = payload.to_unsafe_h if payload.respond_to?(:to_unsafe_h)
|
||||
raise ActionController::BadRequest, "credential must be an object" unless payload.is_a?(Hash)
|
||||
|
||||
payload
|
||||
rescue JSON::ParserError, TypeError, ArgumentError
|
||||
raise ActionController::BadRequest, "invalid credential payload"
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,8 @@
|
||||
class MfaController < ApplicationController
|
||||
include WebauthnRelyingParty
|
||||
|
||||
layout :determine_layout
|
||||
skip_authentication only: [ :verify, :verify_code ]
|
||||
skip_authentication only: [ :verify, :verify_code, :webauthn_options, :verify_webauthn ]
|
||||
|
||||
def new
|
||||
redirect_to root_path if Current.user.otp_required?
|
||||
@@ -30,9 +32,7 @@ class MfaController < ApplicationController
|
||||
@user = User.find_by(id: session[:mfa_user_id])
|
||||
|
||||
if @user&.verify_otp?(params[:code])
|
||||
session.delete(:mfa_user_id)
|
||||
@session = create_session_for(@user)
|
||||
flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(@user)
|
||||
complete_mfa_sign_in(@user)
|
||||
redirect_to root_path
|
||||
else
|
||||
flash.now[:alert] = t(".invalid_code")
|
||||
@@ -40,6 +40,60 @@ class MfaController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def webauthn_options
|
||||
@user = User.find_by(id: session[:mfa_user_id])
|
||||
|
||||
unless @user&.webauthn_enabled?
|
||||
return render json: { error: t(".unavailable") }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
options = webauthn_relying_party.options_for_authentication(
|
||||
allow: @user.webauthn_credentials.pluck(:credential_id),
|
||||
user_verification: "preferred"
|
||||
)
|
||||
session[:webauthn_authentication_challenge] = options.challenge
|
||||
|
||||
render json: options
|
||||
end
|
||||
|
||||
def verify_webauthn
|
||||
@user = User.find_by(id: session[:mfa_user_id])
|
||||
challenge = session.delete(:webauthn_authentication_challenge)
|
||||
|
||||
unless @user&.webauthn_enabled? && challenge.present?
|
||||
return render json: { error: t(".invalid_credential") }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
credential = WebAuthn::Credential.from_get(
|
||||
webauthn_credential_payload,
|
||||
relying_party: webauthn_relying_party
|
||||
)
|
||||
stored_credential = @user.webauthn_credentials.find_by(credential_id: credential.id)
|
||||
|
||||
unless stored_credential
|
||||
return render json: { error: t(".invalid_credential") }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
stored_credential.with_lock do
|
||||
credential.verify(
|
||||
challenge,
|
||||
public_key: stored_credential.public_key,
|
||||
sign_count: stored_credential.sign_count,
|
||||
user_presence: true
|
||||
)
|
||||
|
||||
stored_credential.update!(
|
||||
sign_count: credential.sign_count,
|
||||
last_used_at: Time.current
|
||||
)
|
||||
end
|
||||
complete_mfa_sign_in(@user)
|
||||
|
||||
render json: { redirect_url: root_path }
|
||||
rescue WebAuthn::Error, ActionController::BadRequest, ActionController::ParameterMissing
|
||||
render json: { error: t(".invalid_credential") }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def disable
|
||||
Current.user.disable_mfa!
|
||||
redirect_to settings_security_path, notice: t(".success")
|
||||
@@ -48,10 +102,18 @@ class MfaController < ApplicationController
|
||||
private
|
||||
|
||||
def determine_layout
|
||||
if action_name.in?(%w[verify verify_code])
|
||||
if action_name.in?(%w[webauthn_options verify_webauthn])
|
||||
false
|
||||
elsif action_name.in?(%w[verify verify_code])
|
||||
"auth"
|
||||
else
|
||||
"settings"
|
||||
end
|
||||
end
|
||||
|
||||
def complete_mfa_sign_in(user)
|
||||
session.delete(:mfa_user_id)
|
||||
@session = create_session_for(user)
|
||||
flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,5 +7,6 @@ class Settings::SecuritiesController < ApplicationController
|
||||
[ "Security", nil ]
|
||||
]
|
||||
@oidc_identities = Current.user.oidc_identities.order(:provider)
|
||||
@webauthn_credentials = Current.user.webauthn_credentials.order(created_at: :asc)
|
||||
end
|
||||
end
|
||||
|
||||
83
app/controllers/settings/webauthn_credentials_controller.rb
Normal file
83
app/controllers/settings/webauthn_credentials_controller.rb
Normal file
@@ -0,0 +1,83 @@
|
||||
class Settings::WebauthnCredentialsController < ApplicationController
|
||||
include WebauthnRelyingParty
|
||||
|
||||
layout "settings"
|
||||
|
||||
before_action :ensure_mfa_enabled
|
||||
|
||||
def options
|
||||
Current.user.ensure_webauthn_id!
|
||||
|
||||
registration_options = webauthn_relying_party.options_for_registration(
|
||||
user: {
|
||||
id: Current.user.webauthn_id,
|
||||
name: Current.user.email,
|
||||
display_name: Current.user.display_name
|
||||
},
|
||||
exclude: Current.user.webauthn_credentials.pluck(:credential_id),
|
||||
authenticator_selection: { user_verification: "preferred" },
|
||||
attestation: "none"
|
||||
)
|
||||
|
||||
session[:webauthn_registration_challenge] = registration_options.challenge
|
||||
|
||||
render json: registration_options
|
||||
end
|
||||
|
||||
def create
|
||||
challenge = session.delete(:webauthn_registration_challenge)
|
||||
|
||||
unless challenge.present?
|
||||
return render json: { error: t("webauthn_credentials.failure") }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
credential = webauthn_relying_party.verify_registration(
|
||||
webauthn_credential_payload,
|
||||
challenge,
|
||||
user_presence: true
|
||||
)
|
||||
|
||||
Current.user.webauthn_credentials.create!(
|
||||
nickname: webauthn_credential_name,
|
||||
credential_id: credential.id,
|
||||
public_key: credential.public_key,
|
||||
sign_count: credential.sign_count,
|
||||
transports: webauthn_credential_transports
|
||||
)
|
||||
|
||||
render json: { redirect_url: settings_security_path }
|
||||
rescue WebAuthn::Error, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique, ActionController::BadRequest, ActionController::ParameterMissing
|
||||
render json: { error: t("webauthn_credentials.failure") }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
Current.user.webauthn_credentials.find(params[:id]).destroy!
|
||||
redirect_to settings_security_path, notice: t("webauthn_credentials.success")
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_mfa_enabled
|
||||
return if Current.user.otp_required?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to settings_security_path, alert: t("webauthn_credentials.mfa_required") }
|
||||
format.json { render json: { error: t("webauthn_credentials.mfa_required") }, status: :forbidden }
|
||||
end
|
||||
end
|
||||
|
||||
def webauthn_credential_name
|
||||
webauthn_credential_params[:nickname]
|
||||
end
|
||||
|
||||
def webauthn_credential_transports
|
||||
Array(credential_response_params.dig(:response, :transports)).compact_blank
|
||||
end
|
||||
|
||||
def webauthn_credential_params
|
||||
params.fetch(:webauthn_credential, ActionController::Parameters.new).permit(:nickname)
|
||||
end
|
||||
|
||||
def credential_response_params
|
||||
params.fetch(:credential, ActionController::Parameters.new).permit(response: [ transports: [] ])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
import WebauthnController from "controllers/webauthn_controller";
|
||||
import {
|
||||
prepareCredentialRequestOptions,
|
||||
serializePublicKeyCredential,
|
||||
} from "utils/webauthn";
|
||||
|
||||
export default class extends WebauthnController {
|
||||
static targets = ["error"];
|
||||
static values = {
|
||||
optionsUrl: String,
|
||||
verifyUrl: String,
|
||||
unsupportedMessage: String,
|
||||
errorFallback: String,
|
||||
};
|
||||
|
||||
async authenticate(event) {
|
||||
event.preventDefault();
|
||||
this.clearError();
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
this.showError(this.unsupportedMessageValue);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = await this.fetchOptions();
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: prepareCredentialRequestOptions(options),
|
||||
});
|
||||
|
||||
await this.verifyCredential(serializePublicKeyCredential(credential));
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchOptions() {
|
||||
const response = await fetch(this.optionsUrlValue, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await this.errorMessage(response));
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async verifyCredential(credential) {
|
||||
const response = await fetch(this.verifyUrlValue, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ credential }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await this.errorMessage(response));
|
||||
|
||||
const result = await response.json();
|
||||
window.location.href = result.redirect_url;
|
||||
}
|
||||
}
|
||||
39
app/javascript/controllers/webauthn_controller.js
Normal file
39
app/javascript/controllers/webauthn_controller.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
get headers() {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']")
|
||||
?.content,
|
||||
};
|
||||
}
|
||||
|
||||
async errorMessage(response) {
|
||||
try {
|
||||
const result = await response.clone().json();
|
||||
if (result.error) return result.error;
|
||||
} catch (_error) {
|
||||
return this.errorFallbackValue;
|
||||
}
|
||||
|
||||
return this.errorFallbackValue;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (this.hasErrorTarget) {
|
||||
this.errorTarget.textContent = message;
|
||||
this.errorTarget.hidden = false;
|
||||
this.errorTarget.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
}
|
||||
|
||||
clearError() {
|
||||
if (this.hasErrorTarget) {
|
||||
this.errorTarget.textContent = "";
|
||||
this.errorTarget.hidden = true;
|
||||
this.errorTarget.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import WebauthnController from "controllers/webauthn_controller";
|
||||
import {
|
||||
prepareCredentialCreationOptions,
|
||||
serializePublicKeyCredential,
|
||||
} from "utils/webauthn";
|
||||
|
||||
export default class extends WebauthnController {
|
||||
static targets = ["error", "nickname"];
|
||||
static values = {
|
||||
optionsUrl: String,
|
||||
createUrl: String,
|
||||
unsupportedMessage: String,
|
||||
errorFallback: String,
|
||||
};
|
||||
|
||||
async register(event) {
|
||||
event.preventDefault();
|
||||
this.clearError();
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
this.showError(this.unsupportedMessageValue);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = await this.fetchOptions();
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: prepareCredentialCreationOptions(options),
|
||||
});
|
||||
|
||||
await this.createCredential(serializePublicKeyCredential(credential));
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchOptions() {
|
||||
const response = await fetch(this.optionsUrlValue, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await this.errorMessage(response));
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async createCredential(credential) {
|
||||
const response = await fetch(this.createUrlValue, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
credential,
|
||||
webauthn_credential: {
|
||||
nickname: this.hasNicknameTarget ? this.nicknameTarget.value : "",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await this.errorMessage(response));
|
||||
|
||||
const result = await response.json();
|
||||
window.location.href = result.redirect_url;
|
||||
}
|
||||
}
|
||||
85
app/javascript/utils/webauthn.js
Normal file
85
app/javascript/utils/webauthn.js
Normal file
@@ -0,0 +1,85 @@
|
||||
function bufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const binary = String.fromCharCode(...bytes);
|
||||
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function base64urlToBuffer(value) {
|
||||
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(
|
||||
base64.length + ((4 - (base64.length % 4)) % 4),
|
||||
"=",
|
||||
);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
export function prepareCredentialCreationOptions(options) {
|
||||
options.challenge = base64urlToBuffer(options.challenge);
|
||||
options.user.id = base64urlToBuffer(options.user.id);
|
||||
options.excludeCredentials = (options.excludeCredentials || []).map(
|
||||
(credential) => ({
|
||||
...credential,
|
||||
id: base64urlToBuffer(credential.id),
|
||||
}),
|
||||
);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function prepareCredentialRequestOptions(options) {
|
||||
options.challenge = base64urlToBuffer(options.challenge);
|
||||
options.allowCredentials = (options.allowCredentials || []).map(
|
||||
(credential) => ({
|
||||
...credential,
|
||||
id: base64urlToBuffer(credential.id),
|
||||
}),
|
||||
);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function serializePublicKeyCredential(credential) {
|
||||
const serialized = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
};
|
||||
|
||||
if (credential.response.attestationObject) {
|
||||
serialized.response = {
|
||||
attestationObject: bufferToBase64url(
|
||||
credential.response.attestationObject,
|
||||
),
|
||||
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
||||
transports: credential.response.getTransports
|
||||
? credential.response.getTransports()
|
||||
: [],
|
||||
};
|
||||
} else {
|
||||
serialized.response = {
|
||||
authenticatorData: bufferToBase64url(
|
||||
credential.response.authenticatorData,
|
||||
),
|
||||
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
||||
signature: bufferToBase64url(credential.response.signature),
|
||||
userHandle: credential.response.userHandle
|
||||
? bufferToBase64url(credential.response.userHandle)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ class User < ApplicationRecord
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :chats, dependent: :destroy
|
||||
has_many :api_keys, dependent: :destroy
|
||||
has_many :webauthn_credentials, dependent: :destroy
|
||||
has_many :mobile_devices, dependent: :destroy
|
||||
has_many :invitations, foreign_key: :inviter_id, dependent: :destroy
|
||||
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
|
||||
@@ -227,11 +228,14 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
def disable_mfa!
|
||||
update!(
|
||||
otp_secret: nil,
|
||||
otp_required: false,
|
||||
otp_backup_codes: []
|
||||
)
|
||||
transaction do
|
||||
update!(
|
||||
otp_secret: nil,
|
||||
otp_required: false,
|
||||
otp_backup_codes: []
|
||||
)
|
||||
webauthn_credentials.destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
def verify_otp?(code)
|
||||
@@ -245,6 +249,20 @@ class User < ApplicationRecord
|
||||
totp.provisioning_uri(email)
|
||||
end
|
||||
|
||||
def ensure_webauthn_id!
|
||||
return webauthn_id if webauthn_id.present?
|
||||
|
||||
with_lock do
|
||||
update!(webauthn_id: WebAuthn.generate_user_id) unless webauthn_id.present?
|
||||
end
|
||||
|
||||
webauthn_id
|
||||
end
|
||||
|
||||
def webauthn_enabled?
|
||||
otp_required? && webauthn_credentials.exists?
|
||||
end
|
||||
|
||||
def onboarded?
|
||||
onboarded_at.present?
|
||||
end
|
||||
|
||||
15
app/models/webauthn_credential.rb
Normal file
15
app/models/webauthn_credential.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class WebauthnCredential < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
before_validation :set_default_nickname
|
||||
|
||||
validates :nickname, presence: true, length: { maximum: 80 }
|
||||
validates :credential_id, presence: true, uniqueness: true
|
||||
validates :public_key, presence: true
|
||||
validates :sign_count, numericality: { greater_than_or_equal_to: 0, only_integer: true }
|
||||
|
||||
private
|
||||
def set_default_nickname
|
||||
self.nickname = nickname.to_s.strip.presence || I18n.t("webauthn_credentials.default_name")
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,29 @@
|
||||
header_description t(".description")
|
||||
%>
|
||||
|
||||
<% if @user&.webauthn_enabled? %>
|
||||
<div class="space-y-3 mt-4 md:mt-0" data-controller="webauthn-authentication"
|
||||
data-webauthn-authentication-options-url-value="<%= webauthn_options_mfa_path %>"
|
||||
data-webauthn-authentication-verify-url-value="<%= verify_webauthn_mfa_path %>"
|
||||
data-webauthn-authentication-unsupported-message-value="<%= t(".webauthn_unsupported") %>"
|
||||
data-webauthn-authentication-error-fallback-value="<%= t("mfa.verify_webauthn.invalid_credential") %>">
|
||||
<%= render DS::Button.new(
|
||||
text: t(".webauthn_button"),
|
||||
variant: "secondary",
|
||||
icon: "fingerprint",
|
||||
full_width: true,
|
||||
type: "button",
|
||||
data: { action: "webauthn-authentication#authenticate" }
|
||||
) %>
|
||||
<p class="text-sm text-destructive" role="alert" aria-live="assertive" aria-atomic="true" aria-hidden="true" hidden data-webauthn-authentication-target="error"></p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="border-t border-primary flex-1"></div>
|
||||
<span class="text-xs text-secondary"><%= t(".or") %></span>
|
||||
<div class="border-t border-primary flex-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= styled_form_with url: verify_mfa_path, method: :post, class: "space-y-4 mt-4 md:mt-0", data: { turbo: false } do |form| %>
|
||||
<%= form.text_field :code,
|
||||
required: true,
|
||||
|
||||
@@ -45,6 +45,82 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if Current.user.otp_required? %>
|
||||
<%= settings_section title: t(".webauthn_title"), subtitle: t(".webauthn_description") do %>
|
||||
<div class="space-y-4">
|
||||
<% if @webauthn_credentials.any? %>
|
||||
<div class="space-y-2">
|
||||
<% @webauthn_credentials.each do |credential| %>
|
||||
<div class="flex items-center justify-between bg-container p-4 shadow-border-xs rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 shrink-0 bg-surface rounded-full flex items-center justify-center">
|
||||
<%= icon "fingerprint", class: "w-5 h-5 text-secondary" %>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= credential.nickname %></p>
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t(".webauthn_added", date: l(credential.created_at.to_date)) %>
|
||||
<% if credential.last_used_at.present? %>
|
||||
<span><%= t(".webauthn_last_used", time_ago: time_ago_in_words(credential.last_used_at)) %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render DS::Button.new(
|
||||
text: t(".webauthn_remove"),
|
||||
variant: "outline_destructive",
|
||||
size: "sm",
|
||||
href: settings_webauthn_credential_path(credential),
|
||||
method: :delete,
|
||||
confirm: CustomConfirm.new(
|
||||
title: t(".webauthn_remove_confirm"),
|
||||
body: t(".webauthn_remove_confirm_body"),
|
||||
btn_text: t(".webauthn_remove"),
|
||||
destructive: true
|
||||
)
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-surface-inset rounded-lg p-4">
|
||||
<p class="text-sm text-secondary"><%= t(".webauthn_empty") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= styled_form_with scope: :webauthn_credential,
|
||||
url: settings_webauthn_credentials_path,
|
||||
method: :post,
|
||||
class: "space-y-3",
|
||||
data: {
|
||||
controller: "webauthn-registration",
|
||||
action: "submit->webauthn-registration#register",
|
||||
webauthn_registration_options_url_value: options_settings_webauthn_credentials_path,
|
||||
webauthn_registration_create_url_value: settings_webauthn_credentials_path,
|
||||
webauthn_registration_unsupported_message_value: t(".webauthn_unsupported"),
|
||||
webauthn_registration_error_fallback_value: t("webauthn_credentials.failure")
|
||||
} do |form| %>
|
||||
<%= form.text_field :nickname,
|
||||
placeholder: t(".webauthn_name_placeholder"),
|
||||
label: t(".webauthn_name_label"),
|
||||
data: { webauthn_registration_target: "nickname" } %>
|
||||
|
||||
<p class="text-sm text-destructive" role="alert" aria-live="assertive" aria-atomic="true" aria-hidden="true" hidden data-webauthn-registration-target="error"></p>
|
||||
|
||||
<div>
|
||||
<%= render DS::Button.new(
|
||||
text: t(".webauthn_add"),
|
||||
variant: "secondary",
|
||||
icon: "fingerprint",
|
||||
type: "submit"
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @oidc_identities.any? || AuthConfig.sso_providers.any? %>
|
||||
<%= settings_section title: t(".sso_title"), subtitle: t(".sso_subtitle") do %>
|
||||
<% if @oidc_identities.any? %>
|
||||
|
||||
@@ -10,6 +10,14 @@ class Rack::Attack
|
||||
request.ip if request.path == "/oauth/token"
|
||||
end
|
||||
|
||||
# Throttle unauthenticated WebAuthn MFA ceremonies similarly to sign-in
|
||||
# endpoints; registration remains behind normal application authentication.
|
||||
throttle("mfa/webauthn", limit: 10, period: 1.minute) do |request|
|
||||
if request.post? && request.path.in?(%w[/mfa/webauthn_options /mfa/verify_webauthn])
|
||||
request.ip
|
||||
end
|
||||
end
|
||||
|
||||
# Throttle admin endpoints to prevent brute-force attacks
|
||||
# More restrictive than general API limits since admin access is sensitive
|
||||
throttle("admin/ip", limit: 10, period: 1.minute) do |request|
|
||||
|
||||
34
config/initializers/webauthn.rb
Normal file
34
config/initializers/webauthn.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Rails.application.configure do
|
||||
config.x.webauthn = ActiveSupport::OrderedOptions.new
|
||||
|
||||
credentials_config = Rails.application.credentials.webauthn || {}
|
||||
credential_rp_id = credentials_config[:rp_id] || credentials_config["rp_id"]
|
||||
credential_origins = credentials_config[:allowed_origins] || credentials_config["allowed_origins"]
|
||||
|
||||
configured_rp_id = ENV["WEBAUTHN_RP_ID"].presence || credential_rp_id.presence || ENV["APP_DOMAIN"].presence
|
||||
default_rp_id = Rails.env.test? ? "www.example.com" : "localhost"
|
||||
|
||||
rp_id = configured_rp_id.presence || default_rp_id
|
||||
rp_id = rp_id.to_s.strip.sub(%r{\Ahttps?://}, "").split("/").first.to_s.split(":").first
|
||||
|
||||
configured_origins = ENV["WEBAUTHN_ALLOWED_ORIGINS"].presence || credential_origins
|
||||
allowed_origins = Array(configured_origins)
|
||||
.flat_map { |origin| origin.to_s.split(",") }
|
||||
.map { |origin| origin.strip.chomp("/") }
|
||||
.reject(&:blank?)
|
||||
|
||||
if allowed_origins.blank?
|
||||
allowed_origins = if Rails.env.test?
|
||||
[ "http://www.example.com" ]
|
||||
elsif rp_id == "localhost"
|
||||
[ "http://localhost:3000" ]
|
||||
else
|
||||
[ "https://#{rp_id}" ]
|
||||
end
|
||||
end
|
||||
|
||||
config.x.webauthn.rp_id = rp_id
|
||||
config.x.webauthn.allowed_origins = allowed_origins
|
||||
end
|
||||
@@ -31,8 +31,15 @@ en:
|
||||
verify_title: 2. Enter Verification Code
|
||||
verify:
|
||||
description: Enter the code from your authenticator app to continue
|
||||
or: or
|
||||
page_title: Verify Two-Factor Authentication
|
||||
title: Two-Factor Authentication
|
||||
verify_button: Verify
|
||||
webauthn_button: Use passkey or security key
|
||||
webauthn_unsupported: This browser does not support passkeys or security keys.
|
||||
verify_code:
|
||||
invalid_code: Invalid authentication code. Please try again.
|
||||
verify_webauthn:
|
||||
invalid_credential: Could not verify that passkey or security key. Please try again.
|
||||
webauthn_options:
|
||||
unavailable: No passkeys or security keys are available for this account.
|
||||
|
||||
@@ -10,3 +10,25 @@ en:
|
||||
mfa_description: Add an extra layer of security to your account by requiring
|
||||
a code from your authenticator app when signing in
|
||||
mfa_title: Two-Factor Authentication
|
||||
webauthn_add: Add passkey or security key
|
||||
webauthn_added: Added %{date}
|
||||
webauthn_description: Use a passkey, Touch ID, Windows Hello, or a hardware
|
||||
security key as a second factor when signing in.
|
||||
webauthn_empty: No passkeys or security keys are registered yet.
|
||||
webauthn_last_used: Last used %{time_ago} ago
|
||||
webauthn_name_label: Key name
|
||||
webauthn_name_placeholder: MacBook Touch ID, YubiKey, etc.
|
||||
webauthn_remove: Remove
|
||||
webauthn_remove_confirm: Are you sure you want to remove this passkey or
|
||||
security key?
|
||||
webauthn_remove_confirm_body: You will need to register this passkey or
|
||||
security key again before it can be used for sign-in verification.
|
||||
webauthn_title: Passkeys and security keys
|
||||
webauthn_unsupported: This browser does not support passkeys or security
|
||||
keys.
|
||||
webauthn_credentials:
|
||||
default_name: Security key
|
||||
failure: Could not save that passkey or security key. Please try again.
|
||||
mfa_required: Enable two-factor authentication before adding a passkey or security
|
||||
key.
|
||||
success: Passkey or security key removed.
|
||||
|
||||
@@ -118,6 +118,8 @@ Rails.application.routes.draw do
|
||||
resource :mfa, controller: "mfa", only: [ :new, :create ] do
|
||||
get :verify
|
||||
post :verify, to: "mfa#verify_code"
|
||||
post :webauthn_options
|
||||
post :verify_webauthn
|
||||
delete :disable
|
||||
end
|
||||
|
||||
@@ -195,6 +197,9 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resource :payment, only: :show
|
||||
resource :security, only: :show
|
||||
resources :webauthn_credentials, only: %i[create destroy] do
|
||||
post :options, on: :collection
|
||||
end
|
||||
resources :sso_identities, only: :destroy
|
||||
resource :api_key, only: [ :show, :new, :create, :destroy ]
|
||||
resource :ai_prompts, only: :show
|
||||
|
||||
21
db/migrate/20260501142000_create_webauthn_credentials.rb
Normal file
21
db/migrate/20260501142000_create_webauthn_credentials.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
class CreateWebauthnCredentials < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :users, :webauthn_id, :string
|
||||
add_index :users, :webauthn_id, unique: true, where: "webauthn_id IS NOT NULL"
|
||||
|
||||
create_table :webauthn_credentials, id: :uuid do |t|
|
||||
t.references :user, null: false, foreign_key: true, type: :uuid
|
||||
t.string :nickname, null: false
|
||||
t.string :credential_id, null: false
|
||||
t.text :public_key, null: false
|
||||
t.bigint :sign_count, null: false, default: 0
|
||||
t.string :transports, array: true, null: false, default: []
|
||||
t.datetime :last_used_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :webauthn_credentials, :credential_id, unique: true
|
||||
add_check_constraint :webauthn_credentials, "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
|
||||
end
|
||||
end
|
||||
18
db/schema.rb
generated
18
db/schema.rb
generated
@@ -1605,6 +1605,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_02_120000) do
|
||||
t.string "locale"
|
||||
t.string "ui_layout"
|
||||
t.uuid "default_account_id"
|
||||
t.string "webauthn_id"
|
||||
t.index ["default_account_id"], name: "index_users_on_default_account_id"
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["family_id"], name: "index_users_on_family_id"
|
||||
@@ -1612,6 +1613,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_02_120000) do
|
||||
t.index ["locale"], name: "index_users_on_locale"
|
||||
t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)"
|
||||
t.index ["preferences"], name: "index_users_on_preferences", using: :gin
|
||||
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true, where: "(webauthn_id IS NOT NULL)"
|
||||
end
|
||||
|
||||
create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -1633,6 +1635,21 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_02_120000) do
|
||||
t.string "subtype"
|
||||
end
|
||||
|
||||
create_table "webauthn_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "user_id", null: false
|
||||
t.string "nickname", null: false
|
||||
t.string "credential_id", null: false
|
||||
t.text "public_key", null: false
|
||||
t.bigint "sign_count", default: 0, null: false
|
||||
t.string "transports", default: [], null: false, array: true
|
||||
t.datetime "last_used_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
|
||||
t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true
|
||||
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
|
||||
end
|
||||
|
||||
add_foreign_key "account_providers", "accounts", on_delete: :cascade
|
||||
add_foreign_key "account_shares", "accounts"
|
||||
add_foreign_key "account_shares", "users"
|
||||
@@ -1728,4 +1745,5 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_02_120000) do
|
||||
add_foreign_key "users", "accounts", column: "default_account_id", on_delete: :nullify
|
||||
add_foreign_key "users", "chats", column: "last_viewed_chat_id"
|
||||
add_foreign_key "users", "families"
|
||||
add_foreign_key "webauthn_credentials", "users"
|
||||
end
|
||||
|
||||
@@ -111,6 +111,17 @@ and change it to `true`
|
||||
RAILS_ASSUME_SSL: "true"
|
||||
```
|
||||
|
||||
#### WebAuthn MFA (passkeys and security keys)
|
||||
|
||||
If you enable passkeys, Touch ID, Windows Hello, or hardware security keys as MFA credentials, pin the WebAuthn relying party settings in your `.env` file:
|
||||
|
||||
```txt
|
||||
WEBAUTHN_RP_ID="example.com"
|
||||
WEBAUTHN_ALLOWED_ORIGINS="https://sure.example.com"
|
||||
```
|
||||
|
||||
`WEBAUTHN_RP_ID` should usually be your registrable domain, not a full URL. See [WebAuthn MFA Configuration](webauthn.md) before changing hostnames or reverse proxy settings for an instance with registered passkeys.
|
||||
|
||||
#### Binding to IPv6 (optional)
|
||||
|
||||
By default Sure listens on `0.0.0.0:3000` (IPv4 wildcard) inside the container and Docker publishes the port on the host's IPv4 interface only. If you want the app reachable over IPv6 as well, two things need to change:
|
||||
|
||||
27
docs/hosting/webauthn.md
Normal file
27
docs/hosting/webauthn.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# WebAuthn MFA Configuration
|
||||
|
||||
Sure supports passkeys, Touch ID, Windows Hello, and hardware security keys as MFA credentials. WebAuthn credentials are bound to the relying party ID used when they are registered, so production deployments should pin these values explicitly instead of deriving them from incoming request headers.
|
||||
|
||||
Set these environment variables for self-hosted deployments:
|
||||
|
||||
```bash
|
||||
WEBAUTHN_RP_ID=example.com
|
||||
WEBAUTHN_ALLOWED_ORIGINS=https://sure.example.com
|
||||
```
|
||||
|
||||
`WEBAUTHN_RP_ID` is usually the registrable domain, such as `example.com`, not a full URL and not a hostname with a port. This lets credentials work across subdomains when the browser permits it.
|
||||
|
||||
`WEBAUTHN_ALLOWED_ORIGINS` is a comma-separated list of full origins where users access Sure, including scheme and host. Examples:
|
||||
|
||||
```bash
|
||||
WEBAUTHN_ALLOWED_ORIGINS=https://sure.example.com,https://app.example.com
|
||||
```
|
||||
|
||||
For local development, use:
|
||||
|
||||
```bash
|
||||
WEBAUTHN_RP_ID=localhost
|
||||
WEBAUTHN_ALLOWED_ORIGINS=http://localhost:3000
|
||||
```
|
||||
|
||||
Changing `WEBAUTHN_RP_ID` after users register credentials can make existing passkeys and security keys unavailable. Keep the value stable across reverse proxy, domain, and hostname changes.
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
require "test_helper"
|
||||
require "webauthn/fake_client"
|
||||
|
||||
class Settings::WebauthnCredentialsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@user.webauthn_credentials.destroy_all
|
||||
sign_in @user
|
||||
@user.setup_mfa!
|
||||
@user.enable_mfa!
|
||||
@client = WebAuthn::FakeClient.new("http://www.example.com")
|
||||
end
|
||||
|
||||
test "options require enabled MFA" do
|
||||
@user.disable_mfa!
|
||||
|
||||
post options_settings_webauthn_credentials_path, as: :json
|
||||
|
||||
assert_response :forbidden
|
||||
assert_equal I18n.t("webauthn_credentials.mfa_required"), JSON.parse(response.body).fetch("error")
|
||||
end
|
||||
|
||||
test "creates a credential from a verified registration challenge" do
|
||||
options = registration_options
|
||||
credential = @client.create(challenge: options.fetch("challenge"), rp_id: "www.example.com")
|
||||
|
||||
assert_difference -> { @user.webauthn_credentials.count }, 1 do
|
||||
post settings_webauthn_credentials_path, params: {
|
||||
webauthn_credential: { nickname: "MacBook Touch ID" },
|
||||
credential: credential
|
||||
}, as: :json
|
||||
end
|
||||
|
||||
assert_response :success
|
||||
assert_equal settings_security_path, JSON.parse(response.body).fetch("redirect_url")
|
||||
|
||||
stored_credential = @user.webauthn_credentials.reload.last
|
||||
assert_equal "MacBook Touch ID", stored_credential.nickname
|
||||
assert_equal credential.fetch("id"), stored_credential.credential_id
|
||||
assert_includes stored_credential.transports, "internal"
|
||||
assert @user.reload.webauthn_id.present?
|
||||
end
|
||||
|
||||
test "uses configured relying party id and allowed origin" do
|
||||
with_webauthn_config(rp_id: "example.test", allowed_origins: [ "https://app.example.test" ]) do
|
||||
client = WebAuthn::FakeClient.new("https://app.example.test")
|
||||
options = registration_options
|
||||
|
||||
assert_equal "example.test", options.dig("rp", "id")
|
||||
|
||||
credential = client.create(challenge: options.fetch("challenge"), rp_id: "example.test")
|
||||
|
||||
assert_difference -> { @user.webauthn_credentials.count }, 1 do
|
||||
post settings_webauthn_credentials_path, params: {
|
||||
webauthn_credential: { nickname: "Configured origin key" },
|
||||
credential: credential
|
||||
}, as: :json
|
||||
end
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects a credential when registration challenge has already been used" do
|
||||
options = registration_options
|
||||
credential = @client.create(challenge: options.fetch("challenge"), rp_id: "www.example.com")
|
||||
|
||||
post settings_webauthn_credentials_path, params: {
|
||||
webauthn_credential: { nickname: "MacBook Touch ID" },
|
||||
credential: credential
|
||||
}, as: :json
|
||||
assert_response :success
|
||||
|
||||
assert_no_difference -> { @user.webauthn_credentials.count } do
|
||||
post settings_webauthn_credentials_path, params: {
|
||||
webauthn_credential: { nickname: "Replay" },
|
||||
credential: credential
|
||||
}, as: :json
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "rejects malformed credential payloads" do
|
||||
registration_options
|
||||
|
||||
assert_no_difference -> { @user.webauthn_credentials.count } do
|
||||
post settings_webauthn_credentials_path, params: {
|
||||
webauthn_credential: { nickname: "Malformed" },
|
||||
credential: []
|
||||
}, as: :json
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal I18n.t("webauthn_credentials.failure"), JSON.parse(response.body).fetch("error")
|
||||
end
|
||||
|
||||
test "rejects database-level duplicate credential races" do
|
||||
registration_options
|
||||
@user.webauthn_credentials.create!(
|
||||
nickname: "Existing security key",
|
||||
credential_id: "duplicate-credential-id",
|
||||
public_key: "public-key"
|
||||
)
|
||||
|
||||
verified_credential = Struct.new(:id, :public_key, :sign_count).new("duplicate-credential-id", "new-public-key", 0)
|
||||
relying_party = mock("webauthn_relying_party")
|
||||
relying_party.expects(:verify_registration).returns(verified_credential)
|
||||
Settings::WebauthnCredentialsController.any_instance.stubs(:webauthn_relying_party).returns(relying_party)
|
||||
|
||||
assert_no_difference -> { @user.webauthn_credentials.count } do
|
||||
post settings_webauthn_credentials_path, params: {
|
||||
webauthn_credential: { nickname: "Duplicate security key" },
|
||||
credential: { id: "duplicate-credential-id", response: {} }
|
||||
}, as: :json
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal I18n.t("webauthn_credentials.failure"), JSON.parse(response.body).fetch("error")
|
||||
end
|
||||
|
||||
test "uses localized default credential nickname" do
|
||||
options = registration_options
|
||||
credential = @client.create(challenge: options.fetch("challenge"), rp_id: "www.example.com")
|
||||
|
||||
post settings_webauthn_credentials_path, params: {
|
||||
webauthn_credential: { nickname: "" },
|
||||
credential: credential
|
||||
}, as: :json
|
||||
|
||||
assert_response :success
|
||||
assert_equal I18n.t("webauthn_credentials.default_name"), @user.webauthn_credentials.reload.last.nickname
|
||||
end
|
||||
|
||||
test "destroys a credential owned by the current user" do
|
||||
credential = @user.webauthn_credentials.create!(
|
||||
nickname: "YubiKey",
|
||||
credential_id: "credential-to-delete",
|
||||
public_key: "public-key"
|
||||
)
|
||||
|
||||
assert_difference -> { @user.webauthn_credentials.count }, -1 do
|
||||
delete settings_webauthn_credential_path(credential)
|
||||
end
|
||||
|
||||
assert_redirected_to settings_security_path
|
||||
end
|
||||
|
||||
private
|
||||
def registration_options
|
||||
post options_settings_webauthn_credentials_path, as: :json
|
||||
assert_response :success
|
||||
JSON.parse(response.body)
|
||||
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
|
||||
@@ -102,11 +102,45 @@ class UserTest < ActiveSupport::TestCase
|
||||
user = users(:family_member)
|
||||
user.setup_mfa!
|
||||
user.enable_mfa!
|
||||
user.webauthn_credentials.create!(
|
||||
nickname: "YubiKey",
|
||||
credential_id: "credential-id",
|
||||
public_key: "public-key"
|
||||
)
|
||||
|
||||
user.disable_mfa!
|
||||
|
||||
assert_nil user.otp_secret
|
||||
assert_not user.otp_required?
|
||||
assert_empty user.otp_backup_codes
|
||||
assert_empty user.webauthn_credentials
|
||||
end
|
||||
|
||||
test "ensure_webauthn_id! generates a stable credential user handle" do
|
||||
user = users(:family_member)
|
||||
assert_nil user.webauthn_id
|
||||
|
||||
webauthn_id = user.ensure_webauthn_id!
|
||||
|
||||
assert webauthn_id.present?
|
||||
assert_equal webauthn_id, user.reload.ensure_webauthn_id!
|
||||
end
|
||||
|
||||
test "webauthn_enabled? requires MFA and at least one credential" do
|
||||
user = users(:family_member)
|
||||
assert_not user.webauthn_enabled?
|
||||
|
||||
user.setup_mfa!
|
||||
user.enable_mfa!
|
||||
assert_not user.webauthn_enabled?
|
||||
|
||||
user.webauthn_credentials.create!(
|
||||
nickname: "Touch ID",
|
||||
credential_id: "touch-id-credential",
|
||||
public_key: "public-key"
|
||||
)
|
||||
|
||||
assert user.webauthn_enabled?
|
||||
end
|
||||
|
||||
test "verify_otp? validates TOTP codes" do
|
||||
|
||||
Reference in New Issue
Block a user