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>
199 lines
6.8 KiB
Ruby
199 lines
6.8 KiB
Ruby
class WebauthnController < ApplicationController
|
|
before_action :set_webauthn_credential, only: [:destroy]
|
|
skip_before_action :require_authentication, only: [:check]
|
|
|
|
# Rate limit check endpoint to prevent enumeration attacks
|
|
rate_limit to: 10, within: 1.minute, only: [:check], with: -> {
|
|
render json: {error: "Too many requests. Try again later."}, status: :too_many_requests
|
|
}
|
|
|
|
# GET /webauthn/new
|
|
def new
|
|
@webauthn_credential = WebauthnCredential.new
|
|
end
|
|
|
|
# POST /webauthn/challenge
|
|
# Generate registration challenge for creating a new passkey
|
|
def challenge
|
|
user = Current.session&.user
|
|
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
|
|
|
registration_options = WebAuthn::Credential.options_for_create(
|
|
user: {
|
|
id: user.webauthn_user_handle,
|
|
name: user.email_address,
|
|
display_name: user.name || user.email_address
|
|
},
|
|
exclude: user.webauthn_credentials.pluck(:external_id),
|
|
authenticator_selection: {
|
|
userVerification: "preferred",
|
|
residentKey: "preferred",
|
|
authenticatorAttachment: "platform" # Prefer platform authenticators first
|
|
}
|
|
)
|
|
|
|
# Store challenge in session for verification
|
|
session[:webauthn_challenge] = registration_options.challenge
|
|
|
|
render json: registration_options
|
|
end
|
|
|
|
# POST /webauthn/create
|
|
# Verify and store the new credential
|
|
def create
|
|
credential_data, nickname = extract_credential_params
|
|
|
|
if credential_data.blank? || nickname.blank?
|
|
render json: {error: "Credential and nickname are required"}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
# Retrieve the challenge from session
|
|
challenge = session.delete(:webauthn_challenge)
|
|
|
|
if challenge.blank?
|
|
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
begin
|
|
# Pass the credential hash directly to WebAuthn gem
|
|
webauthn_credential = WebAuthn::Credential.from_create(credential_data.to_h)
|
|
|
|
# Verify the credential against the challenge
|
|
webauthn_credential.verify(challenge)
|
|
|
|
# Extract credential metadata from the hash
|
|
response = credential_data.to_h
|
|
client_extension_results = response["clientExtensionResults"] || {}
|
|
|
|
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
|
|
"cross-platform"
|
|
else
|
|
"platform"
|
|
end
|
|
|
|
# Determine if this is a backup/synced credential
|
|
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
|
|
backup_state = client_extension_results["credProps"]&.dig("backup") || false
|
|
|
|
# Store the credential
|
|
user = Current.session&.user
|
|
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
|
|
|
@webauthn_credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
|
public_key: Base64.urlsafe_encode64(webauthn_credential.public_key),
|
|
sign_count: webauthn_credential.sign_count,
|
|
nickname: nickname,
|
|
authenticator_type: authenticator_type,
|
|
backup_eligible: backup_eligible,
|
|
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",
|
|
credential_id: @webauthn_credential.id
|
|
}
|
|
rescue WebAuthn::Error => e
|
|
Rails.logger.error "WebAuthn registration error: #{e.message}"
|
|
render json: {error: "Failed to register passkey: #{e.message}"}, status: :unprocessable_entity
|
|
rescue => e
|
|
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
|
|
render json: {error: "An unexpected error occurred"}, status: :internal_server_error
|
|
end
|
|
end
|
|
|
|
# DELETE /webauthn/:id
|
|
# 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,
|
|
notice: "Passkey '#{nickname}' has been removed"
|
|
}
|
|
format.json {
|
|
render json: {
|
|
success: true,
|
|
message: "Passkey '#{nickname}' has been removed"
|
|
}
|
|
}
|
|
end
|
|
end
|
|
|
|
# GET /webauthn/check
|
|
# Check if user has WebAuthn credentials (for login page detection)
|
|
# Security: Returns identical responses for non-existent users to prevent enumeration
|
|
def check
|
|
email = params[:email]&.strip&.downcase
|
|
|
|
if email.blank?
|
|
render json: {has_webauthn: false, requires_webauthn: false}
|
|
return
|
|
end
|
|
|
|
user = User.find_by(email_address: email)
|
|
|
|
# Security: Return identical response for non-existent users
|
|
# Combined with rate limiting (10/min), this prevents account enumeration
|
|
if user.nil?
|
|
render json: {has_webauthn: false, requires_webauthn: false}
|
|
return
|
|
end
|
|
|
|
# Only return minimal necessary info - no user_id or preferred_method
|
|
render json: {
|
|
has_webauthn: user.can_authenticate_with_webauthn?,
|
|
requires_webauthn: user.require_webauthn?,
|
|
has_totp: user.totp_enabled?
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
def extract_credential_params
|
|
# Use require.permit which is working and reliable
|
|
# The JavaScript sends params both directly and wrapped in webauthn key
|
|
|
|
# Try direct parameters first
|
|
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
|
nickname = params.require(:nickname)
|
|
[credential_params, nickname]
|
|
rescue ActionController::ParameterMissing
|
|
Rails.logger.error("Using the fallback parameters")
|
|
# Fallback to webauthn-wrapped parameters
|
|
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
|
[webauthn_params[:credential], webauthn_params[:nickname]]
|
|
end
|
|
|
|
def set_webauthn_credential
|
|
user = Current.session&.user
|
|
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
|
@webauthn_credential = user.webauthn_credentials.find(params[:id])
|
|
rescue ActiveRecord::RecordNotFound
|
|
respond_to do |format|
|
|
format.html { redirect_to profile_path, alert: "Passkey not found" }
|
|
format.json { render json: {error: "Passkey not found"}, status: :not_found }
|
|
end
|
|
end
|
|
|
|
# Helper method to convert Base64 to Base64URL if needed
|
|
def base64_to_base64url(str)
|
|
str.tr("+", "-").tr("/", "_").gsub(/=+$/, "")
|
|
end
|
|
|
|
# Helper method to convert Base64URL to Base64 if needed
|
|
def base64url_to_base64(str)
|
|
str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4
|
|
end
|
|
end
|