mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 05:11:05 +00:00
* 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
94 lines
2.9 KiB
Ruby
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
|