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 @@ +
+
+
+ <%= inline_icon("fingerprint", size: 32) %> +
+
+
+ <%= identity_webauthn_credential.display_name %> +
+
+
+ +
+
+ <%= t "identity_webauthn_credentials.created_at" %> + <%= identity_webauthn_credential.created_at.strftime("%b %d, %Y at %l:%M %p") %> +
+
+ <%= t "identity_webauthn_credentials.last_used" %> + <%= time_ago_in_words(identity_webauthn_credential.updated_at) %> ago +
+
+ +
+
+ +
+ +
+

<%= t("identity_webauthn_credentials.remove_confirmation") %>

+
+ + <%= button_to identity_webauthn_credential_path(identity_webauthn_credential), + method: :delete, + class: "button danger" do %> + Confirm + <% end %> +
+
+
+
diff --git a/app/views/identity_webauthn_credentials/index.html.erb b/app/views/identity_webauthn_credentials/index.html.erb new file mode 100644 index 0000000..58dc13a --- /dev/null +++ b/app/views/identity_webauthn_credentials/index.html.erb @@ -0,0 +1,30 @@ +
+ <% if @webauthn_credentials.empty? %> +
+

<%= 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" + } %> +
+ <% else %> +
+ <%= render partial: "identity_webauthn_credential", collection: @webauthn_credentials %> +
+ +
+ <%= link_to "Add another passkey", + new_identity_webauthn_credential_path, + class: "btn btn-secondary", + data: { + "hx-get": new_identity_webauthn_credential_path, + "hx-target": "#webauthn-credentials-container", + "hx-swap": "innerHTML" + } %> +
+ <% end %> +
diff --git a/app/views/identity_webauthn_credentials/new.html.erb b/app/views/identity_webauthn_credentials/new.html.erb new file mode 100644 index 0000000..80996df --- /dev/null +++ b/app/views/identity_webauthn_credentials/new.html.erb @@ -0,0 +1,52 @@ +
+

+ A passwordless way to sign in using your device's biometric authentication or PIN. +

+ +
+ Browser not supported +

Your browser doesn't support the latest WebAuthn features. Use something modern!

+
+ + <%= form_with url: identity_webauthn_credentials_path, method: :post, local: true, id: "webauthn-registration-form", style: "display: none;" do |form| %> + <%= form.hidden_field :credential_data, id: "registration-credential-data" %> + <%= form.hidden_field :nickname, id: "registration-nickname" %> + <% end %> + +
+
+
+ + + Give this passkey a memorable name to identify it later! +
+ + + +
+ + <%= link_to "Cancel", + identity_webauthn_credentials_path, + class: "btn btn-secondary", + data: { + "hx-get": identity_webauthn_credentials_path, + "hx-target": "#webauthn-credentials-container", + "hx-swap": "innerHTML" + } %> +
+
+
+
diff --git a/app/views/logins/webauthn.html.erb b/app/views/logins/webauthn.html.erb new file mode 100644 index 0000000..7c0b3a5 --- /dev/null +++ b/app/views/logins/webauthn.html.erb @@ -0,0 +1,46 @@ +
+
+
+

<%= t(".title") %>

+ <%= t(".subtitle") %> +
+ +
+ Authentication Failed +

+
+ + <%= form_with url: verify_webauthn_login_attempt_path(@attempt), method: :post, local: true, id: "webauthn-form" do |form| %> + <%= form.hidden_field :credential_data, id: "credential-data" %> + <% end %> + +
+ +
+ +
+

+ <%= t(".prefer_email") %> + <%= button_to t(".use_email_code"), + skip_webauthn_login_attempt_path(@attempt), + method: :post, + class: "secondary small-btn" %> +

+
+
+ +
+
+ Browser Not Supported +

<%= t(".browser_not_supported") %>

+
+
+
diff --git a/app/views/static_pages/security.html.erb b/app/views/static_pages/security.html.erb index 13f5de4..5c58e4d 100644 --- a/app/views/static_pages/security.html.erb +++ b/app/views/static_pages/security.html.erb @@ -19,6 +19,11 @@ <%= render Components::BootlegTurbo.new(identity_totps_path, id: "totp-container", hx_swap: "innerHTML") %> +
+

