From 57abc0b8045de63468dde7a19b4749d1d111d9d5 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Tue, 4 Nov 2025 16:20:11 +1100 Subject: [PATCH] Add webauthn --- Gemfile | 3 + Gemfile.lock | 24 ++ app/controllers/sessions_controller.rb | 138 +++++++- app/controllers/webauthn_controller.rb | 198 +++++++++++ .../controllers/webauthn_controller.js | 317 ++++++++++++++++++ app/models/user.rb | 49 +++ app/models/webauthn_credential.rb | 96 ++++++ app/views/profiles/show.html.erb | 122 +++++++ app/views/sessions/new.html.erb | 136 +++++++- config/initializers/webauthn.rb | 54 +++ config/routes.rb | 11 + ...51104042155_create_webauthn_credentials.rb | 32 ++ .../20251104042206_add_webauthn_to_users.rb | 16 + db/schema.rb | 29 +- 14 files changed, 1211 insertions(+), 14 deletions(-) create mode 100644 app/controllers/webauthn_controller.rb create mode 100644 app/javascript/controllers/webauthn_controller.js create mode 100644 app/models/webauthn_credential.rb create mode 100644 config/initializers/webauthn.rb create mode 100644 db/migrate/20251104042155_create_webauthn_credentials.rb create mode 100644 db/migrate/20251104042206_add_webauthn_to_users.rb diff --git a/Gemfile b/Gemfile index 0360f9b..1b4f56a 100644 --- a/Gemfile +++ b/Gemfile @@ -31,6 +31,9 @@ gem "rqrcode", "~> 3.1" # JWT for OIDC ID tokens gem "jwt", "~> 3.1" +# WebAuthn for passkey support +gem "webauthn", "~> 3.0" + # Public Suffix List for domain parsing gem "public_suffix", "~> 6.0" diff --git a/Gemfile.lock b/Gemfile.lock index 3bcd83a..1473cc0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -77,11 +77,13 @@ GEM uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + android_key_attestation (0.3.0) ast (2.4.3) base64 (0.3.0) bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) bigdecimal (3.3.1) + bindata (2.5.1) bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) @@ -100,11 +102,15 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cbor (0.5.10.1) childprocess (5.1.0) logger (~> 1.5) chunky_png (1.4.0) concurrent-ruby (1.3.5) connection_pool (2.5.4) + cose (1.3.1) + cbor (~> 0.5.9) + openssl-signature_algorithm (~> 1.0) crass (1.0.6) date (3.4.1) debug (1.11.0) @@ -209,6 +215,9 @@ GEM racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) + openssl (3.3.2) + openssl-signature_algorithm (1.3.0) + openssl (> 2.0) ostruct (0.6.3) parallel (1.27.0) parser (3.3.9.0) @@ -315,6 +324,8 @@ GEM ffi (~> 1.12) logger rubyzip (3.2.1) + safety_net_attestation (0.5.0) + jwt (>= 2.0, < 4.0) securerandom (0.4.1) selenium-webdriver (4.38.0) base64 (~> 0.2) @@ -363,6 +374,10 @@ GEM thruster (0.1.16-arm64-darwin) thruster (0.1.16-x86_64-linux) timeout (0.4.3) + tpm-key_attestation (0.14.1) + bindata (~> 2.4) + openssl (> 2.0) + openssl-signature_algorithm (~> 1.0) tsort (0.2.0) turbo-rails (2.0.17) actionpack (>= 7.1.0) @@ -379,6 +394,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) websocket (1.2.11) websocket-driver (0.8.0) base64 @@ -429,6 +452,7 @@ DEPENDENCIES turbo-rails tzinfo-data web-console + webauthn (~> 3.0) BUNDLED WITH 2.7.2 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 90b9041..cf116b9 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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) diff --git a/app/controllers/webauthn_controller.rb b/app/controllers/webauthn_controller.rb new file mode 100644 index 0000000..9b7d511 --- /dev/null +++ b/app/controllers/webauthn_controller.rb @@ -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 \ No newline at end of file diff --git a/app/javascript/controllers/webauthn_controller.js b/app/javascript/controllers/webauthn_controller.js new file mode 100644 index 0000000..0e26509 --- /dev/null +++ b/app/javascript/controllers/webauthn_controller.js @@ -0,0 +1,317 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["nickname", "submitButton", "status", "error"]; + static values = { + challengeUrl: String, + createUrl: String, + checkUrl: String + }; + + connect() { + // Check if WebAuthn is supported + if (!this.isWebAuthnSupported()) { + console.warn("WebAuthn is not supported in this browser"); + return; + } + } + + // Check if browser supports WebAuthn + isWebAuthnSupported() { + return ( + window.PublicKeyCredential !== undefined && + typeof window.PublicKeyCredential === "function" + ); + } + + // Check if user has passkeys (for login page) + async checkWebAuthnSupport(event) { + const email = event.target.value.trim(); + + if (!email || !this.isValidEmail(email)) { + return; + } + + try { + const response = await fetch(`${this.checkUrlValue}?email=${encodeURIComponent(email)}`); + const data = await response.json(); + + console.debug("WebAuthn check response:", data); + + if (data.has_webauthn) { + console.debug("Dispatching webauthn-available event"); + // Trigger custom event for login form to show passkey option + this.dispatch("webauthn-available", { + detail: { + hasWebauthn: data.has_webauthn, + requiresWebauthn: data.requires_webauthn, + preferredMethod: data.preferred_method + } + }); + + // Auto-trigger passkey authentication if required + if (data.requires_webauthn) { + setTimeout(() => this.authenticate(), 100); + } + } else { + console.debug("No WebAuthn credentials found for this email"); + } + } catch (error) { + console.error("Error checking WebAuthn support:", error); + } + } + + // Start registration ceremony + async register(event) { + event.preventDefault(); + + if (!this.isWebAuthnSupported()) { + this.showError("WebAuthn is not supported in your browser"); + return; + } + + const nickname = this.nicknameTarget.value.trim(); + if (!nickname) { + this.showError("Please enter a nickname for this passkey"); + return; + } + + this.setLoading(true); + this.clearMessages(); + + try { + // Get registration challenge from server + const challengeResponse = await fetch(this.challengeUrlValue, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.getCSRFToken() + } + }); + + if (!challengeResponse.ok) { + throw new Error("Failed to get registration challenge"); + } + + const credentialCreationOptions = await challengeResponse.json(); + + // Use modern Web Authentication API Level 3 to parse options + // This automatically handles all base64url encoding/decoding + const publicKeyOptions = PublicKeyCredential.parseCreationOptionsFromJSON( + credentialCreationOptions + ); + + // Create credential via WebAuthn API + const credential = await navigator.credentials.create({ + publicKey: publicKeyOptions + }); + + if (!credential) { + throw new Error("Failed to create credential"); + } + + // Send credential to server for verification + // Use toJSON() to properly serialize the credential + const credentialResponse = await fetch(this.createUrlValue, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.getCSRFToken() + }, + body: JSON.stringify({ + credential: credential.toJSON(), + nickname: nickname + }) + }); + + const result = await credentialResponse.json(); + + if (result.success) { + this.showSuccess(result.message); + + // Clear the form + this.nicknameTarget.value = ""; + + // Dispatch event to refresh the passkey list + this.dispatch("passkey-registered", { + detail: { + nickname: nickname, + credentialId: result.credential_id + } + }); + + // Optionally close modal or redirect + setTimeout(() => { + if (window.location.pathname === "/webauthn/new") { + window.location.href = "/profile"; + } + }, 1500); + } else { + this.showError(result.error || "Failed to register passkey"); + } + + } catch (error) { + console.error("WebAuthn registration error:", error); + this.showError(this.getErrorMessage(error)); + } finally { + this.setLoading(false); + } + } + + // Start authentication ceremony + async authenticate(event) { + if (event) { + event.preventDefault(); + } + + if (!this.isWebAuthnSupported()) { + this.showError("WebAuthn is not supported in your browser"); + return; + } + + this.setLoading(true); + this.clearMessages(); + + try { + // Get authentication challenge from server + const response = await fetch("/sessions/webauthn/challenge", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.getCSRFToken() + }, + body: JSON.stringify({ + email: this.getUserEmail() + }) + }); + + if (!response.ok) { + throw new Error("Failed to get authentication challenge"); + } + + const credentialRequestOptions = await response.json(); + + // Use modern Web Authentication API Level 3 to parse options + // This automatically handles all base64url encoding/decoding + const publicKeyOptions = PublicKeyCredential.parseRequestOptionsFromJSON( + credentialRequestOptions + ); + + // Get credential via WebAuthn API + const credential = await navigator.credentials.get({ + publicKey: publicKeyOptions + }); + + if (!credential) { + throw new Error("Failed to get credential"); + } + + // Send assertion to server for verification + // Use toJSON() to properly serialize the credential + const authResponse = await fetch("/sessions/webauthn/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.getCSRFToken() + }, + body: JSON.stringify({ + credential: credential.toJSON(), + email: this.getUserEmail() + }) + }); + + const result = await authResponse.json(); + + if (result.success) { + // Redirect to dashboard or intended URL + window.location.href = result.redirect_to || "/"; + } else { + this.showError(result.error || "Authentication failed"); + } + + } catch (error) { + console.error("WebAuthn authentication error:", error); + this.showError(this.getErrorMessage(error)); + } finally { + this.setLoading(false); + } + } + + // UI helper methods + setLoading(isLoading) { + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.disabled = isLoading; + this.submitButtonTarget.textContent = isLoading ? "Registering..." : "Register Passkey"; + } + } + + showSuccess(message) { + if (this.hasStatusTarget) { + this.statusTarget.textContent = message; + this.statusTarget.className = "mt-2 text-sm text-green-600"; + this.statusTarget.style.display = "block"; + } + } + + showError(message) { + if (this.hasErrorTarget) { + this.errorTarget.textContent = message; + this.errorTarget.className = "mt-2 text-sm text-red-600"; + this.errorTarget.style.display = "block"; + } + } + + clearMessages() { + if (this.hasStatusTarget) { + this.statusTarget.style.display = "none"; + this.statusTarget.textContent = ""; + } + if (this.hasErrorTarget) { + this.errorTarget.style.display = "none"; + this.errorTarget.textContent = ""; + } + } + + getCSRFToken() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute("content") : ""; + } + + getUserEmail() { + // Try multiple ways to get the user email from login form + let emailInput = document.querySelector('input[type="email"]'); + if (!emailInput) { + emailInput = document.querySelector('input[name="email"]'); + } + if (!emailInput) { + emailInput = document.querySelector('input[name="session[email_address]"]'); + } + if (!emailInput) { + emailInput = document.querySelector('input[name="user[email_address]"]'); + } + return emailInput ? emailInput.value.trim() : ""; + } + + isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + } + + getErrorMessage(error) { + // Common WebAuthn errors + if (error.name === "NotAllowedError") { + return "Authentication was cancelled or timed out. Please try again."; + } + if (error.name === "SecurityError") { + return "Security requirements not met. Make sure you're using HTTPS."; + } + if (error.name === "NotSupportedError") { + return "This device doesn't support the requested authentication method."; + } + if (error.name === "InvalidStateError") { + return "This authenticator has already been registered."; + } + + // Fallback to error message + return error.message || "An unexpected error occurred"; + } +} diff --git a/app/models/user.rb b/app/models/user.rb index f20553f..abed8bf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,6 +4,7 @@ class User < ApplicationRecord has_many :user_groups, dependent: :destroy has_many :groups, through: :user_groups has_many :oidc_user_consents, dependent: :destroy + has_many :webauthn_credentials, dependent: :destroy # Token generation for passwordless flows generates_token_for :invitation_login, expires_in: 24.hours do @@ -80,6 +81,54 @@ class User < ApplicationRecord JSON.parse(backup_codes) end + # WebAuthn methods + def webauthn_enabled? + webauthn_credentials.exists? + end + + def can_authenticate_with_webauthn? + webauthn_enabled? && active? + end + + def require_webauthn? + webauthn_required? || (webauthn_enabled? && !password_digest.present?) + end + + # Generate stable WebAuthn user handle on first use + def webauthn_user_handle + return webauthn_id if webauthn_id.present? + + # Generate random 64-byte opaque identifier (base64url encoded) + handle = SecureRandom.urlsafe_base64(64) + update_column(:webauthn_id, handle) + handle + end + + def platform_authenticators + webauthn_credentials.platform_authenticators + end + + def roaming_authenticators + webauthn_credentials.roaming_authenticators + end + + def webauthn_credential_for(external_id) + webauthn_credentials.find_by(external_id: external_id) + end + + # Check if user has any backed up (synced) passkeys + def has_synced_passkeys? + webauthn_credentials.exists?(backup_eligible: true, backup_state: true) + end + + # Preferred authentication method for login flow + def preferred_authentication_method + return :webauthn if require_webauthn? + return :webauthn if can_authenticate_with_webauthn? && preferred_2fa_method == "webauthn" + return :password if password_digest.present? + :webauthn + end + def has_oidc_consent?(application, requested_scopes) oidc_user_consents .where(application: application) diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb new file mode 100644 index 0000000..a859faf --- /dev/null +++ b/app/models/webauthn_credential.rb @@ -0,0 +1,96 @@ +class WebauthnCredential < ApplicationRecord + belongs_to :user + + # Validations + validates :external_id, presence: true, uniqueness: true + validates :public_key, presence: true + validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true } + validates :nickname, presence: true + validates :authenticator_type, inclusion: { in: %w[platform cross-platform] } + + # Scopes for querying + scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed) + scope :platform_authenticators, -> { where(authenticator_type: "platform") } + scope :roaming_authenticators, -> { where(authenticator_type: "cross-platform") } + scope :recently_used, -> { where.not(last_used_at: nil).order(last_used_at: :desc) } + scope :never_used, -> { where(last_used_at: nil) } + + # Update last used timestamp and sign count after successful authentication + def update_usage!(sign_count:, ip_address: nil, user_agent: nil) + update!( + last_used_at: Time.current, + last_used_ip: ip_address, + sign_count: sign_count, + user_agent: user_agent + ) + end + + # Check if this is a platform authenticator (built-in device) + def platform_authenticator? + authenticator_type == "platform" + end + + # Check if this is a roaming authenticator (USB/NFC/Bluetooth key) + def roaming_authenticator? + authenticator_type == "cross-platform" + end + + # Check if this credential is backed up (synced passkeys) + def backed_up? + backup_eligible? && backup_state? + end + + # Human readable description + def description + if nickname.present? + "#{nickname} (#{authenticator_type.humanize})" + else + "#{authenticator_type.humanize} Authenticator" + end + end + + # Check if sign count is suspicious (clone detection) + def suspicious_sign_count?(new_sign_count) + return false if sign_count.zero? && new_sign_count > 0 # First use + return false if new_sign_count > sign_count # Normal increment + + # Sign count didn't increase - possible clone + true + end + + # Format for display in UI + def display_name + nickname || "#{authenticator_type&.humanize} Authenticator" + end + + # When was this credential created? + def created_recently? + created_at > 1.week.ago + end + + # How long ago was this last used? + def last_used_ago + return "Never" unless last_used_at + + time_ago_in_words(last_used_at) + end + + private + + def time_ago_in_words(time) + seconds = Time.current - time + minutes = seconds / 60 + hours = minutes / 60 + days = hours / 24 + + if days > 0 + "#{days.floor} day#{'s' if days > 1} ago" + elsif hours > 0 + "#{hours.floor} hour#{'s' if hours > 1} ago" + elsif minutes > 0 + "#{minutes.floor} minute#{'s' if minutes > 1} ago" + else + "Just now" + end + end +end \ No newline at end of file diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index 3c6b5b2..422e14c 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -181,6 +181,128 @@ + +
+
+

