Add webauthn
This commit is contained in:
3
Gemfile
3
Gemfile
@@ -31,6 +31,9 @@ gem "rqrcode", "~> 3.1"
|
|||||||
# JWT for OIDC ID tokens
|
# JWT for OIDC ID tokens
|
||||||
gem "jwt", "~> 3.1"
|
gem "jwt", "~> 3.1"
|
||||||
|
|
||||||
|
# WebAuthn for passkey support
|
||||||
|
gem "webauthn", "~> 3.0"
|
||||||
|
|
||||||
# Public Suffix List for domain parsing
|
# Public Suffix List for domain parsing
|
||||||
gem "public_suffix", "~> 6.0"
|
gem "public_suffix", "~> 6.0"
|
||||||
|
|
||||||
|
|||||||
24
Gemfile.lock
24
Gemfile.lock
@@ -77,11 +77,13 @@ GEM
|
|||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
addressable (2.8.7)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
|
android_key_attestation (0.3.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
bcrypt_pbkdf (1.1.1)
|
bcrypt_pbkdf (1.1.1)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (3.3.1)
|
||||||
|
bindata (2.5.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.18.6)
|
bootsnap (1.18.6)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
@@ -100,11 +102,15 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
|
cbor (0.5.10.1)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.4)
|
connection_pool (2.5.4)
|
||||||
|
cose (1.3.1)
|
||||||
|
cbor (~> 0.5.9)
|
||||||
|
openssl-signature_algorithm (~> 1.0)
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
debug (1.11.0)
|
debug (1.11.0)
|
||||||
@@ -209,6 +215,9 @@ GEM
|
|||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-x86_64-linux-musl)
|
nokogiri (1.18.10-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
|
openssl (3.3.2)
|
||||||
|
openssl-signature_algorithm (1.3.0)
|
||||||
|
openssl (> 2.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.9.0)
|
parser (3.3.9.0)
|
||||||
@@ -315,6 +324,8 @@ GEM
|
|||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.2.1)
|
rubyzip (3.2.1)
|
||||||
|
safety_net_attestation (0.5.0)
|
||||||
|
jwt (>= 2.0, < 4.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.38.0)
|
selenium-webdriver (4.38.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
@@ -363,6 +374,10 @@ GEM
|
|||||||
thruster (0.1.16-arm64-darwin)
|
thruster (0.1.16-arm64-darwin)
|
||||||
thruster (0.1.16-x86_64-linux)
|
thruster (0.1.16-x86_64-linux)
|
||||||
timeout (0.4.3)
|
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)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.17)
|
turbo-rails (2.0.17)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
@@ -379,6 +394,14 @@ GEM
|
|||||||
activemodel (>= 6.0.0)
|
activemodel (>= 6.0.0)
|
||||||
bindex (>= 0.4.0)
|
bindex (>= 0.4.0)
|
||||||
railties (>= 6.0.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 (1.2.11)
|
||||||
websocket-driver (0.8.0)
|
websocket-driver (0.8.0)
|
||||||
base64
|
base64
|
||||||
@@ -429,6 +452,7 @@ DEPENDENCIES
|
|||||||
turbo-rails
|
turbo-rails
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
web-console
|
web-console
|
||||||
|
webauthn (~> 3.0)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.7.2
|
2.7.2
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class SessionsController < ApplicationController
|
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: 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: :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
|
def new
|
||||||
# Redirect to signup if this is first run
|
# Redirect to signup if this is first run
|
||||||
@@ -118,6 +119,141 @@ class SessionsController < ApplicationController
|
|||||||
redirect_to active_sessions_path, notice: "Session revoked successfully."
|
redirect_to active_sessions_path, notice: "Session revoked successfully."
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def validate_redirect_url(url)
|
def validate_redirect_url(url)
|
||||||
|
|||||||
198
app/controllers/webauthn_controller.rb
Normal file
198
app/controllers/webauthn_controller.rb
Normal 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
|
||||||
317
app/javascript/controllers/webauthn_controller.js
Normal file
317
app/javascript/controllers/webauthn_controller.js
Normal file
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ class User < ApplicationRecord
|
|||||||
has_many :user_groups, dependent: :destroy
|
has_many :user_groups, dependent: :destroy
|
||||||
has_many :groups, through: :user_groups
|
has_many :groups, through: :user_groups
|
||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
|
has_many :webauthn_credentials, dependent: :destroy
|
||||||
|
|
||||||
# Token generation for passwordless flows
|
# Token generation for passwordless flows
|
||||||
generates_token_for :invitation_login, expires_in: 24.hours do
|
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||||
@@ -80,6 +81,54 @@ class User < ApplicationRecord
|
|||||||
JSON.parse(backup_codes)
|
JSON.parse(backup_codes)
|
||||||
end
|
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)
|
def has_oidc_consent?(application, requested_scopes)
|
||||||
oidc_user_consents
|
oidc_user_consents
|
||||||
.where(application: application)
|
.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
|
||||||
@@ -181,6 +181,128 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Passkeys (WebAuthn) -->
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6" data-controller="webauthn" data-webauthn-challenge-url-value="/webauthn/challenge" data-webauthn-create-url-value="/webauthn/create">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900">Passkeys</h3>
|
||||||
|
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||||
|
<p>Use your fingerprint, face recognition, or security key to sign in without passwords.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Passkey Form -->
|
||||||
|
<div class="mt-5">
|
||||||
|
<div id="add-passkey-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="passkey-nickname" class="block text-sm font-medium text-gray-700">Passkey Name</label>
|
||||||
|
<input type="text"
|
||||||
|
id="passkey-nickname"
|
||||||
|
data-webauthn-target="nickname"
|
||||||
|
placeholder="e.g., MacBook Touch ID, iPhone Face ID"
|
||||||
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Give this passkey a memorable name so you can identify it later.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->webauthn#register"
|
||||||
|
data-webauthn-target="submitButton"
|
||||||
|
class="inline-flex items-center rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
Add New Passkey
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Messages -->
|
||||||
|
<div data-webauthn-target="status" class="hidden mt-2 p-3 rounded-md text-sm"></div>
|
||||||
|
<div data-webauthn-target="error" class="hidden mt-2 p-3 rounded-md text-sm"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Passkeys List -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h4 class="text-md font-medium text-gray-900 mb-4">Your Passkeys</h4>
|
||||||
|
<% if @user.webauthn_credentials.exists? %>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% @user.webauthn_credentials.order(created_at: :desc).each do |credential| %>
|
||||||
|
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<% if credential.platform_authenticator? %>
|
||||||
|
<!-- Platform authenticator icon -->
|
||||||
|
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<% else %>
|
||||||
|
<!-- Roaming authenticator icon -->
|
||||||
|
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||||
|
</svg>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-gray-900">
|
||||||
|
<%= credential.nickname %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<%= credential.authenticator_type.humanize %> •
|
||||||
|
Last used <%= credential.last_used_ago %>
|
||||||
|
<% if credential.backed_up? %>
|
||||||
|
• <span class="text-green-600">Synced</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<% if credential.created_recently? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
<% 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 %>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>Tip:</strong> 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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No passkeys</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by adding your first passkey for passwordless sign-in.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function showDisable2FAModal() {
|
function showDisable2FAModal() {
|
||||||
document.getElementById('disable-2fa-modal').classList.remove('hidden');
|
document.getElementById('disable-2fa-modal').classList.remove('hidden');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="mx-auto md:w-2/3 w-full">
|
<div class="mx-auto md:w-2/3 w-full" data-controller="webauthn" data-webauthn-check-url-value="/webauthn/check">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,26 +13,138 @@
|
|||||||
autocomplete: "username",
|
autocomplete: "username",
|
||||||
placeholder: "your@email.com",
|
placeholder: "your@email.com",
|
||||||
value: params[:email_address],
|
value: params[:email_address],
|
||||||
|
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" },
|
||||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-5">
|
<!-- WebAuthn section - initially hidden -->
|
||||||
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
|
<div id="webauthn-section" class="my-5 hidden">
|
||||||
<%= form.password_field :password,
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||||
required: true,
|
<div class="flex items-center">
|
||||||
autocomplete: "current-password",
|
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
placeholder: "Enter your password",
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
maxlength: 72,
|
</svg>
|
||||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
<p class="text-sm text-green-800">
|
||||||
|
<strong>Passkey detected!</strong> You can sign in without a password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->webauthn#authenticate"
|
||||||
|
class="w-full rounded-md px-3.5 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium cursor-pointer flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||||
|
</svg>
|
||||||
|
Continue with Passkey
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-5">
|
<!-- Password section - shown by default, hidden if WebAuthn is required -->
|
||||||
<%= form.submit "Sign in",
|
<div id="password-section">
|
||||||
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
|
<div class="my-5">
|
||||||
|
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
|
||||||
|
<%= form.password_field :password,
|
||||||
|
required: true,
|
||||||
|
autocomplete: "current-password",
|
||||||
|
placeholder: "Enter your password",
|
||||||
|
maxlength: 72,
|
||||||
|
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-5">
|
||||||
|
<%= form.submit "Sign in",
|
||||||
|
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 text-sm text-gray-600 text-center">
|
<div class="mt-4 text-sm text-gray-600 text-center">
|
||||||
<%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %>
|
<%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div id="loading-overlay" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg p-6 flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Authenticating...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status messages -->
|
||||||
|
<div id="status-message" class="hidden mt-4 p-3 rounded-md"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const webauthnController = document.querySelector('[data-controller="webauthn"]');
|
||||||
|
|
||||||
|
if (webauthnController) {
|
||||||
|
// Listen for WebAuthn availability events
|
||||||
|
webauthnController.addEventListener('webauthn:webauthn-available', function(event) {
|
||||||
|
console.debug("Received webauthn-available event:", event.detail);
|
||||||
|
const detail = event.detail;
|
||||||
|
const webauthnSection = document.getElementById('webauthn-section');
|
||||||
|
const passwordSection = document.getElementById('password-section');
|
||||||
|
|
||||||
|
if (detail.hasWebauthn) {
|
||||||
|
console.debug("Showing WebAuthn section");
|
||||||
|
webauthnSection.classList.remove('hidden');
|
||||||
|
|
||||||
|
// If WebAuthn is required, hide password section
|
||||||
|
if (detail.requiresWebauthn) {
|
||||||
|
passwordSection.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
// Show both options
|
||||||
|
passwordSection.classList.add('border-t pt-4 mt-4');
|
||||||
|
|
||||||
|
// Add an "or" divider
|
||||||
|
const orDiv = document.createElement('div');
|
||||||
|
orDiv.className = 'relative my-4';
|
||||||
|
orDiv.innerHTML = `
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="px-2 bg-white text-gray-500">Or</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
webauthnSection.parentNode.insertBefore(orDiv, webauthnSection);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.debug("WebAuthn not available, keeping section hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for WebAuthn registration events (from profile page)
|
||||||
|
webauthnController.addEventListener('webauthn:passkey-registered', function(event) {
|
||||||
|
// Show success message
|
||||||
|
const statusMessage = document.getElementById('status-message');
|
||||||
|
statusMessage.className = 'mt-4 p-3 rounded-md bg-green-50 text-green-800 border border-green-200';
|
||||||
|
statusMessage.textContent = 'Passkey registered successfully!';
|
||||||
|
statusMessage.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Hide after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
statusMessage.classList.add('hidden');
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading overlay management
|
||||||
|
function showLoading() {
|
||||||
|
document.getElementById('loading-overlay').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoading() {
|
||||||
|
document.getElementById('loading-overlay').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading when WebAuthn authentication starts
|
||||||
|
document.addEventListener('webauthn:authenticate-start', showLoading);
|
||||||
|
document.addEventListener('webauthn:authenticate-end', hideLoading);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
54
config/initializers/webauthn.rb
Normal file
54
config/initializers/webauthn.rb
Normal file
@@ -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
|
||||||
@@ -19,6 +19,10 @@ Rails.application.routes.draw do
|
|||||||
get "/totp-verification", to: "sessions#verify_totp", as: :totp_verification
|
get "/totp-verification", to: "sessions#verify_totp", as: :totp_verification
|
||||||
post "/totp-verification", to: "sessions#verify_totp"
|
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
|
# OIDC (OpenID Connect) routes
|
||||||
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
||||||
get "/.well-known/jwks.json", to: "oidc#jwks"
|
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
|
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
|
||||||
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_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
|
# Admin routes
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
root "dashboard#index"
|
root "dashboard#index"
|
||||||
|
|||||||
32
db/migrate/20251104042155_create_webauthn_credentials.rb
Normal file
32
db/migrate/20251104042155_create_webauthn_credentials.rb
Normal file
@@ -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
|
||||||
16
db/migrate/20251104042206_add_webauthn_to_users.rb
Normal file
16
db/migrate/20251104042206_add_webauthn_to_users.rb
Normal file
@@ -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
|
||||||
29
db/schema.rb
generated
29
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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|
|
create_table "application_groups", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.datetime "created_at", 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.datetime "last_sign_in_at"
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.string "password_digest", null: false
|
t.string "password_digest", null: false
|
||||||
|
t.string "preferred_2fa_method"
|
||||||
t.integer "status", default: 0, null: false
|
t.integer "status", default: 0, null: false
|
||||||
t.boolean "totp_required", default: false, null: false
|
t.boolean "totp_required", default: false, null: false
|
||||||
t.string "totp_secret"
|
t.string "totp_secret"
|
||||||
t.datetime "updated_at", null: false
|
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 ["email_address"], name: "index_users_on_email_address", unique: true
|
||||||
t.index ["status"], name: "index_users_on_status"
|
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
|
end
|
||||||
|
|
||||||
add_foreign_key "application_groups", "applications"
|
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 "sessions", "users"
|
||||||
add_foreign_key "user_groups", "groups"
|
add_foreign_key "user_groups", "groups"
|
||||||
add_foreign_key "user_groups", "users"
|
add_foreign_key "user_groups", "users"
|
||||||
|
add_foreign_key "webauthn_credentials", "users"
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user