<%= t ".webauthn" %>

+ <%= render Components::BootlegTurbo.new(identity_webauthn_credentials_path, id: "webauthn-container", hx_swap: "innerHTML") %> +
+

<%= t ".backup_codes" %>

<%= render Components::BootlegTurbo.new(identity_backup_codes_path, id: "backup-codes-container", hx_swap: "innerHTML") %> diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb new file mode 100644 index 0000000..03d408a --- /dev/null +++ b/config/initializers/webauthn.rb @@ -0,0 +1,21 @@ +# Configure WebAuthn for passkey authentication +WebAuthn.configure do |config| + # The allowed origins - where WebAuthn requests can come from + config.allowed_origins = if Rails.env.production? + [ "https://account.hackclub.com" ] + elsif Rails.env.development? + [ "http://localhost:3000" ] + else + # For test environment or other environments + [ "http://localhost:3000" ] + end + + # The Relying Party name - shown in authenticator UI + config.rp_name = "Hack Club Account" + + # Credential options (optional - these are the defaults) + # Algorithms we support for credential public keys + # ES256 is ECDSA with SHA-256, the most widely supported algorithm + # RS256 is RSA with SHA-256, supported by some older authenticators + config.algorithms = [ "ES256", "RS256" ] +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 1f1617c..3940583 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -224,6 +224,7 @@ en: email: Email totp: TOTP backup_code: Backup code + webauthn: Passkey logins: new: title: Welcome back! @@ -259,6 +260,14 @@ en: verify: Verify lost_authenticator: Lost your authenticator? use_backup_code: Use a backup code instead + webauthn: + title: Welcome back! + subtitle: Use the passkey saved on this device or another nearby device + authenticate: Use passkey + authenticating: Authenticating + browser_not_supported: Your browser doesn't support passkeys. Please use a modern browser or use email code instead. + prefer_email: Don't have a passkey? + use_email_code: Use email code instead saml: http_post: title: SAMLing... @@ -276,6 +285,7 @@ en: applications: Linked apps mfa: 2-Factor Authentication backup_codes: Backup Codes + webauthn: Passkeys identity_backup_codes: totp_heading: "Save your backup codes" save_warning_title: Save these codes now! @@ -339,6 +349,17 @@ en: auth_type: Login method terminate: Log out terminate_confirmation: Log out this session? + identity_webauthn_credentials: + index: + setup_description: Sign in securely using a passkey stored on your device - like your phone, laptop, or hardware security key. + setup_webauthn: Set up passkey + added: Added + created_at: Created + last_used: Last used + remove: Remove + remove_confirmation: Are you sure you want to remove this passkey? You won't be able to use it to sign in anymore. + successfully_added: passkey added! + successfully_removed: passkey removed! step_up: new: title: Gotta confirm it's you... diff --git a/config/routes.rb b/config/routes.rb index 9287646..1c1330c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -274,6 +274,10 @@ def self.matches?(request) post "/login/:id/totp", to: "logins#verify_totp", as: :verify_totp_login_attempt get "/login/:id/backup_code", to: "logins#backup_code", as: :backup_code_login_attempt post "/login/:id/backup_code", to: "logins#verify_backup_code", as: :verify_backup_code_login_attempt + get "/login/:id/webauthn", to: "logins#webauthn", as: :webauthn_login_attempt + post "/login/:id/webauthn/options", to: "logins#webauthn_options", as: :webauthn_options_login_attempt + post "/login/:id/webauthn/verify", to: "logins#verify_webauthn", as: :verify_webauthn_login_attempt + post "/login/:id/webauthn/skip", to: "logins#skip_webauthn", as: :skip_webauthn_login_attempt delete "/logout", to: "sessions#logout", as: :logout @@ -300,6 +304,12 @@ def self.matches?(request) end end + resources :identity_webauthn_credentials, only: [ :index, :new, :create, :destroy ] do + collection do + post :options + end + end + # Step-up authentication flow get "/step_up", to: "step_up#new", as: :new_step_up post "/step_up/verify", to: "step_up#verify", as: :verify_step_up diff --git a/db/migrate/20251126211842_create_identity_webauthn_credentials.rb b/db/migrate/20251126211842_create_identity_webauthn_credentials.rb new file mode 100644 index 0000000..fd64606 --- /dev/null +++ b/db/migrate/20251126211842_create_identity_webauthn_credentials.rb @@ -0,0 +1,13 @@ +class CreateIdentityWebauthnCredentials < ActiveRecord::Migration[8.0] + def change + create_table :identity_webauthn_credentials do |t| + t.references :identity, null: false, foreign_key: true + t.string :external_id + t.string :public_key + t.string :nickname + t.integer :sign_count + + t.timestamps + end + end +end diff --git a/db/migrate/20251126233614_improve_webauthn_credentials_constraints.rb b/db/migrate/20251126233614_improve_webauthn_credentials_constraints.rb new file mode 100644 index 0000000..0d46c19 --- /dev/null +++ b/db/migrate/20251126233614_improve_webauthn_credentials_constraints.rb @@ -0,0 +1,8 @@ +class ImproveWebauthnCredentialsConstraints < ActiveRecord::Migration[8.0] + def change + change_column_null :identity_webauthn_credentials, :external_id, false + change_column_null :identity_webauthn_credentials, :public_key, false + add_index :identity_webauthn_credentials, :external_id, unique: true, if_not_exists: true + add_index :identity_webauthn_credentials, :identity_id, if_not_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index fc2ab14..5e4bf94 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_12_02_092143) do +ActiveRecord::Schema[8.0].define(version: 2025_12_02_095643) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -298,6 +298,7 @@ t.boolean "developer_mode", default: false, null: false t.boolean "saml_debug" t.boolean "is_in_workspace", default: false, null: false + t.string "webauthn_id" t.index ["aadhaar_number_bidx"], name: "index_identities_on_aadhaar_number_bidx", unique: true t.index ["deleted_at"], name: "index_identities_on_deleted_at" t.index ["legacy_migrated_at"], name: "index_identities_on_legacy_migrated_at" @@ -406,6 +407,18 @@ t.index ["login_attempt_id"], name: "index_identity_v2_login_codes_on_login_attempt_id" end + create_table "identity_webauthn_credentials", force: :cascade do |t| + t.bigint "identity_id", null: false + t.string "external_id", null: false + t.string "public_key", null: false + t.string "nickname" + t.integer "sign_count" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["external_id"], name: "index_identity_webauthn_credentials_on_external_id", unique: true + t.index ["identity_id"], name: "index_identity_webauthn_credentials_on_identity_id" + end + create_table "login_attempts", force: :cascade do |t| t.bigint "identity_id", null: false t.bigint "session_id" @@ -531,6 +544,18 @@ t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end + create_table "webauthn_credentials", force: :cascade do |t| + t.bigint "identity_id", null: false + t.string "external_id", null: false + t.string "public_key", null: false + t.string "nickname", null: false + t.integer "sign_count", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true + t.index ["identity_id"], name: "index_webauthn_credentials_on_identity_id" + end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "addresses", "identities" @@ -550,6 +575,7 @@ add_foreign_key "identity_totps", "identities" add_foreign_key "identity_v2_login_codes", "identities" add_foreign_key "identity_v2_login_codes", "login_attempts" + add_foreign_key "identity_webauthn_credentials", "identities" add_foreign_key "login_attempts", "identities" add_foreign_key "login_attempts", "identity_sessions", column: "session_id" add_foreign_key "oauth_access_grants", "identities", column: "resource_owner_id" @@ -560,4 +586,5 @@ add_foreign_key "verifications", "identities" add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id" add_foreign_key "verifications", "identity_documents" + add_foreign_key "webauthn_credentials", "identities" end