diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 46c6b01438f62e8e241312cdaf7f2f555ac26f2c..05bdb8ac634f50c53446d83e9234c322d02a2461 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -29,6 +29,13 @@ def prompt_for_two_factor(user) render 'devise/sessions/two_factor' end + def prompt_for_passwordless_authentication_via_passkey + add_gon_variables + setup_passkey_authentication + + render 'devise/sessions/passkeys' + end + def handle_locked_user(user) clear_two_factor_attempt! @@ -39,6 +46,14 @@ def locked_user_redirect(user) redirect_to new_user_session_path, alert: locked_user_redirect_alert(user) end + def handle_passwordless_flow + if passwordless_passkey_params[:device_response].present? + authenticate_with_passwordless_authentication_via_passkey + else + prompt_for_passwordless_authentication_via_passkey + end + end + def authenticate_with_two_factor user = self.resource = find_user return handle_locked_user(user) unless user.can?(:log_in) @@ -94,19 +109,33 @@ def authenticate_with_two_factor_via_otp(user) end def authenticate_with_two_factor_via_webauthn(user) - if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute + if Webauthn::AuthenticateService.new(user, user_params[:device_response], + session[:challenge]).execute handle_two_factor_success(user) else handle_two_factor_failure(user, 'WebAuthn', _('Authentication via WebAuthn device failed.')) end end + def authenticate_with_passwordless_authentication_via_passkey + existing_user_with_passkey = Authn::Passkey::AuthenticateService.new(passwordless_passkey_params[:device_response], + session[:challenge]).execute + + if existing_user_with_passkey + handle_passwordless_auth_with_passkey_success(existing_user_with_passkey) + else + handle_passwordless_auth_with_passkey_failure('WebAuthn', _('Authentication via Passkey failed.')) + end + end + # rubocop: disable CodeReuse/ActiveRecord def setup_webauthn_authentication(user) if user.second_factor_webauthn_registrations.present? webauthn_registration_ids = user.second_factor_webauthn_registrations.pluck(:credential_xid) + webauthn_registration_ids.concat(user.passkeys.pluck(:credential_xid)) if passkey_via_2fa_enabled?(user) + get_options = WebAuthn::Credential.options_for_get( allow: webauthn_registration_ids, user_verification: 'discouraged', @@ -118,6 +147,16 @@ def setup_webauthn_authentication(user) end # rubocop: enable CodeReuse/ActiveRecord + def setup_passkey_authentication + get_options = WebAuthn::Credential.options_for_get( + allow: [], + user_verification: 'required' + ) + + session[:challenge] = get_options.challenge + gon.push(webauthn: { options: Gitlab::Json.dump(get_options) }) + end + def handle_two_factor_success(user) # Remove any lingering user data from login clear_two_factor_attempt! @@ -135,6 +174,20 @@ def handle_two_factor_failure(user, method, message) prompt_for_two_factor(user) end + def handle_passwordless_auth_with_passkey_success(user) + clear_two_factor_attempt! + + remember_me(user) if passwordless_passkey_params[:remember_me] == '1' + sign_in(user) + end + + def handle_passwordless_auth_with_passkey_failure(method, message) + Gitlab::AppLogger.info("Failed Login: ip=#{request.remote_ip} method=#{method}") + + flash.now[:alert] = message + prompt_for_passwordless_authentication_via_passkey + end + def send_two_factor_otp_attempt_failed_email(user) user.notification_service.two_factor_otp_attempt_failed(user, request.remote_ip) end @@ -156,6 +209,10 @@ def user_password_changed?(user) Digest::SHA256.hexdigest(user.encrypted_password) != session[:user_password_hash] end + + def passkey_via_2fa_enabled?(user) + user.two_factor_enabled? && user.passkeys_enabled? + end end AuthenticatesWithTwoFactor.prepend_mod_with('AuthenticatesWithTwoFactor') diff --git a/app/controllers/profiles/passkeys_controller.rb b/app/controllers/profiles/passkeys_controller.rb index 13a0dd7476d2fa782e47d6c5b543a5bca610c037..3b9306c32e3c826642231f1d92576b1366c764e9 100644 --- a/app/controllers/profiles/passkeys_controller.rb +++ b/app/controllers/profiles/passkeys_controller.rb @@ -3,20 +3,35 @@ module Profiles class PasskeysController < Profiles::ApplicationController before_action :check_passkeys_available! + skip_before_action :check_two_factor_requirement feature_category :system_access def new - # TODO: Add any needed controller code - render :new + setup_passkey_registration_page end def create - # TODO: Add any needed controller code + @passkey = Authn::Passkey::RegisterService.new( + current_user, + device_registration_params, + session[:challenge] + ).execute + + if @passkey.persisted? + session.delete(:challenge) + + notice = _("Passkey added successfully! Next time you sign in, select the sign-in with passkey option.") + redirect_to profile_two_factor_auth_path, notice: notice + else + render :new + end end def destroy - # TODO: Add any needed controller code + Authn::Passkey::DestroyService.new(current_user, current_user, destroy_params[:id]).execute + + redirect_to profile_two_factor_auth_path, status: :found, notice: _("Passkey has been deleted!") end private @@ -24,5 +39,55 @@ def destroy def check_passkeys_available! render_404 unless Feature.enabled?(:passkeys, current_user) end + + def setup_passkey_registration_page + @passkey ||= WebauthnRegistration.passkey.new + @passkeys ||= get_passkeys + + current_user.user_detail.update!(webauthn_xid: WebAuthn.generate_user_id) unless current_user.webauthn_xid + + options = webauthn_options + session[:challenge] = options.challenge + + gon.push(webauthn: { options: options }) + + render :new + end + + def get_passkeys + current_user.passkeys.map do |passkey| + { + name: passkey.name, + created_at: passkey.created_at, + last_used_at: passkey.last_used_at, + delete_path: profile_passkey_path(passkey) + } + end + end + + def webauthn_options + WebAuthn::Credential.options_for_create( + user: { + id: current_user.webauthn_xid, + name: current_user.username, + display_name: current_user.name + }, + exclude: current_user.get_all_webauthn_credential_ids, + authenticator_selection: { + user_verification: 'required', + resident_key: 'required' + }, + rp: { name: 'GitLab' }, + extensions: { credProps: true } + ) + end + + def device_registration_params + params.require(:device_registration).permit(:device_response, :name) + end + + def destroy_params + params.permit(:id) + end end end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 68bb5a6a299c57a61c76e178a7c24b3824f05651..05217644a44336b32c02ff73c60295cad2ba00ff 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -201,6 +201,8 @@ def setup_webauthn_registration @registrations = second_factor_webauthn_registrations @webauthn_registration ||= WebauthnRegistration.new + @passkeys = get_passkeys + current_user.user_detail.update!(webauthn_xid: WebAuthn.generate_user_id) unless current_user.webauthn_xid options = webauthn_options @@ -219,12 +221,31 @@ def second_factor_webauthn_registrations end end + def get_passkeys + current_user.passkeys.map do |passkey| + { + name: passkey.name, + created_at: passkey.created_at, + last_used_at: passkey.last_used_at, + delete_path: profile_passkey_path(passkey) + } + end + end + def webauthn_options WebAuthn::Credential.options_for_create( - user: { id: current_user.webauthn_xid, name: current_user.username }, - exclude: current_user.second_factor_webauthn_registrations.map(&:credential_xid), - authenticator_selection: { user_verification: 'discouraged' }, - rp: { name: 'GitLab' } + user: { + id: current_user.webauthn_xid, + name: current_user.username, + display_name: current_user.name + }, + exclude: current_user.get_all_webauthn_credential_ids, + authenticator_selection: { + user_verification: 'discouraged', + resident_key: 'preferred' + }, + rp: { name: 'GitLab' }, + extensions: { credProps: true } ) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index b8ee5e8a4789d49a850b03a32dcc46cb639c52f0..6d8fe3114cad90c1cc86de9cd911eb15dbb202af 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -66,6 +66,12 @@ def new super end + def new_passkey + return unless Feature.enabled?(:passkeys, current_user || Feature.current_request) + + handle_passwordless_flow + end + def create super do |resource| # User has successfully signed in, so clear any unused reset token @@ -212,6 +218,11 @@ def user_params params.require(:user).permit(:login, :password, :remember_me, :otp_attempt, :device_response) end + def passwordless_passkey_params + permitted_list = [:device_response, :remember_me] + params.permit(permitted_list) + end + def find_user strong_memoize(:find_user) do if session[:otp_user_id] && user_params[:login] diff --git a/app/models/user.rb b/app/models/user.rb index b4403061ddafd6ce16c3b430e2fc3fe89807cfa7..08e3fb231be6f275bcfb4d164a0c630965446344 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1189,6 +1189,10 @@ def ends_with_reserved_file_extension?(username) # Instance methods # + def get_all_webauthn_credential_ids + second_factor_webauthn_registrations.pluck(:credential_xid) + passkeys.pluck(:credential_xid) + end + def full_path username end @@ -1320,7 +1324,7 @@ def remember_me?(token, generated_at) def disable_two_factor! transaction do - self.disable_webauthn! + self.disable_second_factor_webauthn! self.disable_two_factor_otp! self.reset_backup_codes! end @@ -1337,11 +1341,11 @@ def disable_two_factor_otp! ) end - def disable_webauthn! + def disable_second_factor_webauthn! self.second_factor_webauthn_registrations.destroy_all # rubocop:disable Cop/DestroyAll end - def destroy_webauthn_device(device_id) + def destroy_second_factor_webauthn_device(device_id) self.second_factor_webauthn_registrations.find(device_id).destroy end @@ -1364,6 +1368,10 @@ def two_factor_webauthn_enabled? second_factor_webauthn_registrations.any? end + def passkeys_enabled? + passkeys.any? + end + def needs_new_otp_secret? !two_factor_otp_enabled? && otp_secret_expired? end diff --git a/app/services/authn/passkey/authenticate_service.rb b/app/services/authn/passkey/authenticate_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..d60f59d6bc7b37511f6ac71b979a50f9a64e7114 --- /dev/null +++ b/app/services/authn/passkey/authenticate_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Handles passwordless authentication with passkeys, specifically on the sign_in page. +# +module Authn + module Passkey + class AuthenticateService < BaseService + def initialize(device_response, challenge) + @device_response = device_response + @challenge = challenge + end + + def execute + parsed_device_response = Gitlab::Json.parse(@device_response) + + passkey_credential = WebAuthn::Credential.from_get(parsed_device_response) + encoded_raw_id = Base64.strict_encode64(passkey_credential.raw_id) + stored_passkey_credential = find_matching_credential_xid(encoded_raw_id) + + encoder = WebAuthn.configuration.encoder + + if stored_passkey_credential && + validate_passkey_credential(passkey_credential) && + verify_passkey_credential(passkey_credential, stored_passkey_credential, @challenge, encoder) + + stored_passkey_credential.update!( + counter: passkey_credential.sign_count, + last_used_at: Time.current + ) + + return find_matching_user_with_passkey(stored_passkey_credential) + end + + false + rescue JSON::ParserError, WebAuthn::SignCountVerificationError, WebAuthn::Error, ActiveRecord::RecordNotFound + false + end + + private + + def find_matching_credential_xid(possible_user_passkey_credential_xid) + WebauthnRegistration.passkey.find_by_credential_xid(possible_user_passkey_credential_xid) + end + + def find_matching_user_with_passkey(existing_credential_xid) + User.find(existing_credential_xid.user_id) + end + + def validate_passkey_credential(passkey_credential) + passkey_credential.type == WebAuthn::TYPE_PUBLIC_KEY && + passkey_credential.raw_id && passkey_credential.id && + passkey_credential.raw_id == WebAuthn.standard_encoder.decode(passkey_credential.id) + end + + def verify_passkey_credential(passkey_credential, stored_credential, challenge, encoder) + passkey_credential.response.verify( + encoder.decode(challenge), + public_key: encoder.decode(stored_credential.public_key), + sign_count: stored_credential.counter, + rp_id: URI(WebAuthn.configuration.origin).host + ) + end + end + end +end diff --git a/app/services/authn/passkey/destroy_service.rb b/app/services/authn/passkey/destroy_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..81fcd9c8282ea4ead47bf0d7783ba7296749985b --- /dev/null +++ b/app/services/authn/passkey/destroy_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Authn + module Passkey + class DestroyService < BaseService + attr_reader :passkey, :user, :current_user + + def initialize(current_user, user, passkey_id) + @current_user = current_user + @user = user + @passkey = user.passkeys.find(passkey_id) + end + + def execute + passkey.destroy + end + end + end +end + +Webauthn::DestroyService.prepend_mod diff --git a/app/services/authn/passkey/register_service.rb b/app/services/authn/passkey/register_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..5c20eebc9ac678c508cea2783d9d482f0ced8bd0 --- /dev/null +++ b/app/services/authn/passkey/register_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Authn + module Passkey + class RegisterService < BaseService + def initialize(user, params, challenge) + @user = user + @params = params + @challenge = challenge + end + + def execute + registration = WebauthnRegistration.new + + begin + passkey_credential = WebAuthn::Credential.from_create(Gitlab::Json.parse(@params[:device_response])) + passkey_credential.verify(@challenge) + + @passkey_credential = passkey_credential + + registration.update( + credential_xid: Base64.strict_encode64(@passkey_credential.raw_id), + public_key: @passkey_credential.public_key, + counter: @passkey_credential.sign_count, + name: @params[:name], + user: @user, + authentication_mode: :passwordless, + passkey_eligible: true, + last_used_at: Time.current + ) + rescue JSON::ParserError + registration.errors.add(:base, _('Your WebAuthn device did not send a valid JSON response.')) + rescue WebAuthn::Error => e + registration.errors.add(:base, e.message) + end + + registration + end + end + end +end diff --git a/app/services/webauthn/authenticate_service.rb b/app/services/webauthn/authenticate_service.rb index 8ae02f88348318ce14f1183549a14a83ccb85178..35b8323bded6b4a33236dd53d2e13653b1d5f1d2 100644 --- a/app/services/webauthn/authenticate_service.rb +++ b/app/services/webauthn/authenticate_service.rb @@ -13,7 +13,8 @@ def execute webauthn_credential = WebAuthn::Credential.from_get(parsed_device_response) encoded_raw_id = Base64.strict_encode64(webauthn_credential.raw_id) - stored_webauthn_credential = @user.second_factor_webauthn_registrations.find_by_credential_xid(encoded_raw_id) + stored_webauthn_credential = @user.passkeys.find_by_credential_xid(encoded_raw_id) || + @user.second_factor_webauthn_registrations.find_by_credential_xid(encoded_raw_id) encoder = WebAuthn.configuration.encoder @@ -21,7 +22,10 @@ def execute validate_webauthn_credential(webauthn_credential) && verify_webauthn_credential(webauthn_credential, stored_webauthn_credential, @challenge, encoder) - stored_webauthn_credential.update!(counter: webauthn_credential.sign_count) + stored_webauthn_credential.update!( + counter: webauthn_credential.sign_count, + last_used_at: Time.current + ) return true end diff --git a/app/services/webauthn/destroy_service.rb b/app/services/webauthn/destroy_service.rb index b4decf2e524e2bb5b815c6b9cdc48853e1bc9b8f..911b371c888fe84b9785f7fb42606daf51776a23 100644 --- a/app/services/webauthn/destroy_service.rb +++ b/app/services/webauthn/destroy_service.rb @@ -13,7 +13,7 @@ def initialize(current_user, user, second_factor_webauthn_registrations_id) def execute return error(_('You are not authorized to perform this action')) unless authorized? - result = destroy_webauthn_device + result = destroy_second_factor_webauthn_device if result[:status] == :success notify_on_success(user, webauthn_registration.name) @@ -33,9 +33,9 @@ def authorized? current_user.can?(:disable_two_factor, user) end - def destroy_webauthn_device + def destroy_second_factor_webauthn_device ::Users::UpdateService.new(current_user, user: user).execute do |user| - user.destroy_webauthn_device(webauthn_registration.id) + user.destroy_second_factor_webauthn_device(webauthn_registration.id) end end diff --git a/app/services/webauthn/register_service.rb b/app/services/webauthn/register_service.rb index 21be22027a8f8bcd97fbfc6fa9b770f4bc66fa19..d030db6d34b867c84e535d765fec876d54a58c10 100644 --- a/app/services/webauthn/register_service.rb +++ b/app/services/webauthn/register_service.rb @@ -15,12 +15,16 @@ def execute webauthn_credential = WebAuthn::Credential.from_create(Gitlab::Json.parse(@params[:device_response])) webauthn_credential.verify(@challenge) + @webauthn_credential = webauthn_credential + registration.update( - credential_xid: Base64.strict_encode64(webauthn_credential.raw_id), - public_key: webauthn_credential.public_key, - counter: webauthn_credential.sign_count, + credential_xid: Base64.strict_encode64(@webauthn_credential.raw_id), + public_key: @webauthn_credential.public_key, + counter: @webauthn_credential.sign_count, name: @params[:name], - user: @user + user: @user, + passkey_eligible: passkey?, + last_used_at: Time.current ) rescue JSON::ParserError registration.errors.add(:base, _('Your WebAuthn device did not send a valid JSON response.')) @@ -30,5 +34,13 @@ def execute registration end + + private + + # This service will prefer to create a passkey but remain as 2FA device. + # This is to determine & save the passkey eligibility of user 2FA authenticators for future upgrade flows + def passkey? + !!@webauthn_credential.client_extension_outputs.dig("credProps", "rk") + end end end diff --git a/app/views/devise/sessions/passkeys.html.haml b/app/views/devise/sessions/passkeys.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..13cc17267311f858340ceab3259932646ff94f5d --- /dev/null +++ b/app/views/devise/sessions/passkeys.html.haml @@ -0,0 +1,5 @@ + +%h1 + TODO: Implement Passkey (Passwordless Flow) + +.js-passkeys diff --git a/app/views/profiles/two_factor_auths/_default_2fa_method.html.haml b/app/views/profiles/two_factor_auths/_default_2fa_method.html.haml index 000a04227a4bf61275096b05f924dab66da6cb86..bdbdbf24f54b82806853d20404a78eadf90804e8 100644 --- a/app/views/profiles/two_factor_auths/_default_2fa_method.html.haml +++ b/app/views/profiles/two_factor_auths/_default_2fa_method.html.haml @@ -1,5 +1,4 @@ - return unless Feature.enabled?(:passkeys, current_user) -- @passkeys = ['todo'] - return unless current_user.two_factor_enabled? %p diff --git a/app/views/profiles/two_factor_auths/_passkeys.html.haml b/app/views/profiles/two_factor_auths/_passkeys.html.haml index 240499b7ca7e8af7cf7b728e495206afc7504a65..4573712462b1d9d9de3d76178b6f265ed222f8c7 100644 --- a/app/views/profiles/two_factor_auths/_passkeys.html.haml +++ b/app/views/profiles/two_factor_auths/_passkeys.html.haml @@ -1,5 +1,3 @@ --# TODO: Swap out for real passkeys once backend is complete -- @passkeys = @registrations = render ::Layouts::CrudComponent.new(s_('ProfilesAuthentication|Manage passkeys'), icon: 'key', count: @passkeys.length ) do |c| @@ -21,8 +19,7 @@ %tr %td{ data: { label: _('Name') } }= passkey[:name] %td{ data: { label: s_('ProfilesAuthentication|Added on') } }= l(passkey[:created_at].to_date, format: :admin) - -# TODO: change created_at for last_used_at once backend is complete - %td{ data: { label: s_('ProfilesAuthentication|Last used') } }= l(passkey[:created_at].to_date, format: :admin) + %td{ data: { label: s_('ProfilesAuthentication|Last used') } }= passkey[:last_used_at] ? l(passkey[:last_used_at].to_date, format: :admin) : '' %td{ data: { label: _('Actions') }, class: '!gl-py-3' } .js-two-factor-action-confirm{ data: delete_passkey_data(current_password_required?, passkey[:delete_path]) } - else diff --git a/app/views/profiles/two_factor_auths/_passkeys_2fa.html.haml b/app/views/profiles/two_factor_auths/_passkeys_2fa.html.haml index 17c0c6c09bf40f5ae63b49f7862b643b9180a511..eac434f8c3dd662e75940b6b22ba33be98385d81 100644 --- a/app/views/profiles/two_factor_auths/_passkeys_2fa.html.haml +++ b/app/views/profiles/two_factor_auths/_passkeys_2fa.html.haml @@ -1,5 +1,4 @@ - return unless Feature.enabled?(:passkeys, current_user) -- @passkeys = ['todo'] - return unless !current_user.two_factor_enabled? && @passkeys.present? = render ::Layouts::CrudComponent.new(s_('ProfilesAuthentication|Passkeys'), diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index fe9096302b15588d14092163b912bf39d2c7f223..00fc32caf6dcdf3e780c227810a29ab77f45981e 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -3,8 +3,6 @@ - page_title title, _('Account') - add_to_breadcrumbs _('Account'), profile_account_path - troubleshooting_link = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication_troubleshooting.md'), target: '_blank', rel: 'noopener noreferrer' --# TODO: Swap out for real passkeys once backend is complete -- @passkeys = ['todo'] - content_for :after_flash_content do - if @error diff --git a/config/routes/user.rb b/config/routes/user.rb index 42fb3e02ecafe81ae7b672d8a31303c722942d94..6536db357e2816ce5308a4c7f9b6dfa5001c474c 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -58,6 +58,8 @@ def override_omniauth(provider, controller, path_prefix = '/users/auth') post '/users/skip_verification_for_now', to: 'sessions#skip_verification_for_now' get '/users/skip_verification_confirmation', to: 'sessions#skip_verification_confirmation' + post '/users/passkeys/sign_in', to: 'sessions#new_passkey', as: :users_passkeys_sign_in + # Redirect on GitHub authorization request errors. E.g. it could happen when user: # 1. cancel authorization the GitLab OAuth app via GitHub to import GitHub repos # (they'll be redirected to /projects/new#import_project) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 108f5e461ada34ac9fbbfc9e878ea79c55b4680c..61ba7775d5117db5590b17a12af2f7a530b8fa33 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -9742,6 +9742,9 @@ msgstr "" msgid "Authentication method updated" msgstr "" +msgid "Authentication via Passkey failed." +msgstr "" + msgid "Authentication via WebAuthn device failed." msgstr "" @@ -47441,9 +47444,15 @@ msgstr "" msgid "Passed on" msgstr "" +msgid "Passkey added successfully! Next time you sign in, select the sign-in with passkey option." +msgstr "" + msgid "Passkey deleted" msgstr "" +msgid "Passkey has been deleted!" +msgstr "" + msgid "Passkey registered" msgstr "" diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2700777a9b1bc87682dda898c9479528d13e6dec..b6db87140ec8f1346434e5870f0b46973e680b2d 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2982,6 +2982,18 @@ end end + describe '#get_all_webauthn_credential_ids' do + let_it_be(:user) { create(:user) } + let_it_be(:second_factor_authenticator) { create(:webauthn_registration, user: user) } + let_it_be(:passkey) { create(:webauthn_registration, :passkey, user: user) } + + it 'returns all webauthn credentials ids' do + expect(user.get_all_webauthn_credential_ids).to match_array( + [second_factor_authenticator.credential_xid, passkey.credential_xid] + ) + end + end + describe '#recently_sent_password_reset?' do it 'is false when reset_password_sent_at is nil' do user = build_stubbed(:user, reset_password_sent_at: nil)