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 @@ + +
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.
++ 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. +
+Get started by adding your first passkey for passwordless sign-in.
+