diff --git a/Gemfile b/Gemfile index b5f3ee3..45c4294 100644 --- a/Gemfile +++ b/Gemfile @@ -134,6 +134,8 @@ gem "geocoder", "~> 1.8" gem "rotp", "~> 6.3" gem "rqrcode", "~> 2.0" +gem "webauthn", "~> 3.1" + gem "bcrypt", "~> 3.1" gem "rack-attack", "~> 6.7" diff --git a/Gemfile.lock b/Gemfile.lock index 24ed053..3cea211 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,6 +92,7 @@ GEM activesupport (>= 6.1, < 8.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + android_key_attestation (0.3.0) annotaterb (4.19.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) @@ -133,6 +134,7 @@ GEM parser (>= 2.4) smart_properties bigdecimal (3.1.9) + bindata (2.5.1) bindex (0.8.1) blind_index (2.7.0) activesupport (>= 7.1) @@ -140,10 +142,11 @@ GEM block_cipher_kit (0.0.4) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.1.0) + brakeman (7.1.1) racc browser (6.2.0) builder (3.3.0) + cbor (0.5.10.1) childprocess (5.1.0) logger (~> 1.5) chunky_png (1.4.0) @@ -159,6 +162,9 @@ GEM parser rails (>= 7.0) rainbow + cose (1.3.1) + cbor (~> 0.5.9) + openssl-signature_algorithm (~> 1.0) countries (7.1.1) unaccent (~> 0.3) crass (1.0.6) @@ -360,6 +366,9 @@ GEM racc (~> 1.4) nokogiri-xmlsec-instructure (0.12.0) nokogiri (~> 1.13) + openssl (3.3.2) + openssl-signature_algorithm (1.3.0) + openssl (> 2.0) ostruct (0.6.1) paper_trail (16.0.0) activerecord (>= 6.1) @@ -515,6 +524,8 @@ GEM ruby-vips (2.2.4) ffi (~> 1.12) logger + safety_net_attestation (0.5.0) + jwt (>= 2.0, < 4.0) saml2 (3.2.3) activesupport (>= 3.2, < 8.2) nokogiri (>= 1.5.8, < 2.0) @@ -549,6 +560,10 @@ GEM thruster (0.1.13-x86_64-darwin) thruster (0.1.13-x86_64-linux) timeout (0.4.3) + tpm-key_attestation (0.14.1) + bindata (~> 2.4) + openssl (> 2.0) + openssl-signature_algorithm (~> 1.0) turbo-rails (2.0.16) actionpack (>= 7.1.0) railties (>= 7.1.0) @@ -574,6 +589,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-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) @@ -660,6 +683,7 @@ DEPENDENCIES valid_email2! vite_rails web-console + webauthn (~> 3.1) wicked (~> 2.0) BUNDLED WITH diff --git a/README.md b/README.md index 81e0730..309a16b 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ so is the onboarding controller, she should really be ripped out and replaced. - make sure you have working installations of ruby ≥ 3.4.4 & nodejs - clone repo -- create .env.development, populate `DATABASE_URL` w/ a local postgres instance +- create .env.development, populate `DATABASE_URL` w/ a local postgres instance and `LOCKBOX_MASTER_KEY` with the value of `openssl rand -hex 32` - if you want to use docker, you can run `docker compose -f docker-compose-dbonly.yml up` to spin up a database and plug `postgresql://postgres@localhost:5432/identity_vault_development` in as your `DATABASE_URL` + - if you don't have docker and are on macOS, [orbstack](https://orbstack.dev) may be helpful - run `bundle install` - run `bin/rails db:prepare` - console in (`bin/rails console`) diff --git a/app/controllers/identity_webauthn_credentials_controller.rb b/app/controllers/identity_webauthn_credentials_controller.rb new file mode 100644 index 0000000..d025ed0 --- /dev/null +++ b/app/controllers/identity_webauthn_credentials_controller.rb @@ -0,0 +1,69 @@ +class IdentityWebauthnCredentialsController < ApplicationController + def index + @webauthn_credentials = current_identity.webauthn_credentials.order(created_at: :desc) + render layout: request.headers["HX-Request"] ? "htmx" : false + end + + def new + render layout: request.headers["HX-Request"] ? "htmx" : false + end + + def options + challenge = WebAuthn::Credential.options_for_create( + user: { + id: current_identity.webauthn_user_id, + name: current_identity.primary_email, + display_name: "#{current_identity.first_name} #{current_identity.last_name}" + }, + exclude: current_identity.webauthn_credentials.raw_credential_ids, + authenticator_selection: { + user_verification: "preferred", + resident_key: "preferred" + } + ) + + # store the challenge in the session to verify it later! + session[:webauthn_registration_challenge] = challenge.challenge + + render json: challenge + end + + def create + begin + credential_data = JSON.parse(params[:credential_data]) + nickname = params[:nickname] + + webauthn_credential = WebAuthn::Credential.from_create(credential_data) + + webauthn_credential.verify(session[:webauthn_registration_challenge]) + + credential = current_identity.webauthn_credentials.create!( + webauthn_id: webauthn_credential.id, + webauthn_public_key: webauthn_credential.public_key, + nickname: nickname.presence, + sign_count: webauthn_credential.sign_count + ) + + session.delete(:webauthn_registration_challenge) + + flash[:success] = t(".successfully_added") + redirect_to security_path + rescue WebAuthn::Error => e + Rails.logger.error "WebAuthn registration error: #{e.message}" + flash[:error] = "Passkey registration failed. Please try again." + render :new, status: :unprocessable_entity + rescue => e + Rails.logger.error "Unexpected WebAuthn registration error: #{e.message}" + flash[:error] = "An unexpected error occurred. Please try again." + render :new, status: :unprocessable_entity + end + end + + def destroy + credential = current_identity.webauthn_credentials.find(params[:id]) + credential.destroy + + flash[:success] = t(".successfully_removed") + redirect_to security_path + end +end diff --git a/app/controllers/logins_controller.rb b/app/controllers/logins_controller.rb index 7dcc0cf..d52bf9f 100644 --- a/app/controllers/logins_controller.rb +++ b/app/controllers/logins_controller.rb @@ -43,8 +43,12 @@ def create same_site: :lax } - send_v2_login_code(identity, attempt) - redirect_to login_attempt_path(id: attempt.to_param), status: :see_other + if identity.webauthn_enabled? + redirect_to webauthn_login_attempt_path(id: attempt.to_param), status: :see_other + else + send_v2_login_code(identity, attempt) + redirect_to login_attempt_path(id: attempt.to_param), status: :see_other + end rescue => e flash[:error] = e.message redirect_to login_path(return_to: @return_to) @@ -157,6 +161,77 @@ def verify_backup_code handle_post_verification_redirect end + def webauthn + render status: :unprocessable_entity + end + + def skip_webauthn + # the user wants to skip using a passkey, use email code instead + send_v2_login_code(@identity, @attempt) + redirect_to login_attempt_path(id: @attempt.to_param), status: :see_other + end + + def webauthn_options + credentials = @identity.webauthn_credentials.pluck(:external_id).map { |id| Base64.urlsafe_decode64(id) } + + options = WebAuthn::Credential.options_for_get( + allow: credentials, + user_verification: "preferred" + ) + + session[:webauthn_authentication_challenge] = options.challenge + + render json: options + end + + def verify_webauthn + flash.clear + + begin + credential_data = JSON.parse(params[:credential_data]) + + webauthn_credential = WebAuthn::Credential.from_get(credential_data) + + Identity::WebauthnCredential.transaction do + credential = @identity.webauthn_credentials.lock.find_by( + external_id: Base64.urlsafe_encode64(webauthn_credential.id, padding: false) + ) + + unless credential + flash.now[:error] = "Passkey not found" + render :webauthn, status: :unprocessable_entity + return + end + + webauthn_credential.verify( + session[:webauthn_authentication_challenge], + public_key: credential.webauthn_public_key, + sign_count: credential.sign_count + ) + + credential.update!(sign_count: webauthn_credential.sign_count) + # "software" passkeys (like the ones from macOS) don't update the sign count, + # so we need to touch the record to update the updated_at timestamp + credential.touch unless credential.saved_change_to_sign_count? + end + + session.delete(:webauthn_authentication_challenge) + factors = (@attempt.authentication_factors || {}).dup + factors[:webauthn] = true + @attempt.update!(authentication_factors: factors) + + handle_post_verification_redirect + rescue WebAuthn::Error => e + Rails.logger.error "WebAuthn authentication error: #{e.message}" + flash.now[:error] = "Passkey verification failed. Please try again or use email code." + render :webauthn, status: :unprocessable_entity + rescue => e + Rails.logger.error "Unexpected WebAuthn error: #{e.message}" + flash.now[:error] = "An unexpected error occurred. Please try again." + render :webauthn, status: :unprocessable_entity + end + end + private def set_attempt @@ -315,6 +390,8 @@ def redirect_to_next_factor if available.include?(:totp) redirect_to totp_login_attempt_path(id: @attempt.to_param), status: :see_other + elsif available.include?(:webauthn) + redirect_to webauthn_login_attempt_path(id: @attempt.to_param), status: :see_other elsif available.include?(:backup_code) redirect_to backup_code_login_attempt_path(id: @attempt.to_param), status: :see_other else diff --git a/app/frontend/js/alpine.js b/app/frontend/js/alpine.js index 73757b1..0d1730d 100644 --- a/app/frontend/js/alpine.js +++ b/app/frontend/js/alpine.js @@ -1,3 +1,9 @@ import Alpine from 'alpinejs' +import { webauthnRegister } from './webauthn-registration.js' +import { webauthnAuth } from './webauthn-authentication.js' + +Alpine.data('webauthnRegister', webauthnRegister) +Alpine.data('webauthnAuth', webauthnAuth) + window.Alpine = Alpine Alpine.start() \ No newline at end of file diff --git a/app/frontend/js/webauthn-authentication.js b/app/frontend/js/webauthn-authentication.js new file mode 100644 index 0000000..96a28f3 --- /dev/null +++ b/app/frontend/js/webauthn-authentication.js @@ -0,0 +1,85 @@ +export function webauthnAuth() { + return { + loading: false, + error: null, + browserSupported: true, + + init() { + this.browserSupported = !!( + globalThis.PublicKeyCredential?.parseRequestOptionsFromJSON && + navigator.credentials?.get + ); + + if (this.browserSupported) { + this.authenticate(); + } + }, + + getLoginAttemptId() { + const pathParts = window.location.pathname.split('/'); + const loginIndex = pathParts.indexOf('login'); + if (loginIndex >= 0 && pathParts.length > loginIndex + 1) { + return pathParts[loginIndex + 1]; + } + throw new Error('Could not determine login attempt ID'); + }, + + async getAuthenticationOptions() { + const loginAttemptId = this.getLoginAttemptId(); + const response = await fetch(`/login/${loginAttemptId}/webauthn/options`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content + } + }); + + if (!response.ok) { + throw new Error('Failed to get authentication options from server'); + } + + return await response.json(); + }, + + async authenticate() { + this.loading = true; + this.error = null; + + try { + const options = await this.getAuthenticationOptions(); + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options); + const credential = await navigator.credentials.get({ publicKey }); + + if (!credential) { + throw new Error('Authentication failed - no credential returned'); + } + + const credentialJSON = credential.toJSON(); + + const credentialDataField = document.getElementById('credential-data'); + const form = document.getElementById('webauthn-form'); + + if (!credentialDataField || !form) { + throw new Error('Form elements not found'); + } + + credentialDataField.value = JSON.stringify(credentialJSON); + form.submit(); + } catch (error) { + console.error('Passkey authentication error:', error); + + if (error.name === 'NotAllowedError') { + this.error = 'Authentication was cancelled or not allowed'; + } else if (error.name === 'InvalidStateError') { + this.error = 'No passkey found for this account'; + } else if (error.name === 'NotSupportedError') { + this.error = 'Passkeys are not supported on this device'; + } else { + this.error = error.message || 'An unexpected error occurred'; + } + + this.loading = false; + } + } + }; +} diff --git a/app/frontend/js/webauthn-registration.js b/app/frontend/js/webauthn-registration.js new file mode 100644 index 0000000..d9c5468 --- /dev/null +++ b/app/frontend/js/webauthn-registration.js @@ -0,0 +1,74 @@ +export function webauthnRegister() { + return { + nickname: '', + loading: false, + error: null, + browserSupported: true, + + init() { + this.browserSupported = !!( + globalThis.PublicKeyCredential?.parseCreationOptionsFromJSON && + navigator.credentials?.create + ); + }, + + async getRegistrationOptions() { + const response = await fetch('/identity_webauthn_credentials/options', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content + } + }); + + if (!response.ok) { + throw new Error('Failed to get registration options from server'); + } + + return await response.json(); + }, + + async register() { + if (!this.nickname.trim()) { + this.error = 'Please enter a nickname for your passkey'; + return; + } + + this.loading = true; + this.error = null; + + try { + const options = await this.getRegistrationOptions(); + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options); + const credential = await navigator.credentials.create({ publicKey }); + + if (!credential) { + throw new Error('Credential creation failed'); + } + + const credentialJSON = credential.toJSON(); + + const credentialDataField = document.getElementById('registration-credential-data'); + const nicknameField = document.getElementById('registration-nickname'); + const form = document.getElementById('webauthn-registration-form'); + + credentialDataField.value = JSON.stringify(credentialJSON); + nicknameField.value = this.nickname; + form.submit(); + } catch (error) { + console.error('Passkey registration error:', error); + if (error.name === 'NotAllowedError') { + this.error = 'Registration was cancelled or not allowed'; + } else if (error.name === 'InvalidStateError') { + this.error = 'This passkey is already registered'; + } else if (error.name === 'NotSupportedError') { + this.error = 'Passkeys are not supported on this device'; + } else { + this.error = error.message || 'An unexpected error occurred'; + } + + this.loading = false; + } + } + }; +} diff --git a/app/frontend/stylesheets/snippets/auth.scss b/app/frontend/stylesheets/snippets/auth.scss index 59493f1..96621f6 100644 --- a/app/frontend/stylesheets/snippets/auth.scss +++ b/app/frontend/stylesheets/snippets/auth.scss @@ -20,7 +20,7 @@ padding: 2.5rem; width: 100%; max-width: 610px; - box-shadow: + box-shadow: 4px 4px 16px rgba(0, 0, 0, 0.04), 2px 2px 8px rgba(0, 0, 0, 0.02), inset 0 1px 0 rgba(255, 255, 255, 0.8); @@ -28,16 +28,16 @@ @include dark-mode { background: linear-gradient(165deg, #252932 0%, #1f2329 100%); border-color: rgba(255, 255, 255, 0.08); - box-shadow: + box-shadow: 4px 4px 16px rgba(0, 0, 0, 0.3), 2px 2px 8px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05); } - + header { text-align: center; margin-bottom: $space-7; - + h1 { font-size: 1.85rem; font-weight: 600; @@ -45,19 +45,19 @@ color: var(--text-strong); letter-spacing: -0.02em; } - + small { font-size: 0.95rem; color: var(--text-muted-strong); display: block; line-height: 1.5; - + a { color: var(--pico-primary); text-decoration: none; font-weight: 500; @include transition-default(color); - + &:hover { color: var(--pico-primary-hover); text-decoration: underline; @@ -65,7 +65,7 @@ } } } - + fieldset { @include form-fieldset; margin: 0 0 1.75rem; @@ -78,11 +78,11 @@ } small { font-size: 0.85rem; color: var(--text-muted-strong); margin-top: 0.375rem; display: block; } } - + .grid { gap: 1rem; } - + button[type="submit"], input[type="submit"] { width: 100%; @@ -91,25 +91,25 @@ font-size: 1rem; border: none; } - + footer { text-align: center; margin-top: $space-7; padding-top: $space-6; border-top: 1px solid var(--surface-2-border); - + p { margin: $space-1 0; font-size: 0.9rem; color: var(--text-muted-strong); } - + a { color: var(--pico-primary); text-decoration: none; font-weight: 500; @include transition-default(color); - + &:hover { color: var(--pico-primary-hover); text-decoration: underline; @@ -123,7 +123,7 @@ .brand { display: none; } - + // Flash wrapper for auth layout .auth-flash-wrapper { position: absolute; @@ -132,7 +132,7 @@ right: 0; padding: 2rem 1rem 0; pointer-events: none; - + .banner { max-width: 640px; margin-left: auto; @@ -141,3 +141,28 @@ } } } + +// WebAuthn +.webauthn-button { + width: 100%; + padding: 0.7rem 1rem; + border: none; + border-radius: 8px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + + .webauthn-icon { + display: inline-flex; + align-items: center; + line-height: 1; + + svg { + display: block; + } + } +} \ No newline at end of file diff --git a/app/frontend/stylesheets/snippets/security.scss b/app/frontend/stylesheets/snippets/security.scss index 74cb499..c0270f6 100644 --- a/app/frontend/stylesheets/snippets/security.scss +++ b/app/frontend/stylesheets/snippets/security.scss @@ -1,3 +1,8 @@ +.security-sections { + max-width: 900px; + margin: 2rem auto 0; +} + .loading-text { color: var(--text-muted-strong); font-size: 0.95rem; @@ -8,7 +13,7 @@ .totp-disabled { text-align: center; padding: $space-6 $space-3; - + p { color: var(--text-muted-strong); margin-bottom: $space-5; @@ -26,28 +31,28 @@ display: flex; align-items: center; gap: $space-4; - + .status-icon { font-size: 2rem; flex-shrink: 0; } - + .status-info { flex: 1; - + strong { display: block; font-size: 1rem; margin-bottom: 0.25rem; } - + p.status-detail { margin: 0; font-size: 0.9rem; color: var(--text-muted-strong); } } - + button { font-size: 0.875rem !important; padding: $space-1 $space-3 !important; @@ -65,13 +70,13 @@ .totp-setup-header { text-align: center; margin-bottom: $space-5; - + h3 { margin: 0 0 $space-1; font-size: 1.5rem; font-weight: 700; } - + .step-indicator { margin: 0; font-size: 0.9rem; @@ -94,13 +99,13 @@ margin-bottom: $space-5; background: var(--surface-2); border-radius: $radius-lg; - + p { margin-bottom: $space-5; font-weight: 600; font-size: 0.95rem; } - + .qr { display: block; width: min(60vw, 200px) !important; @@ -110,15 +115,15 @@ padding: $space-3; border-radius: $radius-md; } - + details { margin-top: $space-5; - + summary { cursor: pointer; font-size: 0.9rem; color: var(--text-muted-strong); - + &:hover { color: var(--pico-primary); } @@ -134,7 +139,7 @@ word-break: break-all; font-family: $font-mono; } - + .copy-secret-btn { margin-top: $space-2; font-size: 0.875rem !important; @@ -157,11 +162,12 @@ display: flex; gap: $space-3; margin-top: $space-5; - + @media (max-width: 480px) { flex-direction: column; - - button, input[type="submit"] { + + button, + input[type="submit"] { width: 100%; } } @@ -171,7 +177,7 @@ margin-top: $space-5; padding-top: $space-5; border-top: 1px solid var(--surface-2-border); - + details { summary { cursor: pointer; @@ -180,12 +186,12 @@ display: flex; align-items: center; gap: $space-2; - + &:hover { color: var(--pico-primary); } } - + p { margin-top: $space-3; padding-left: $space-6; @@ -201,7 +207,7 @@ .sessions-actions { margin-bottom: $space-4; text-align: right; - + button { font-size: 0.875rem !important; padding: $space-1 $space-3 !important; @@ -215,11 +221,34 @@ gap: $space-3; } +.session-card { + @include card($padding: $space-4, $radius: $radius-lg); + @include transition-default(); + + [data-theme="dark"] & { + background: #1f2937; + border-color: #374151; + } + + &:hover { + transform: translateY(-1px); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.1), + 0 2px 6px rgba(0, 0, 0, 0.06); + + [data-theme="dark"] & { + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.4), + 0 2px 6px rgba(0, 0, 0, 0.3); + } + } +} + .current-session { background: linear-gradient(135deg, #f7fee7, #ecfccb); border-color: #84cc16; border-width: 1px; - box-shadow: + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.5); @@ -227,7 +256,7 @@ @include dark-mode { background: linear-gradient(135deg, rgba(132, 204, 22, 0.15), rgba(132, 204, 22, 0.08)); border-color: #84cc16; - box-shadow: + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2); } @@ -260,14 +289,14 @@ @include badge(#84cc16, #1a1d23, 4px); margin-left: $space-1; float: right; - box-shadow: + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.3); @include dark-mode { background: #84cc16; color: #1a1d23; - box-shadow: + box-shadow: 0 1px 3px rgba(132, 204, 22, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2); } @@ -285,7 +314,7 @@ font-size: 0.85rem; color: var(--text-muted-strong); margin-bottom: $space-3; - + div { display: flex; gap: $space-1; @@ -345,7 +374,7 @@ background: color-mix(in srgb, #d97706 15%, #1f2937 85%); color: #fbbf24; } - + strong { font-weight: 600; } @@ -381,16 +410,18 @@ gap: $space-3; flex-wrap: wrap; margin-bottom: $space-5; - - button, a { + + button, + a { flex: 1; min-width: 140px; } - + @media (max-width: 480px) { flex-direction: column; - - button, a { + + button, + a { width: 100%; min-width: unset; } @@ -400,7 +431,7 @@ .backup-codes-confirmation { padding-top: $space-5; border-top: 1px solid var(--surface-2-border); - + label { display: flex; align-items: center; @@ -409,15 +440,15 @@ font-size: 0.95rem; font-weight: 500; cursor: pointer; - + input[type="checkbox"] { margin: 0; } } - + button { width: 100%; - + &:disabled { opacity: 0.5; cursor: not-allowed; @@ -429,23 +460,23 @@ display: flex; align-items: center; gap: $space-4; - + .status-info { flex: 1; - + strong { display: block; font-size: 1rem; margin-bottom: 0.25rem; } - + .status-detail { margin: 0; font-size: 0.9rem; color: var(--text-muted-strong); } } - + button { font-size: 0.875rem !important; padding: $space-1 $space-3 !important; @@ -460,3 +491,74 @@ color: var(--text-muted-strong); font-size: 0.95rem; } + +// WebAuthn! +.webauthn-disabled { + text-align: center; + padding: $space-6 $space-3; + + p { + color: var(--text-muted-strong); + margin-bottom: $space-5; + font-size: 0.95rem; + } +} + +.webauthn-setup-description { + margin-bottom: $space-5; + font-size: 0.95rem; + font-style: italic; + color: var(--text-muted-strong); +} + +// Inline confirmation for delete actions +.confirmation-inline { + padding: $space-4; + background: var(--surface-2); + border-radius: $radius-md; + border: 1px solid var(--surface-2-border); + + [data-theme="dark"] & { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.3); + } + + .confirmation-text { + margin: 0 0 $space-4; + font-size: 0.9rem; + color: var(--text-strong); + line-height: 1.5; + } + + .confirmation-actions { + display: flex; + gap: $space-2; + justify-content: flex-end; + + @media (max-width: 480px) { + flex-direction: column; + + button, + form { + width: 100%; + } + } + + button, + form button { + font-size: 0.875rem !important; + padding: $space-1 $space-3 !important; + width: auto !important; + margin: 0 !important; + min-width: 80px; + + @media (max-width: 480px) { + width: 100% !important; + } + } + + form { + display: inline-block; + } + } +} \ No newline at end of file diff --git a/app/models/identity.rb b/app/models/identity.rb index 90ca20d..c575759 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -54,6 +54,7 @@ class Identity < ApplicationRecord has_many :v2_login_codes, class_name: "Identity::V2LoginCode", dependent: :destroy has_many :totps, class_name: "Identity::TOTP", dependent: :destroy has_many :backup_codes, class_name: "Identity::BackupCode", dependent: :destroy + has_many :webauthn_credentials, class_name: "Identity::WebauthnCredential", dependent: :destroy has_many :documents, class_name: "Identity::Document", dependent: :destroy has_many :verifications, class_name: "Verification", dependent: :destroy @@ -299,10 +300,20 @@ def totp = totps.verified.first def backup_codes_enabled? = backup_codes.active.any? + def webauthn_enabled? = webauthn_credentials.any? + + # Encode identity ID as base64url for WebAuthn user.id + # Uses 64-bit unsigned big-endian binary format + def webauthn_user_id + user_id_binary = [ id ].pack("Q>") + Base64.urlsafe_encode64(user_id_binary, padding: false) + end + def available_step_up_methods methods = [] methods << :totp if totp.present? methods << :backup_code if backup_codes_enabled? + methods << :webauthn if webauthn_enabled? # Future: methods << :sms if sms_verified? methods end @@ -310,7 +321,8 @@ def available_step_up_methods # Generic 2FA method helpers def two_factor_methods [ - totps.verified + totps.verified, + webauthn_credentials # Future: sms_two_factors.verified, ].flatten.compact end diff --git a/app/models/identity/webauthn_credential.rb b/app/models/identity/webauthn_credential.rb new file mode 100644 index 0000000..4213006 --- /dev/null +++ b/app/models/identity/webauthn_credential.rb @@ -0,0 +1,51 @@ +class Identity::WebauthnCredential < ApplicationRecord + belongs_to :identity + + validates :external_id, presence: true, uniqueness: true + validates :public_key, presence: true + validates :sign_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :nickname, length: { maximum: 50 }, allow_blank: true + + before_validation :set_initial_sign_count, on: :create + + # WebAuthn credential IDs and public keys are binary data that need to be + # base64url encoded for storage and transmission + def webauthn_id + Base64.urlsafe_decode64(external_id) + end + + def webauthn_id=(value) + self.external_id = Base64.urlsafe_encode64(value, padding: false) + end + + def webauthn_public_key + Base64.urlsafe_decode64(public_key) + end + + def webauthn_public_key=(value) + self.public_key = Base64.urlsafe_encode64(value, padding: false) + end + + # Increment the sign count after successful authentication + # This helps detect credential cloning attacks + def increment_sign_count! + increment!(:sign_count) + end + + # Human-readable display for the credential + def display_name + nickname.presence || "Passkey created #{created_at.strftime('%b %d, %Y')}" + end + + # Class method to get all decoded credential IDs for a collection + # Useful for building WebAuthn allow/exclude lists + def self.raw_credential_ids + pluck(:external_id).map { |id| Base64.urlsafe_decode64(id) } + end + + private + + def set_initial_sign_count + self.sign_count ||= 0 + end +end diff --git a/app/models/login_attempt.rb b/app/models/login_attempt.rb index a99e996..c86dc16 100644 --- a/app/models/login_attempt.rb +++ b/app/models/login_attempt.rb @@ -8,7 +8,7 @@ class LoginAttempt < ApplicationRecord has_encrypted :browser_token before_validation :ensure_browser_token - store_accessor :authentication_factors, :email, :totp, :backup_code, :legacy_email, prefix: :authenticated_with + store_accessor :authentication_factors, :email, :totp, :backup_code, :webauthn, :legacy_email, prefix: :authenticated_with EXPIRATION = 15.minutes @@ -72,9 +72,12 @@ def totp_available? = !authenticated_with_totp && identity.totp.present? def backup_code_available? = !authenticated_with_backup_code && identity.backup_codes_enabled? + def webauthn_available? = !authenticated_with_webauthn && identity.webauthn_enabled? + def available_factors factors = [] factors << :email if email_available? + factors << :webauthn if webauthn_available? factors << :totp if totp_available? factors << :backup_code if backup_code_available? factors @@ -83,8 +86,12 @@ def available_factors private def required_authentication_factors_count + # WebAuthn inherently provides 2FA (possession + biometric/PIN) + # So if WebAuthn is used, we only need 1 factor + if authenticated_with_webauthn + 1 # Require 2FA if enabled AND at least one 2FA method is configured - if identity.requires_two_factor? + elsif identity.requires_two_factor? 2 else 1 diff --git a/app/views/identity_sessions/_identity_session.html.erb b/app/views/identity_sessions/_identity_session.html.erb index 991e7a6..8cc338a 100644 --- a/app/views/identity_sessions/_identity_session.html.erb +++ b/app/views/identity_sessions/_identity_session.html.erb @@ -50,6 +50,7 @@ <% auth_methods = [] %> <% auth_methods << t("auth_type.email") if identity_session.login_attempt.authenticated_with_email || identity_session.login_attempt.authenticated_with_legacy_email %> <% auth_methods << t("auth_type.totp") if identity_session.login_attempt.authenticated_with_totp %> + <% auth_methods << t("auth_type.webauthn") if identity_session.login_attempt.authenticated_with_webauthn %> <% auth_methods << t("auth_type.backup_code") if identity_session.login_attempt.authenticated_with_backup_code %> <%= auth_methods.join(", ") %> diff --git a/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb b/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb new file mode 100644 index 0000000..841024d --- /dev/null +++ b/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb @@ -0,0 +1,43 @@ +
<%= t("identity_webauthn_credentials.remove_confirmation") %>
+<%= t(".setup_description") %>
+ <%= link_to t(".setup_webauthn"), + new_identity_webauthn_credential_path, + class: "webauthn-enable-btn", + data: { + "hx-get": new_identity_webauthn_credential_path, + "hx-target": "#webauthn-credentials-container", + "hx-swap": "innerHTML" + } %> ++ A passwordless way to sign in using your device's biometric authentication or PIN. +
+ +Your browser doesn't support the latest WebAuthn features. Use something modern!
+<%= t(".browser_not_supported") %>
+