Add webauthn
This commit is contained in:
@@ -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)
|
||||
|
||||
96
app/models/webauthn_credential.rb
Normal file
96
app/models/webauthn_credential.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user