diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 46c6b01438f62e8e241312cdaf7f2f555ac26f2c..153a5783604aa4ee6d149bd181324cafadcc5f54 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) @@ -101,11 +116,28 @@ def authenticate_with_two_factor_via_webauthn(user) end end + def authenticate_with_passwordless_authentication_via_passkey + result = Authn::Passkey::AuthenticateService.new( + passwordless_passkey_params[:device_response], + session[:challenge] + ).execute + + if result.success? + handle_passwordless_auth_with_passkey_success(result.payload) + else + handle_passwordless_auth_with_passkey_failure('WebAuthn', result.message) + 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 = if passkey_via_2fa_enabled?(user) + user.get_all_webauthn_credential_ids + else + user.second_factor_webauthn_registrations.pluck(:credential_xid) + end get_options = WebAuthn::Credential.options_for_get( allow: webauthn_registration_ids, @@ -118,6 +150,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 +177,26 @@ 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) + + redirect_to root_path || stored_redirect_uri + end + + def handle_passwordless_auth_with_passkey_failure(method, message) + Gitlab::AppLogger.info( + message: "Failed Login", + login_method: method, + remote_ip: request.remote_ip + ) + + 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 +218,10 @@ def user_password_changed?(user) Digest::SHA256.hexdigest(user.encrypted_password) != session[:user_password_hash] end + + def passkey_via_2fa_enabled?(user) + Feature.enabled?(:passkeys, 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..f5789e78f673dac5892b41dd11b54dd7039aa8f1 100644 --- a/app/controllers/profiles/passkeys_controller.rb +++ b/app/controllers/profiles/passkeys_controller.rb @@ -3,20 +3,43 @@ module Profiles class PasskeysController < Profiles::ApplicationController before_action :check_passkeys_available! + skip_before_action :check_two_factor_requirement + before_action :validate_current_password, + only: [:create, :destroy], + if: :current_password_required? feature_category :system_access + helper_method :current_password_required? + def new - # TODO: Add any needed controller code - render :new + setup_passkey_registration_page end def create - # TODO: Add any needed controller code + result = Authn::Passkey::RegisterService.new( + current_user, + device_registration_params, + session[:challenge] + ).execute + + if result.success? + session.delete(:challenge) + + redirect_to profile_two_factor_auth_path, status: :found, notice: result.message + else + redirect_to profile_two_factor_auth_path, status: :found, alert: result.message + end end def destroy - # TODO: Add any needed controller code + result = Authn::Passkey::DestroyService.new(current_user, current_user, destroy_params[:id]).execute + + if result.success? + redirect_to profile_two_factor_auth_path, status: :found, notice: result.message + else + redirect_to profile_two_factor_auth_path, status: :found, alert: result.message + end end private @@ -24,5 +47,78 @@ def destroy def check_passkeys_available! render_404 unless Feature.enabled?(:passkeys, current_user) end + + def current_password_required? + !current_user.password_automatically_set? && current_user.allow_password_authentication_for_web? + end + + def validate_current_password + return if current_user.valid_password?(validate_password_params[:current_password]) + + current_user.increment_failed_attempts! + + error_message = { message: _('You must provide a valid current password.') } + if validate_password_params[:action] == 'create' + @webauthn_error = error_message + else + @error = error_message + end + + setup_passkey_registration_page + 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 + + def validate_password_params + params.permit(:current_password, :action) + 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..5da6234e4b5891a41bf3a93d7dfc8b32e5edce49 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -200,6 +200,7 @@ def device_registration_params 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 @@ -219,12 +220,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 e21c70e961f34033fcdee73aa8950219fceec1b8..a5dbb7bcc136c9010543270fc69939c81c4c871a 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, 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 3b197ade7a9042501b2a94b56fa8a7954cf06c6e..8c98d56473150a76a29bfe83714f0a80e5ff5b11 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -173,6 +173,7 @@ def update_tracked_fields!(request) has_many :expiring_soon_and_unnotified_personal_access_tokens, -> { expiring_and_not_notified_without_impersonation }, class_name: 'PersonalAccessToken' has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent + has_many :webauthn_registrations has_many :passkeys, -> { passkey }, class_name: 'WebauthnRegistration' has_many :second_factor_webauthn_registrations, -> { second_factor_authenticator }, class_name: 'WebauthnRegistration' has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -1188,6 +1189,10 @@ def ends_with_reserved_file_extension?(username) # Instance methods # + def get_all_webauthn_credential_ids + webauthn_registrations.pluck(:credential_xid) + end + def full_path username end @@ -1364,6 +1369,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/config/routes/user.rb b/config/routes/user.rb index dc98d595e46b3afae4a4d3a69eb078a42028ced9..57eda7cb80ef770ebca5c820f86889140fb6771b 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -58,6 +58,7 @@ 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/fallback_to_email_otp', to: 'sessions#fallback_to_email_otp' + 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 diff --git a/spec/controllers/concerns/authenticates_with_two_factor_spec.rb b/spec/controllers/concerns/authenticates_with_two_factor_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d35a26c4e035e569919027df2d570dfe45e874d3 --- /dev/null +++ b/spec/controllers/concerns/authenticates_with_two_factor_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AuthenticatesWithTwoFactor, :aggregate_failures, feature_category: :system_access do + controller(ActionController::Base) do + include AuthenticatesWithTwoFactor + + def new_passkey + handle_passwordless_flow + end + + def passwordless_passkey_params + permitted_list = [:device_response, :remember_me] + params.permit(permitted_list) + end + end + + let(:user) { create(:user) } + let(:passkey) { create(:webauthn_registration, :passkey, user: user) } + + before do + routes.draw do + post "new_passkey" => "anonymous#new_passkey" + end + + allow(controller).to receive(:add_gon_variables) + allow(controller).to receive(:render).with('devise/sessions/passkeys') + end + + subject(:perform_request) do + post :new_passkey, params: params + end + + describe '#handle_passwordless_flow' do + shared_examples 'prompts the user to authenticate with a passkey' do + it 'calls .prompt_for_passwordless_authentication_via_passkey' do + expect(controller).to receive(:prompt_for_passwordless_authentication_via_passkey) + + perform_request + end + end + + context 'when a device_response is present' do + let(:params) { { device_response: 'test_response' } } + + context 'when a passkey is found' do + before do + allow_next_instance_of(Authn::Passkey::AuthenticateService) do |instance| + allow(instance).to receive(:execute).and_return( + ServiceResponse.success(message: _('Passkey successfully authenticated.'), payload: user) + ) + end + end + + it 'authenticates successfully' do + perform_request + + expect(response).to redirect_to(root_path) + end + end + + context 'when a passkey is not found' do + let(:params) { { device_response: 'invalid_response' } } + + before do + allow_next_instance_of(Authn::Passkey::AuthenticateService) do |instance| + allow(instance).to receive(:execute).and_return( + ServiceResponse.error(message: _('Passkey failed to authenticate.')) + ) + end + end + + it 'renders a flash alert from the backend service' do + perform_request + + expect(flash[:alert]).to eq('Passkey failed to authenticate.') + end + + it 'logs an error' do + expect(Gitlab::AppLogger).to receive(:info) + + perform_request + end + + it_behaves_like 'prompts the user to authenticate with a passkey' + end + end + + context 'when a device_response is not present' do + let(:params) { {} } + + it_behaves_like 'prompts the user to authenticate with a passkey' + end + end +end diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb index 9ee2fa7b1f14a5f192521641b861c6a3a39bb9bc..102248b8939dd900526e300ada2b63b20b96c052 100644 --- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb +++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb @@ -315,9 +315,18 @@ def go def challenge @_challenge ||= begin options_for_create = WebAuthn::Credential.options_for_create( - user: { id: user.webauthn_xid, name: user.username }, - authenticator_selection: { user_verification: 'discouraged' }, - rp: { name: 'GitLab' } + user: { + id: user.webauthn_xid, + name: user.username, + display_name: user.name + }, + exclude: user.get_all_webauthn_credential_ids, + authenticator_selection: { + user_verification: 'discouraged', + resident_key: 'preferred' + }, + rp: { name: 'GitLab' }, + extensions: { credProps: true } ) options_for_create.challenge end @@ -332,6 +341,35 @@ def device_response expect(user.failed_attempts).to be_eql(1) end + context 'when it sets ups its view form' do + it 'renders relevant view variables (2FA & Passkeys)', :freeze_time do + stored_second_factor_webauthn_registration = create(:webauthn_registration, user: user) + stored_passkey = create(:webauthn_registration, :passkey, user: user) + + go + + rendered_second_factor_webauthn_registration = assigns[:registrations].first + rendered_passkey = assigns[:passkeys].first + + expect(assigns[:registrations].count).to eq(user.second_factor_webauthn_registrations.count) + expect(rendered_second_factor_webauthn_registration[:name]).to eq( + stored_second_factor_webauthn_registration.name + ) + expect(rendered_second_factor_webauthn_registration[:created_at]).to eq( + stored_second_factor_webauthn_registration.created_at + ) + expect(rendered_second_factor_webauthn_registration[:delete_path]).to eq( + destroy_webauthn_profile_two_factor_auth_path(stored_second_factor_webauthn_registration) + ) + + expect(assigns[:passkeys].count).to eq(user.passkeys.count) + expect(rendered_passkey[:name]).to eq(stored_passkey.name) + expect(rendered_passkey[:created_at]).to eq(stored_passkey.created_at) + expect(rendered_passkey[:last_used_at]).to eq(stored_passkey.last_used_at) + expect(rendered_passkey[:delete_path]).to eq(profile_passkey_path(stored_passkey)) + end + end + context "when valid password is given" do it "registers and render OTP backup codes" do post :create_webauthn, params: params_with_password diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 2716c9ed03eca5377f995f20055dfcf5e9201a18..9b25b9893cf3f7a6c01e157fa21ac77058277c8e 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -649,7 +649,7 @@ def authenticate_2fa(otp_user_id: user.id, **user_params) end context 'when using two-factor authentication via WebAuthn device' do - let(:user) { create(:user, :two_factor_via_webauthn) } + let(:user) { create(:user, :two_factor_via_webauthn, :with_passkey) } def authenticate_2fa(user_params) post(:create, params: { user: user_params }, session: { otp_user_id: user.id }) @@ -699,6 +699,27 @@ def authenticate_2fa(user_params) change { AuthenticationEvent.count }.by(1)) expect(AuthenticationEvent.last.provider).to eq("two-factor-via-webauthn-device") end + + context 'when the :passkeys Feature Flag is enabled' do + it 'allows both passkeys & second_factor_authenticators to be used for 2FA' do + expect(user).to receive(:get_all_webauthn_credential_ids) + + controller.send(:setup_webauthn_authentication, user) + end + end + + context 'when the :passkeys Feature Flag is disabled' do + before do + stub_feature_flags(passkeys: false) + end + + it 'allows for only second_factor_authenticators to be used for 2FA' do + expect(user).not_to receive(:get_all_webauthn_credential_ids) + expect(user).to receive(:second_factor_webauthn_registrations) + + controller.send(:setup_webauthn_authentication, user) + end + end end context 'when the user is locked and submits a valid verification token' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0c46faebd4cf24f617c85ea9a4abdca24f08aed0..a95e4f5b1fdb0fd7d23feff45aaa4cee4db86a24 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -245,6 +245,7 @@ it { is_expected.to have_many(:notes).dependent(:destroy) } it { is_expected.to have_many(:merge_requests).dependent(:destroy) } it { is_expected.to have_many(:identities).dependent(:destroy) } + it { is_expected.to have_many(:webauthn_registrations) } it { is_expected.to have_many(:passkeys) } it { is_expected.to have_many(:second_factor_webauthn_registrations) } it { is_expected.to have_many(:spam_logs).dependent(:destroy) } @@ -2999,6 +3000,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) diff --git a/spec/requests/profiles/passkeys_controller_spec.rb b/spec/requests/profiles/passkeys_controller_spec.rb index e647945b5d07d0f51bf91931636b5f99dfbfe46d..5e5bd5f297240ed411c43bbbb5535d1c00dd6832 100644 --- a/spec/requests/profiles/passkeys_controller_spec.rb +++ b/spec/requests/profiles/passkeys_controller_spec.rb @@ -3,10 +3,65 @@ require 'spec_helper' RSpec.describe Profiles::PasskeysController, feature_category: :system_access do - let_it_be(:current_user) { create(:user, :with_namespace) } + let_it_be_with_reload(:user) { create(:user, :with_namespace) } before do - sign_in(current_user) + sign_in(user) + + allow(described_class).to receive(:current_user).and_return(user) + end + + shared_examples 'user must enter a valid current password' do + let(:error_message) { { message: _('You must provide a valid current password.') } } + + it 'requires the current password' do + bad + + error = assigns[:error] + expect(error).to eq(error_message) + expect(response).to render_template(:new) + end + + it "validates password attempts" do + expect { bad }.to change { user.failed_attempts }.from(0).to(1) + expect { go }.not_to change { user.failed_attempts } + end + + context 'when user authenticates with an external service' do + before do + allow(user).to receive(:password_automatically_set?).and_return(true) + end + + it 'does not require the current password' do + bad + + expect(assigns[:error]).not_to eq(error_message) + end + end + + context 'when password authentication is disabled' do + before do + stub_application_setting(password_authentication_enabled_for_web: false) + end + + it 'does not require the current password' do + bad + + expect(assigns[:error]).not_to eq(error_message) + end + end + + context 'when the user is an LDAP user' do + before do + allow(user).to receive(:ldap_user?).and_return(true) + end + + it 'does not require the current password' do + bad + + expect(assigns[:error]).not_to eq(error_message) + end + end end shared_examples 'page is found' do @@ -67,19 +122,178 @@ end describe 'POST create' do + let(:client) { WebAuthn::FakeClient.new('http://localhost', encoding: :base64) } # Matches config.encoding + let(:credential) { create_credential(client: client, rp_id: request.host) } + + let(:params) do + { device_registration: { name: '1Password', device_response: device_response }, current_password: 'fake' } + end + + let(:params_with_password) do + { device_registration: { name: 'LastPass', device_response: device_response }, current_password: user.password } + end + before do - post profile_passkeys_path + allow_next_instance_of(Profiles::PasskeysController) do |instance| + allow(instance).to receive(:session).and_return({ + challenge: challenge + }) + end + end + + def bad + post profile_passkeys_path, params: params end - it_behaves_like 'page has no content' + def go + post profile_passkeys_path, params: params_with_password + end + + def challenge + @_challenge ||= begin + options_for_create = WebAuthn::Credential.options_for_create( + user: { + id: user.webauthn_xid, + name: user.username, + display_name: user.name + }, + exclude: user.get_all_webauthn_credential_ids, + authenticator_selection: { + user_verification: 'required', + resident_key: 'required' + }, + rp: { name: 'GitLab' }, + extensions: { credProps: true } + ) + options_for_create.challenge + end + end + + def device_response + client.create(challenge: challenge).to_json # rubocop:disable Rails/SaveBang -- .create is a FakeClient method + end + + context "when valid password is given" do + it "registers and redirects back to the 2FA profile page" do + count = user.passkeys.count + + go + + expect(user.passkeys.count).to be(count + 1) + expect(response).to redirect_to(profile_two_factor_auth_path) + expect(flash[:notice]).to match( + /Passkey added successfully! Next time you sign in, select the sign-in with passkey option./ + ) + end + + context 'when registration fails' do + context "with a service error" do + def challenge + Base64.strict_encode64(SecureRandom.random_bytes(16)) # Throws a challenge error + end + + it "redirects back to the 2FA profile page with an alert" do + go + + expect { response }.not_to change { user.passkeys.count } + expect(response).to redirect_to(profile_two_factor_auth_path) + expect(flash[:alert]).to be_present + end + end + end + end + + context "when valid password is not valid" do + it "renders the passkeys#new page" do + bad + + expect(user.failed_attempts).to be(1) + expect(response).not_to redirect_to(profile_two_factor_auth_path) + expect(response).to render_template(:new) + end + end end describe 'DELETE destroy' do - before do - delete profile_passkey_path(1) + let_it_be_with_reload(:user) do + create(:user, :with_passkey, :with_namespace) + end + + let(:passkey) { user.passkeys.first } + let(:current_password) { user.password } + + def go + delete profile_passkey_path(passkey), params: { current_password: current_password } end - it_behaves_like 'page has no content' + def bad + delete profile_passkey_path(passkey), params: { current_password: 'wrong' } + end + + it_behaves_like 'user must enter a valid current password' + + context "when a valid password is given" do + context 'when authentication succeeds' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(user, :disable_passkey, user) + .and_return(true) + end + + it "redirects back to the 2FA profile page with a backend service notice" do + go + + expect(response).to redirect_to(profile_two_factor_auth_path) + expect(flash[:notice]).to match( + /Passkey has been deleted!/ + ) + end + + it 'destroys the passkey' do + count = user.passkeys.count + + go + + expect(user.passkeys.count).to eq(count - 1) + end + end + + context 'when deletion fails' do + context 'with an unauthorized user' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(user, :disable_passkey, user) + .and_return(false) + end + + it "redirects back to the 2FA profile page with a backend service alert" do + go + + expect(response).to redirect_to(profile_two_factor_auth_path) + expect(flash[:alert]).to match(/You are not authorized to perform this action/) + end + + it 'does not destroy the passkey' do + count = user.passkeys.count + + go + + expect(user.passkeys.count).to eq(count) + end + end + end + end + + context "when valid password is not valid" do + it "renders the passkeys#new page" do + bad + + expect(user.failed_attempts).to be(1) + expect(response).to render_template(:new) + end + end end end end diff --git a/spec/requests/sessions_controller_spec.rb b/spec/requests/sessions_controller_spec.rb index ca04d7d2076465dbe2ae64d345b2847d80adf579..7c2f3f734c6656d71debd1d39e4bda5ac22ec1ee 100644 --- a/spec/requests/sessions_controller_spec.rb +++ b/spec/requests/sessions_controller_spec.rb @@ -44,4 +44,79 @@ include_examples 'set_current_context' end + + describe '#new_passkey' do + shared_examples 'does not call handle_passwordless_flow' do + it 'does not call handle_passwordless_flow' do + expect_next_instance_of(described_class) do |instance| + expect(instance).not_to receive(:handle_passwordless_flow) + end + + perform_request + end + end + + shared_examples 'calls handle_passwordless_flow' do + it 'calls handle_passwordless_flow' do + expect_next_instance_of(described_class) do |instance| + expect(instance).to receive(:handle_passwordless_flow) + end + + perform_request + end + end + + def perform_request + post users_passkeys_sign_in_path + end + + context 'when :passkeys feature flag is off' do + before do + stub_feature_flags(passkeys: false) + end + + it_behaves_like 'does not call handle_passwordless_flow' + end + + context 'when :passkeys feature flag is on' do + it_behaves_like 'calls handle_passwordless_flow' + end + end + + describe 'private methods' do + context 'with .passwordless_passkey_params' do + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:render).with('devise/sessions/passkeys') + end + end + + context 'when parameter sanitization is applied' do + let(:params) do + { + device_response: 'valid_response', + remember_me: '1', + admin: true, + require_two_factor_authentication: false + } + end + + let(:sanitized_params) { controller.send(:passwordless_passkey_params) } + + it 'returns a hash of only permitted scalar keys' do + post users_passkeys_sign_in_path, params: params + + expect(sanitized_params.to_h).to include({ + device_response: 'valid_response', + remember_me: '1' + }) + + expect(sanitized_params.to_h).not_to include({ + admin: true, + require_two_factor_authentication: false + }) + end + end + end + end end diff --git a/spec/support/shared_examples/models/user_shared_examples.rb b/spec/support/shared_examples/models/user_shared_examples.rb index 627339deea81892f9a4d2ef15b5174c9efe30730..f2b684ee3dd6266ab40921128fc1e6b5a5ca1a34 100644 --- a/spec/support/shared_examples/models/user_shared_examples.rb +++ b/spec/support/shared_examples/models/user_shared_examples.rb @@ -12,6 +12,7 @@ gpg_keys emails expiring_soon_and_unnotified_personal_access_tokens + webauthn_registrations second_factor_webauthn_registrations passkeys saved_replies