Add webauthn
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-11-04 16:20:11 +11:00
parent 19bfc21f11
commit 57abc0b804
14 changed files with 1211 additions and 14 deletions

View File

@@ -1,7 +1,8 @@
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create verify_totp ]
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ]
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: { error: "Too many attempts. Try again later." }, status: :too_many_requests }
def new
# Redirect to signup if this is first run
@@ -118,6 +119,141 @@ class SessionsController < ApplicationController
redirect_to active_sessions_path, notice: "Session revoked successfully."
end
# WebAuthn authentication methods
def webauthn_challenge
email = params[:email]&.strip&.downcase
if email.blank?
render json: { error: "Email is required" }, status: :unprocessable_entity
return
end
user = User.find_by(email_address: email)
if user.nil? || !user.can_authenticate_with_webauthn?
render json: { error: "User not found or WebAuthn not available" }, status: :unprocessable_entity
return
end
# Store user ID in session for verification
session[:pending_webauthn_user_id] = user.id
# Store redirect URL if present
if params[:rd].present?
validated_url = validate_redirect_url(params[:rd])
session[:webauthn_redirect_url] = validated_url if validated_url
end
begin
# Generate authentication options
# The WebAuthn gem will handle base64url encoding automatically
options = WebAuthn::Credential.options_for_get(
allow: user.webauthn_credentials.pluck(:external_id),
user_verification: "preferred"
)
# Store challenge in session
session[:webauthn_challenge] = options.challenge
render json: options
rescue => e
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
render json: { error: "Failed to generate WebAuthn challenge" }, status: :internal_server_error
end
end
def webauthn_verify
# Get pending user from session
user_id = session[:pending_webauthn_user_id]
unless user_id
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
return
end
user = User.find_by(id: user_id)
unless user
session.delete(:pending_webauthn_user_id)
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
return
end
# Get the credential and assertion from params
credential_data = params[:credential]
if credential_data.blank?
render json: { error: "Credential data is required" }, status: :unprocessable_entity
return
end
# Get 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
# Decode the credential response
webauthn_credential = WebAuthn::Credential.from_get(credential_data)
# Find the stored credential
external_id = Base64.urlsafe_encode64(webauthn_credential.id)
stored_credential = user.webauthn_credential_for(external_id)
if stored_credential.nil?
render json: { error: "Credential not found" }, status: :unprocessable_entity
return
end
# Verify the assertion
stored_public_key = Base64.urlsafe_decode64(stored_credential.public_key)
webauthn_credential.verify(
challenge,
public_key: stored_public_key,
sign_count: stored_credential.sign_count
)
# Check for suspicious sign count (possible clone)
if stored_credential.suspicious_sign_count?(webauthn_credential.sign_count)
Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id}"
# You might want to notify admins or temporarily disable the credential
end
# Update credential usage
stored_credential.update_usage!(
sign_count: webauthn_credential.sign_count,
ip_address: request.remote_ip,
user_agent: request.user_agent
)
# Clean up session
session.delete(:pending_webauthn_user_id)
if session[:webauthn_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
end
# Create session
start_new_session_for user
render json: {
success: true,
redirect_to: after_authentication_url,
message: "Signed in successfully with passkey"
}
rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn verification error: #{e.message}"
render json: { error: "Authentication failed: #{e.message}" }, status: :unprocessable_entity
rescue JSON::ParserError => e
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
render json: { error: "Invalid credential format" }, status: :unprocessable_entity
rescue => e
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
end
end
private
def validate_redirect_url(url)

View File

@@ -0,0 +1,198 @@
class WebauthnController < ApplicationController
before_action :set_webauthn_credential, only: [:destroy]
skip_before_action :require_authentication, only: [:check]
# 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
)
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
user = Current.session&.user
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
if @webauthn_credential.user != user
render json: { error: "Unauthorized" }, status: :forbidden
return
end
nickname = @webauthn_credential.nickname
@webauthn_credential.destroy
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)
def check
email = params[:email]&.strip&.downcase
if email.blank?
render json: { has_webauthn: false, error: "Email is required" }
return
end
user = User.find_by(email_address: email)
if user.nil?
render json: { has_webauthn: false, message: "User not found" }
return
end
render json: {
has_webauthn: user.can_authenticate_with_webauthn?,
user_id: user.id,
preferred_method: user.preferred_authentication_method,
requires_webauthn: user.require_webauthn?
}
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
begin
# 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
end
def set_webauthn_credential
@webauthn_credential = WebauthnCredential.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.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '')
end
# Helper method to convert Base64URL to Base64 if needed
def base64url_to_base64(str)
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4
end
end