Files
sure/app/controllers/passkey_sessions_controller.rb
T
Josh ca66346dc5 feat: add safe admin user removal (#3131)
* feat: add safe admin user removal

* fix: address user removal review findings

* fix: close remaining user removal review gaps

* fix: handle deleted users during session creation

* fix: fail closed when session creation fails

* fix: reject token issuance for inactive users
2026-08-22 21:54:32 +02:00

94 lines
2.9 KiB
Ruby

# frozen_string_literal: true
# Passwordless sign-in with a discoverable passkey ("usernameless" WebAuthn).
#
# The browser resolves which credential to use, so no email is submitted and
# nothing here can be probed for account enumeration. User verification is
# REQUIRED at assertion time, which makes a lone passkey two factors on its
# own (possession + biometric/PIN) — that is why this path deliberately skips
# the TOTP step in MfaController.
class PasskeySessionsController < ApplicationController
include WebauthnRelyingParty
skip_authentication only: %i[options create]
def options
return head :forbidden unless AuthConfig.passkey_login_enabled?
request_options = webauthn_relying_party.options_for_authentication(
user_verification: "required"
)
session[:passkey_login_challenge] = request_options.challenge
render json: request_options
end
def create
return head :forbidden unless AuthConfig.passkey_login_enabled?
challenge = session.delete(:passkey_login_challenge)
return render_invalid if challenge.blank?
credential = WebAuthn::Credential.from_get(
webauthn_credential_payload,
relying_party: webauthn_relying_party
)
user = user_for(credential)
return render_invalid unless user&.active?
return render_invalid unless AuthConfig.local_login_allowed_for?(user)
# Scoped to the user so an assertion can never pair one account's user
# handle with another account's credential.
stored_credential = user.webauthn_credentials.find_by(credential_id: credential.id)
return render_invalid unless stored_credential
stored_credential.with_lock do
credential.verify(
challenge,
public_key: stored_credential.public_key,
sign_count: stored_credential.sign_count,
user_presence: true,
user_verification: true
)
stored_credential.update!(
sign_count: credential.sign_count,
last_used_at: Time.current
)
end
return render_invalid unless complete_sign_in(user)
render json: { redirect_url: root_path }
rescue WebAuthn::Error, ActionController::BadRequest, ActionController::ParameterMissing
render_invalid
end
private
def user_for(credential)
# `presence` matters: `find_by(webauthn_id: nil)` would match every user
# who never registered a credential.
handle = credential.user_handle.presence
return nil if handle.blank?
User.find_by(webauthn_id: handle)
end
def complete_sign_in(user)
# Drop any half-finished password + TOTP attempt from this browser.
session.delete(:mfa_user_id)
@session = create_session_for(user)
return false unless @session
flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user)
true
end
def render_invalid
render json: { error: t("passkey_sessions.invalid_credential") }, status: :unprocessable_entity
end
end