diff --git a/app/assets/images/key.svg b/app/assets/images/key.svg new file mode 100644 index 000000000..386e0b501 --- /dev/null +++ b/app/assets/images/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 42a3a21ec..35680e429 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -62,6 +62,7 @@ .icon--history { --svg: url("history.svg "); } .icon--home { --svg: url("home.svg "); } .icon--install-edge { --svg: url("install-edge.svg "); } + .icon--key { --svg: url("key.svg "); } .icon--lifebuoy { --svg: url("lifebuoy.svg "); } .icon--lock { --svg: url("lock.svg "); } .icon--logout { --svg: url("logout.svg "); } diff --git a/app/controllers/concerns/current_request.rb b/app/controllers/concerns/current_request.rb index 15f574ab0..193a2713b 100644 --- a/app/controllers/concerns/current_request.rb +++ b/app/controllers/concerns/current_request.rb @@ -8,6 +8,8 @@ module CurrentRequest Current.user_agent = request.user_agent Current.ip_address = request.ip Current.referrer = request.referrer + + ActionPack::WebAuthn::Current.host = request.host end end end diff --git a/app/controllers/sessions/passkeys_controller.rb b/app/controllers/sessions/passkeys_controller.rb new file mode 100644 index 000000000..5b997260b --- /dev/null +++ b/app/controllers/sessions/passkeys_controller.rb @@ -0,0 +1,54 @@ +class Sessions::PasskeysController < ApplicationController + disallow_account_scope + require_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded + + def create + credential = Identity::Credential.authenticate( + id: params.expect(:credential_id), + client_data_json: response_params[:client_data_json], + authenticator_data: response_params[:authenticator_data], + signature: response_params[:signature], + challenge: session.delete(:webauthn_challenge), + origin: request.base_url + ) + + if credential + authentication_succeeded(credential.identity) + else + authentication_failed + end + end + + private + def response_params + params.expect(response: [ :client_data_json, :authenticator_data, :signature ]) + end + + def authentication_succeeded(identity) + start_new_session_for identity + + respond_to do |format| + format.html { redirect_to after_authentication_url } + format.json { render json: { session_token: session_token } } + end + end + + def authentication_failed + alert_message = "That passkey didn't work. Try again." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end + end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again later." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cabcfa143..eb7fa8309 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -6,6 +6,8 @@ class SessionsController < ApplicationController layout "public" def new + @request_options = Identity::Credential.request_options + session[:webauthn_challenge] = @request_options.challenge end def create @@ -67,4 +69,5 @@ class SessionsController < ApplicationController end end end + end diff --git a/app/controllers/users/credentials_controller.rb b/app/controllers/users/credentials_controller.rb index 63c623253..65ebe42ff 100644 --- a/app/controllers/users/credentials_controller.rb +++ b/app/controllers/users/credentials_controller.rb @@ -1,71 +1,40 @@ class Users::CredentialsController < ApplicationController - before_action :set_user - before_action :set_webauthn_host + before_action :set_credential, only: :destroy def index - @credentials = identity.credentials.order(created_at: :desc) + @credentials = Current.identity.credentials.order(name: :asc, created_at: :desc) end def new - @creation_options = creation_options + @creation_options = Identity::Credential.creation_options(identity: Current.identity, display_name: Current.user.name) session[:webauthn_challenge] = @creation_options.challenge end def create - public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create( - client_data_json: decode64(credential_response[:client_data_json]), - attestation_object: decode64(credential_response[:attestation_object]), + Identity::Credential.register( + identity: Current.identity, + name: credential_params[:name], + client_data_json: credential_params[:client_data_json], + attestation_object: credential_params[:attestation_object], challenge: session.delete(:webauthn_challenge), origin: request.base_url, - transports: Array(credential_response[:transports]) + transports: Array(credential_params[:transports]) ) - identity.credentials.create!( - name: params.dig(:credential, :name), - credential_id: public_key_credential.id, - public_key: public_key_credential.public_key.to_der, - sign_count: public_key_credential.sign_count, - transports: public_key_credential.transports - ) - - redirect_to user_credentials_path(@user) + redirect_to user_credentials_path(Current.user) end def destroy - identity.credentials.find(params[:id]).destroy! - redirect_to user_credentials_path(@user) + @credential.destroy! + redirect_to user_credentials_path(Current.user) end private - def set_user - @user = Current.identity.users.find(params[:user_id]) + def set_credential + @credential = Current.identity.credentials.find(params[:id]) end - def set_webauthn_host - ActionPack::WebAuthn::Current.host = request.host + def credential_params + params.expect(credential: [ :name, :client_data_json, :attestation_object, transports: [] ]) end - - def identity - @user.identity - end - - def creation_options - ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( - id: identity.id, - name: identity.email_address, - display_name: @user.name, - resident_key: :required, - exclude_credentials: identity.credentials.map { |c| ExcludeCredential.new(c.credential_id, c.transports) } - ) - end - - def credential_response - params.expect(credential: { response: [ :client_data_json, :attestation_object, transports: [] ] })[:response] - end - - def decode64(value) - Base64.urlsafe_decode64(value) - end - - ExcludeCredential = Struct.new(:id, :transports) end diff --git a/app/javascript/controllers/credential_controller.js b/app/javascript/controllers/credential_controller.js index 1b882d05e..bd9b0baee 100644 --- a/app/javascript/controllers/credential_controller.js +++ b/app/javascript/controllers/credential_controller.js @@ -25,7 +25,7 @@ export default class extends Controller { for (const transport of credential.response.getTransports?.() || []) { const input = document.createElement("input") input.type = "hidden" - input.name = "credential[response][transports][]" + input.name = "credential[transports][]" input.value = transport this.element.appendChild(input) } diff --git a/app/javascript/controllers/passkey_controller.js b/app/javascript/controllers/passkey_controller.js index a652d0c8f..87145db77 100644 --- a/app/javascript/controllers/passkey_controller.js +++ b/app/javascript/controllers/passkey_controller.js @@ -1,36 +1,92 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { + static values = { publicKey: Object, url: String, csrfToken: String } + + #abortController + connect() { - if (window.PublicKeyCredential?.isConditionalMediationAvailable) { - this.attemptConditionalMediation() - } + this.#attemptConditionalMediation() } disconnect() { - this.abortController?.abort() + this.#abortController?.abort() } - async attemptConditionalMediation() { - if (!await PublicKeyCredential.isConditionalMediationAvailable()) return + async #attemptConditionalMediation() { + if (!await PublicKeyCredential?.isConditionalMediationAvailable?.()) return - this.abortController = new AbortController() + this.#abortController = new AbortController() try { const credential = await navigator.credentials.get({ - publicKey: { - challenge: crypto.getRandomValues(new Uint8Array(32)), - rpId: window.location.hostname - }, + publicKey: this.#prepareOptions(this.publicKeyValue), mediation: "conditional", - signal: this.abortController.signal + signal: this.#abortController.signal }) - console.log("Passkey selected:", credential) + this.#submitAssertion(credential) } catch (error) { if (error.name !== "AbortError") { console.error("Passkey error:", error) } } } + + #submitAssertion(credential) { + const form = document.createElement("form") + form.method = "POST" + form.action = this.urlValue + form.style.display = "none" + + const fields = { + authenticity_token: this.csrfTokenValue, + credential_id: credential.id, + "response[client_data_json]": new TextDecoder().decode(credential.response.clientDataJSON), + "response[authenticator_data]": this.#bufferToBase64url(credential.response.authenticatorData), + "response[signature]": this.#bufferToBase64url(credential.response.signature) + } + + for (const [name, value] of Object.entries(fields)) { + const input = document.createElement("input") + input.type = "hidden" + input.name = name + input.value = value + form.appendChild(input) + } + + document.body.appendChild(form) + form.submit() + } + + #prepareOptions(options) { + const prepared = { + ...options, + challenge: this.#base64urlToBuffer(options.challenge) + } + + if (options.allowCredentials?.length) { + prepared.allowCredentials = options.allowCredentials.map(cred => ({ + ...cred, + id: this.#base64urlToBuffer(cred.id) + })) + } else { + delete prepared.allowCredentials + } + + return prepared + } + + #base64urlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/") + const padding = "=".repeat((4 - base64.length % 4) % 4) + const binary = atob(base64 + padding) + return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer + } + + #bufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer) + const binary = String.fromCharCode(...bytes) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + } } diff --git a/app/models/identity/credential.rb b/app/models/identity/credential.rb index 14093c943..eb03add28 100644 --- a/app/models/identity/credential.rb +++ b/app/models/identity/credential.rb @@ -3,10 +3,64 @@ class Identity::Credential < ApplicationRecord serialize :transports, coder: JSON, type: Array, default: [] + class << self + def creation_options(identity:, display_name:) + ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( + id: identity.id, + name: identity.email_address, + display_name: display_name, + resident_key: :required, + exclude_credentials: identity.credentials.map(&:to_public_key_credential) + ) + end + + def request_options(credentials: []) + ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(credentials: credentials.map(&:to_public_key_credential)) + end + + def authenticate(id:, **params) + find_by(credential_id: id)&.authenticate(**params) + end + + def register(identity:, name:, client_data_json:, attestation_object:, challenge:, origin:, transports: []) + public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create( + client_data_json: Base64.urlsafe_decode64(client_data_json), + attestation_object: Base64.urlsafe_decode64(attestation_object), + challenge: challenge, + origin: origin, + transports: transports + ) + + identity.credentials.create!( + name: name, + credential_id: public_key_credential.id, + public_key: public_key_credential.public_key.to_der, + sign_count: public_key_credential.sign_count, + transports: public_key_credential.transports + ) + end + end + + def authenticate(client_data_json:, authenticator_data:, signature:, challenge:, origin:) + pkc = to_public_key_credential + pkc.authenticate( + client_data_json: client_data_json, + authenticator_data: Base64.urlsafe_decode64(authenticator_data), + signature: Base64.urlsafe_decode64(signature), + challenge: challenge, + origin: origin + ) + update!(sign_count: pkc.sign_count) + self + rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError + nil + end + def to_public_key_credential ActionPack::WebAuthn::PublicKeyCredential.new( id: credential_id, - public_key: public_key, + public_key: OpenSSL::PKey.read(public_key), + sign_count: sign_count, transports: transports ) end diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 10293b5a0..553236f1d 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,26 +1,30 @@ <% @page_title = "Enter your email" %> -
New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we’ll get you signed in.
+New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we'll get you signed in.
<% else %> -Enter your email and we’ll get you signed in.
+Enter your email and we'll get you signed in.
<% end %> <% end %> +