diff --git a/Gemfile b/Gemfile index 7f7a463bbb..f0de870a1c 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,7 @@ gem "image_processing", "~> 1.14" gem "platform_agent" gem "aws-sdk-s3", require: false gem "web-push" +gem "webauthn", "~> 3.0" gem "net-http-persistent" gem "rubyzip", require: "zip" gem "mittens" diff --git a/Gemfile.lock b/Gemfile.lock index e4f364c1c9..d2bdd69353 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -112,6 +112,7 @@ GEM railties addressable (2.8.8) public_suffix (>= 2.0.2, < 8.0) + android_key_attestation (0.3.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) @@ -138,6 +139,7 @@ GEM bcrypt_pbkdf (1.1.1) benchmark (0.5.0) bigdecimal (3.3.1) + bindata (2.5.1) bindex (0.8.1) bootsnap (1.19.0) msgpack (~> 1.2) @@ -156,11 +158,15 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cbor (0.5.10.1) childprocess (5.1.0) logger (~> 1.5) chunky_png (1.4.0) concurrent-ruby (1.3.5) connection_pool (2.5.5) + cose (1.3.1) + cbor (~> 0.5.9) + openssl-signature_algorithm (~> 1.0) crack (1.0.1) bigdecimal rexml @@ -308,6 +314,8 @@ GEM nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) openssl (3.3.2) + openssl-signature_algorithm (1.3.0) + openssl (> 2.0) ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.0) @@ -399,6 +407,8 @@ GEM logger ruby2_keywords (0.0.5) rubyzip (3.2.2) + safety_net_attestation (0.5.0) + jwt (>= 2.0, < 4.0) securerandom (0.4.1) selenium-webdriver (4.38.0) base64 (~> 0.2) @@ -451,6 +461,10 @@ GEM thruster (0.1.16-x86_64-darwin) thruster (0.1.16-x86_64-linux) timeout (0.4.4) + tpm-key_attestation (0.14.1) + bindata (~> 2.4) + openssl (> 2.0) + openssl-signature_algorithm (~> 1.0) trilogy (2.9.0) tsort (0.2.0) turbo-rails (2.0.20) @@ -472,6 +486,14 @@ GEM web-push (3.0.2) jwt (~> 3.0) openssl (~> 3.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) webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -546,6 +568,7 @@ DEPENDENCIES vcr web-console web-push + webauthn (~> 3.0) webmock BUNDLED WITH diff --git a/app/controllers/concerns/webauthn_relying_party.rb b/app/controllers/concerns/webauthn_relying_party.rb new file mode 100644 index 0000000000..458ee711be --- /dev/null +++ b/app/controllers/concerns/webauthn_relying_party.rb @@ -0,0 +1,52 @@ +module WebauthnRelyingParty + extend ActiveSupport::Concern + + private + def webauthn_relying_party + @webauthn_relying_party ||= WebAuthn::RelyingParty.new( + allowed_origins: webauthn_allowed_origins, + id: webauthn_rp_id, + name: "Fizzy" + ) + end + + def webauthn_rp_id + configured_rp_id = Rails.application.config.x.webauthn&.rp_id + configured_rp_id.presence || default_webauthn_rp_id + end + + def webauthn_allowed_origins + configured_origins = Rails.application.config.x.webauthn&.allowed_origins + configured_origins.presence || default_webauthn_allowed_origins + end + + def default_webauthn_rp_id + if Rails.env.development? + "fizzy.localhost" + elsif Rails.env.test? + "localhost" + else + request.host + end + end + + def default_webauthn_allowed_origins + if Rails.env.development? + # Allow both HTTP dev server and HTTPS (used by some password managers) + [ + "http://fizzy.localhost:3006", + "https://fizzy.localhost" + ] + elsif Rails.env.test? + [ "http://localhost" ] + else + [ "#{request.protocol}#{request.host_with_port}" ] + end + end + + # Encode a user ID as base64url for WebAuthn JSON serialization + # The WebAuthn spec requires user.id to be transmitted as base64url + def encode_webauthn_user_id(user_id) + Base64.urlsafe_encode64(user_id.to_s, padding: false) + end +end diff --git a/app/controllers/identity/passkeys_controller.rb b/app/controllers/identity/passkeys_controller.rb new file mode 100644 index 0000000000..cb0b156b62 --- /dev/null +++ b/app/controllers/identity/passkeys_controller.rb @@ -0,0 +1,64 @@ +class Identity::PasskeysController < ApplicationController + include WebauthnRelyingParty + + disallow_account_scope + + layout "public" + + def index + @passkeys = Current.identity.passkeys.order(created_at: :desc) + end + + def new + @options = passkey_registration_options + session[:webauthn_challenge] = @options.challenge + end + + def create + webauthn_credential = webauthn_relying_party.verify_registration( + credential_params, + session.delete(:webauthn_challenge) + ) + + Current.identity.passkeys.create!( + external_id: webauthn_credential.id, + public_key: webauthn_credential.public_key, + sign_count: webauthn_credential.sign_count, + name: passkey_name + ) + + redirect_to identity_passkeys_path, notice: "Passkey added successfully." + rescue WebAuthn::Error, JSON::ParserError, KeyError, NoMethodError + redirect_to new_identity_passkey_path, alert: "Could not register passkey." + end + + def destroy + passkey = Current.identity.passkeys.find(params[:id]) + passkey.destroy + redirect_to identity_passkeys_path, notice: "Passkey removed." + end + + private + def passkey_registration_options + webauthn_relying_party.options_for_registration( + user: { + id: encode_webauthn_user_id(Current.identity.id), + name: Current.identity.email_address, + display_name: Current.identity.email_address + }, + authenticator_selection: { + resident_key: "required", + user_verification: "required" + }, + exclude: Current.identity.passkeys.pluck(:external_id) + ) + end + + def credential_params + JSON.parse(params.require(:credential)) + end + + def passkey_name + params[:name].presence || PlatformAgent.new(request.user_agent).os || "Passkey" + end +end diff --git a/app/controllers/sessions/choices_controller.rb b/app/controllers/sessions/choices_controller.rb new file mode 100644 index 0000000000..3082231b2e --- /dev/null +++ b/app/controllers/sessions/choices_controller.rb @@ -0,0 +1,67 @@ +class Sessions::ChoicesController < ApplicationController + include WebauthnRelyingParty + + disallow_account_scope + require_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + + layout "public" + + def new + @identity = Identity.find_by_email_address(email_address) + + if @identity.nil? || @identity.passkeys.none? + redirect_to new_session_path + return + end + + @passkey_options = passkey_authentication_options + session[:webauthn_challenge] = @passkey_options.challenge + end + + def create + if params[:method] == "magic_link" + send_magic_link + else + authenticate_with_passkey + end + end + + private + def email_address + params[:email] + end + + def passkey_authentication_options + webauthn_relying_party.options_for_authentication( + user_verification: "required" + ) + end + + def send_magic_link + identity = Identity.find_by_email_address!(email_address) + redirect_to_session_magic_link identity.send_magic_link + end + + def authenticate_with_passkey + identity = Identity.find_by_email_address!(email_address) + webauthn_credential = WebAuthn::Credential.from_get(credential_params, relying_party: webauthn_relying_party) + passkey = identity.passkeys.find_by!(external_id: webauthn_credential.id) + + webauthn_credential.verify( + session.delete(:webauthn_challenge), + public_key: passkey.public_key, + sign_count: passkey.sign_count + ) + + passkey.update!(sign_count: webauthn_credential.sign_count) + start_new_session_for identity + redirect_to after_authentication_url + rescue WebAuthn::Error, ActiveRecord::RecordNotFound, JSON::ParserError, KeyError, NoMethodError + redirect_to new_session_choice_path(email: email_address), alert: "Authentication failed. Try again." + end + + def credential_params + JSON.parse(params.require(:credential)) + end +end diff --git a/app/controllers/sessions/passkeys_controller.rb b/app/controllers/sessions/passkeys_controller.rb new file mode 100644 index 0000000000..f89353715c --- /dev/null +++ b/app/controllers/sessions/passkeys_controller.rb @@ -0,0 +1,29 @@ +class Sessions::PasskeysController < ApplicationController + include WebauthnRelyingParty + + disallow_account_scope + require_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + + def create + webauthn_credential = WebAuthn::Credential.from_get(credential_params, relying_party: webauthn_relying_party) + passkey = Passkey.find_by!(external_id: webauthn_credential.id) + + webauthn_credential.verify( + session.delete(:webauthn_challenge), + public_key: passkey.public_key, + sign_count: passkey.sign_count + ) + + passkey.update!(sign_count: webauthn_credential.sign_count) + start_new_session_for passkey.identity + redirect_to after_authentication_url + rescue WebAuthn::Error, ActiveRecord::RecordNotFound, JSON::ParserError, KeyError, NoMethodError + redirect_to new_session_path, alert: "Authentication failed. Try again or use email." + end + + private + def credential_params + JSON.parse(params.require(:credential)) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index fb9fe0617e..afa10a91ef 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,4 +1,6 @@ class SessionsController < ApplicationController + include WebauthnRelyingParty + disallow_account_scope require_unauthenticated_access except: :destroy rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } @@ -6,15 +8,21 @@ class SessionsController < ApplicationController layout "public" def new + @passkey_options = passkey_authentication_options + session[:webauthn_challenge] = @passkey_options.challenge end def create if identity = Identity.find_by_email_address(email_address) - redirect_to_session_magic_link identity.send_magic_link + if identity.passkeys.any? + redirect_to new_session_choice_path(email: email_address) + else + redirect_to_session_magic_link identity.send_magic_link + end else signup = Signup.new(email_address: email_address) if signup.valid?(:identity_creation) - redirect_to_session_magic_link signup.create_identity + redirect_to new_signup_path else head :unprocessable_entity end @@ -30,4 +38,8 @@ def destroy def email_address params.expect(:email_address) end + + def passkey_authentication_options + webauthn_relying_party.options_for_authentication(user_verification: "required") + end end diff --git a/app/controllers/signups/passkeys_controller.rb b/app/controllers/signups/passkeys_controller.rb new file mode 100644 index 0000000000..fbd1588146 --- /dev/null +++ b/app/controllers/signups/passkeys_controller.rb @@ -0,0 +1,48 @@ +class Signups::PasskeysController < ApplicationController + include WebauthnRelyingParty + + disallow_account_scope + allow_unauthenticated_access only: :create + + def create + signup = Signup.new(email_address: email_param) + + if signup.valid?(:identity_creation) + webauthn_credential = webauthn_relying_party.verify_registration( + credential_params, + session.delete(:webauthn_challenge) + ) + + identity = Identity.find_or_create_by!(email_address: email_param) + + identity.passkeys.create!( + external_id: webauthn_credential.id, + public_key: webauthn_credential.public_key, + sign_count: webauthn_credential.sign_count, + name: passkey_name + ) + + start_new_session_for identity + redirect_to new_signup_completion_path + else + redirect_to new_signup_path, alert: "Please enter a valid email address." + end + rescue WebAuthn::Error, JSON::ParserError, KeyError, NoMethodError => e + Rails.logger.error "Passkey registration failed: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.first(10).join("\n") + redirect_to new_signup_path, alert: "Could not register passkey. Please try again." + end + + private + def email_param + params[:email_address] + end + + def credential_params + JSON.parse(params.require(:credential)) + end + + def passkey_name + params[:name].presence || PlatformAgent.new(request.user_agent).os || "Passkey" + end +end diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb index 776421201d..ff99fdd8f1 100644 --- a/app/controllers/signups_controller.rb +++ b/app/controllers/signups_controller.rb @@ -1,4 +1,6 @@ class SignupsController < ApplicationController + include WebauthnRelyingParty + disallow_account_scope allow_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." } @@ -8,12 +10,15 @@ class SignupsController < ApplicationController def new @signup = Signup.new + @passkey_options = passkey_registration_options + session[:webauthn_challenge] = @passkey_options.challenge end def create signup = Signup.new(signup_params) if signup.valid?(:identity_creation) - redirect_to_session_magic_link signup.create_identity + identity = Identity.find_or_create_by!(email_address: signup_params[:email_address]) + redirect_to_session_magic_link identity.send_magic_link(for: :sign_up) else head :unprocessable_entity end @@ -27,4 +32,18 @@ def redirect_authenticated_user def signup_params params.expect signup: :email_address end + + def passkey_registration_options + webauthn_relying_party.options_for_registration( + user: { + id: WebAuthn.generate_user_id, + name: "user@example.com", + display_name: "New User" + }, + authenticator_selection: { + resident_key: "required", + user_verification: "required" + } + ) + end end diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js index beca1f08f3..015337e720 100644 --- a/app/javascript/controllers/form_controller.js +++ b/app/javascript/controllers/form_controller.js @@ -2,10 +2,11 @@ import { Controller } from "@hotwired/stimulus" import { debounce, nextFrame } from "helpers/timing_helpers"; export default class extends Controller { - static targets = [ "cancel", "submit", "input" ] + static targets = [ "cancel", "submit", "input", "hiddenEmail" ] static values = { - debounceTimeout: { type: Number, default: 300 } + debounceTimeout: { type: Number, default: 300 }, + emailSelector: String } #isComposing = false @@ -87,4 +88,13 @@ export default class extends Controller { blurActiveInput() { document.activeElement?.blur() } + + copyEmailToHidden(event) { + if (this.hasHiddenEmailTarget && this.emailSelectorValue) { + const emailInput = document.querySelector(this.emailSelectorValue) + if (emailInput) { + this.hiddenEmailTarget.value = emailInput.value + } + } + } } diff --git a/app/javascript/controllers/passkey_controller.js b/app/javascript/controllers/passkey_controller.js new file mode 100644 index 0000000000..adba257aae --- /dev/null +++ b/app/javascript/controllers/passkey_controller.js @@ -0,0 +1,143 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["credential", "email"] + static values = { options: Object, emailSelector: String } + + async register(event) { + event.preventDefault() + + // Copy email from external input if emailSelector is provided + if (this.hasEmailTarget && this.emailSelectorValue) { + const emailInput = document.querySelector(this.emailSelectorValue) + if (emailInput) { + this.emailTarget.value = emailInput.value + } + } + + try { + const options = this.#prepareCreateOptions(this.optionsValue) + const credential = await navigator.credentials.create({ publicKey: options }) + + this.credentialTarget.value = JSON.stringify(this.#serializeCreate(credential)) + this.element.requestSubmit() + } catch (error) { + if (error.name !== "AbortError") { + console.error("Passkey registration failed:", error) + } + } + } + + async authenticate(event) { + event.preventDefault() + + try { + const credential = await navigator.credentials.get({ + publicKey: this.#prepareGetOptions(this.optionsValue) + }) + + this.credentialTarget.value = JSON.stringify(this.#serializeGet(credential)) + this.element.requestSubmit() + } catch (error) { + if (error.name !== "AbortError") { + console.error("Passkey authentication failed:", error) + } + } + } + + #prepareCreateOptions(options) { + // Remove empty extensions object - some authenticators don't handle it well + const { extensions, ...restOptions } = options + const preparedOptions = { + ...restOptions, + challenge: this.#base64urlToBuffer(options.challenge), + user: { + ...options.user, + id: this.#base64urlToBuffer(options.user.id) + }, + excludeCredentials: (options.excludeCredentials || []).map(c => ({ + ...c, + id: this.#base64urlToBuffer(c.id) + })) + } + + // Only include extensions if non-empty + if (extensions && Object.keys(extensions).length > 0) { + preparedOptions.extensions = extensions + } + + return preparedOptions + } + + #prepareGetOptions(options) { + // Remove empty extensions object - some authenticators don't handle it well + const { extensions, ...restOptions } = options + const preparedOptions = { + ...restOptions, + challenge: this.#base64urlToBuffer(options.challenge), + allowCredentials: (options.allowCredentials || []).map(c => ({ + ...c, + id: this.#base64urlToBuffer(c.id) + })) + } + + // Only include extensions if non-empty + if (extensions && Object.keys(extensions).length > 0) { + preparedOptions.extensions = extensions + } + + return preparedOptions + } + + #serializeCreate(credential) { + return { + id: credential.id, + rawId: this.#bufferToBase64url(credential.rawId), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {}, + authenticatorAttachment: credential.authenticatorAttachment || null, + response: { + clientDataJSON: this.#bufferToBase64url(credential.response.clientDataJSON), + attestationObject: this.#bufferToBase64url(credential.response.attestationObject), + transports: credential.response.getTransports ? credential.response.getTransports() : [] + } + } + } + + #serializeGet(credential) { + return { + id: credential.id, + rawId: this.#bufferToBase64url(credential.rawId), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {}, + authenticatorAttachment: credential.authenticatorAttachment || null, + response: { + clientDataJSON: this.#bufferToBase64url(credential.response.clientDataJSON), + authenticatorData: this.#bufferToBase64url(credential.response.authenticatorData), + signature: this.#bufferToBase64url(credential.response.signature), + userHandle: credential.response.userHandle + ? this.#bufferToBase64url(credential.response.userHandle) + : null + } + } + } + + #base64urlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/") + const padded = base64 + "=".repeat((4 - base64.length % 4) % 4) + const binary = atob(padded) + return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer + } + + #bufferToBase64url(buffer) { + // Handle ArrayBuffer, Uint8Array, or already-encoded string + if (typeof buffer === "string") { + // Already a string, assume it's base64url encoded + return buffer + } + + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) + const binary = String.fromCharCode(...bytes) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") + } +} diff --git a/app/models/identity.rb b/app/models/identity.rb index bb69734b4e..4c3c83ab79 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -2,6 +2,7 @@ class Identity < ApplicationRecord include Joinable, Transferable has_many :magic_links, dependent: :destroy + has_many :passkeys, dependent: :destroy has_many :sessions, dependent: :destroy has_many :users, dependent: :nullify has_many :accounts, through: :users diff --git a/app/models/passkey.rb b/app/models/passkey.rb new file mode 100644 index 0000000000..b6e2cf57f5 --- /dev/null +++ b/app/models/passkey.rb @@ -0,0 +1,6 @@ +class Passkey < ApplicationRecord + belongs_to :identity + + validates :external_id, presence: true, uniqueness: true + validates :public_key, presence: true +end diff --git a/app/views/identity/passkeys/index.html.erb b/app/views/identity/passkeys/index.html.erb new file mode 100644 index 0000000000..755ea510d7 --- /dev/null +++ b/app/views/identity/passkeys/index.html.erb @@ -0,0 +1,45 @@ +<% @page_title = "Passkeys" %> + +
+ No passkeys yet. Add one to sign in without email. +
+ <% end %> + ++ <%= link_to "Cancel", identity_passkeys_path, class: "txt-link" %> +
+Choose how to sign in as <%= @identity.email_address %>
+ + <%= form_with url: session_choice_path, + data: { controller: "passkey", passkey_options_value: @passkey_options.as_json } do |form| %> + <%= form.hidden_field :email, value: @identity.email_address %> + <%= form.hidden_field :credential, data: { passkey_target: "credential" } %> + <%= form.hidden_field :method, value: "passkey", data: { passkey_target: "method" } %> + +or
++ Not you? <%= link_to "Use a different email", new_session_path %> +
+or
+ + <%= form_with url: session_passkey_path, + data: { controller: "passkey", passkey_options_value: @passkey_options.as_json } do |form| %> + <%= form.hidden_field :credential, data: { passkey_target: "credential" } %> + + + <% end %> <% content_for :footer do %> diff --git a/app/views/signups/new.html.erb b/app/views/signups/new.html.erb index 4578d020c1..51cd004781 100644 --- a/app/views/signups/new.html.erb +++ b/app/views/signups/new.html.erb @@ -3,18 +3,34 @@Enter your email to create an account.
- -