Passkeys

+
+

Use your fingerprint, face recognition, or security key to sign in without passwords.

+
+ + +
+
+
+ + +

Give this passkey a memorable name so you can identify it later.

+
+ +
+ +
+ + + + +
+
+ + +
+

Your Passkeys

+ <% if @user.webauthn_credentials.exists? %> +
+ <% @user.webauthn_credentials.order(created_at: :desc).each do |credential| %> +
+
+
+ <% if credential.platform_authenticator? %> + + + + + <% else %> + + + + + <% end %> +
+
+
+ <%= credential.nickname %> +
+
+ <%= credential.authenticator_type.humanize %> • + Last used <%= credential.last_used_ago %> + <% if credential.backed_up? %> + • Synced + <% end %> +
+
+
+
+ <% if credential.created_recently? %> + + New + + <% end %> + <%= link_to webauthn_credential_path(credential), + method: :delete, + data: { + confirm: "Are you sure you want to delete '#{credential.nickname}'? You'll need to set it up again to sign in with this device.", + turbo_method: :delete + }, + class: "text-red-600 hover:text-red-800 text-sm font-medium" do %> + + + + <% end %> +
+
+ <% end %> +
+ +
+
+
+ + + +
+
+

+ Tip: Add passkeys on multiple devices for easy access. Platform authenticators (like Touch ID) are synced across your devices if you use iCloud Keychain or Google Password Manager. +

+
+
+
+ <% else %> +
+ + + +

No passkeys

+

Get started by adding your first passkey for passwordless sign-in.

+
+ <% end %> +
+
+
+ diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb new file mode 100644 index 0000000..607795e --- /dev/null +++ b/config/initializers/webauthn.rb @@ -0,0 +1,54 @@ +# WebAuthn configuration for Clinch Identity Provider +WebAuthn.configure do |config| + # Relying Party name (displayed in authenticator prompts) + # For development, use http://localhost to match passkey in Passwords app + origin_host = ENV.fetch("CLINCH_HOST", "http://localhost") + config.allowed_origins = [origin_host] + + # Relying Party ID (must match origin domain) + # Extract domain from origin for RP ID + origin_uri = URI.parse(origin_host) + config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost") + + # For development, we also allow localhost with common ports and without port + if Rails.env.development? + config.allowed_origins += [ + "http://localhost", + "http://localhost:3000", + "http://localhost:3035", + "http://127.0.0.1", + "http://127.0.0.1:3000", + "http://127.0.0.1:3035" + ] + end + + # Relying Party name shown in authenticator prompts + config.rp_name = ENV.fetch("CLINCH_RP_NAME", "Clinch Identity Provider") + + # Credential timeout in milliseconds (60 seconds) + # Users have 60 seconds to complete the authentication ceremony + config.credential_options_timeout = 60_000 + + # Supported algorithms for credential creation + # ES256: ECDSA with P-256 and SHA-256 (most common, secure) + # RS256: RSASSA-PKCS1-v1_5 with SHA-256 (hardware keys often use this) + config.algorithms = ["ES256", "RS256"] + + # Encoding for credential IDs and other data + config.encoding = :base64url + + # Custom verifier for additional security checks if needed + # config.verifier = MyCustomVerifier.new +end + +# Security note: WebAuthn requires HTTPS in production +# The WebAuthn API will not work on non-secure origins in production browsers +# Ensure CLINCH_HOST uses https:// in production environments + +# Example environment variables: +# CLINCH_HOST=https://auth.example.com +# CLINCH_RP_ID=example.com +# CLINCH_RP_NAME="Example Company Identity Provider" +# CLINCH_WEBAUTHN_ATTESTATION=none +# CLINCH_WEBAUTHN_USER_VERIFICATION=preferred +# CLINCH_WEBAUTHN_RESIDENT_KEY=preferred \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index ef423ca..21c8c55 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,10 @@ Rails.application.routes.draw do get "/totp-verification", to: "sessions#verify_totp", as: :totp_verification post "/totp-verification", to: "sessions#verify_totp" + # WebAuthn authentication routes + post "/sessions/webauthn/challenge", to: "sessions#webauthn_challenge" + post "/sessions/webauthn/verify", to: "sessions#webauthn_verify" + # OIDC (OpenID Connect) routes get "/.well-known/openid-configuration", to: "oidc#discovery" get "/.well-known/jwks.json", to: "oidc#jwks" @@ -61,6 +65,13 @@ Rails.application.routes.draw do get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp + # WebAuthn (Passkeys) routes + get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn + post '/webauthn/challenge', to: 'webauthn#challenge' + post '/webauthn/create', to: 'webauthn#create' + delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential + get '/webauthn/check', to: 'webauthn#check' + # Admin routes namespace :admin do root "dashboard#index" diff --git a/db/migrate/20251104042155_create_webauthn_credentials.rb b/db/migrate/20251104042155_create_webauthn_credentials.rb new file mode 100644 index 0000000..28fc696 --- /dev/null +++ b/db/migrate/20251104042155_create_webauthn_credentials.rb @@ -0,0 +1,32 @@ +class CreateWebauthnCredentials < ActiveRecord::Migration[8.1] + def change + create_table :webauthn_credentials do |t| + # Reference to the user who owns this credential + t.references :user, null: false, foreign_key: true, index: true + + # WebAuthn specification fields + t.string :external_id, null: false, index: { unique: true } # credential ID (base64) + t.string :public_key, null: false # public key (base64) + t.integer :sign_count, null: false, default: 0 # signature counter (clone detection) + + # Metadata + t.string :nickname # User-friendly name ("MacBook Touch ID") + t.string :authenticator_type # "platform" or "cross-platform" + t.boolean :backup_eligible, default: false # Can be backed up (passkey sync) + t.boolean :backup_state, default: false # Currently backed up + + # Tracking + t.datetime :last_used_at + t.string :last_used_ip + t.string :user_agent # Browser/OS info + + t.timestamps + end + + # Add composite index for user-specific queries + add_index :webauthn_credentials, [:user_id, :external_id], unique: true + add_index :webauthn_credentials, [:user_id, :last_used_at] + add_index :webauthn_credentials, :authenticator_type + add_index :webauthn_credentials, :last_used_at + end +end diff --git a/db/migrate/20251104042206_add_webauthn_to_users.rb b/db/migrate/20251104042206_add_webauthn_to_users.rb new file mode 100644 index 0000000..9b06c7d --- /dev/null +++ b/db/migrate/20251104042206_add_webauthn_to_users.rb @@ -0,0 +1,16 @@ +class AddWebauthnToUsers < ActiveRecord::Migration[8.1] + def change + # WebAuthn user handle - stable, opaque identifier for the user + # Must be unique and never change once assigned + add_column :users, :webauthn_id, :string + add_index :users, :webauthn_id, unique: true + + # Policy enforcement - whether this user MUST use WebAuthn + # Can be set by admins for high-security accounts + add_column :users, :webauthn_required, :boolean, default: false, null: false + + # User preference for 2FA method (if both TOTP and WebAuthn are available) + # :totp, :webauthn, or nil for system default + add_column :users, :preferred_2fa_method, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 8c66031..f655692 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_11_04_022439) do +ActiveRecord::Schema[8.1].define(version: 2025_11_04_042206) do create_table "application_groups", force: :cascade do |t| t.integer "application_id", null: false t.datetime "created_at", null: false @@ -130,12 +130,38 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_022439) do t.datetime "last_sign_in_at" t.string "name" t.string "password_digest", null: false + t.string "preferred_2fa_method" t.integer "status", default: 0, null: false t.boolean "totp_required", default: false, null: false t.string "totp_secret" t.datetime "updated_at", null: false + t.string "webauthn_id" + t.boolean "webauthn_required", default: false, null: false t.index ["email_address"], name: "index_users_on_email_address", unique: true t.index ["status"], name: "index_users_on_status" + t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true + end + + create_table "webauthn_credentials", force: :cascade do |t| + t.string "authenticator_type" + t.boolean "backup_eligible", default: false + t.boolean "backup_state", default: false + t.datetime "created_at", null: false + t.string "external_id", null: false + t.datetime "last_used_at" + t.string "last_used_ip" + t.string "nickname" + t.string "public_key", null: false + t.integer "sign_count", default: 0, null: false + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["authenticator_type"], name: "index_webauthn_credentials_on_authenticator_type" + t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true + t.index ["last_used_at"], name: "index_webauthn_credentials_on_last_used_at" + t.index ["user_id", "external_id"], name: "index_webauthn_credentials_on_user_id_and_external_id", unique: true + t.index ["user_id", "last_used_at"], name: "index_webauthn_credentials_on_user_id_and_last_used_at" + t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" end add_foreign_key "application_groups", "applications" @@ -149,4 +175,5 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_022439) do add_foreign_key "sessions", "users" add_foreign_key "user_groups", "groups" add_foreign_key "user_groups", "users" + add_foreign_key "webauthn_credentials", "users" end