diff --git a/app/models/user.rb b/app/models/user.rb index 3b2d6faad69be080819a765ddb75974fe3054403..8a9f667bfc7f90b0e3dc9f5345cd0a640944117a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2559,6 +2559,22 @@ def add_admin_note(new_note) self.note = "#{new_note}\n#{self.note}" end + # rubocop: disable CodeReuse/ServiceClass + def support_pin_data + strong_memoize(:support_pin_data) do + Users::SupportPin::RetrieveService.new(self).execute + end + end + # rubocop: enable CodeReuse/ServiceClass + + def support_pin + support_pin_data&.fetch(:pin, nil) + end + + def support_pin_expires_at + support_pin_data&.fetch(:expires_at, nil) + end + protected # override, from Devise::Validatable diff --git a/app/services/users/support_pin/base_service.rb b/app/services/users/support_pin/base_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..cc363c38403a19f10f7faf68977794efb48796e5 --- /dev/null +++ b/app/services/users/support_pin/base_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Users + module SupportPin + class BaseService + SUPPORT_PIN_PREFIX = "support_pin" + SUPPORT_PIN_EXPIRATION = 7.days.from_now + + def initialize(user) + @user = user + end + + def pin_key + "#{SUPPORT_PIN_PREFIX}:#{@user.id}" + end + end + end +end diff --git a/app/services/users/support_pin/retrieve_service.rb b/app/services/users/support_pin/retrieve_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..3e730399137e7f5efe54aad9eed701eaa4081b09 --- /dev/null +++ b/app/services/users/support_pin/retrieve_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Users + module SupportPin + class RetrieveService < SupportPin::BaseService + def execute + Gitlab::Redis::Cache.with do |redis| + key = pin_key + pin = redis.get(key) + expires_at = redis.ttl(key) + + { pin: pin, expires_at: Time.zone.now + expires_at } if pin && expires_at > 0 + end + end + end + end +end diff --git a/app/services/users/support_pin/update_service.rb b/app/services/users/support_pin/update_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..ee716957296fb205d8eaefdda6a1ad844b3a18d1 --- /dev/null +++ b/app/services/users/support_pin/update_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Users + module SupportPin + class UpdateService < SupportPin::BaseService + def execute + pin = generate_pin + expiration = SUPPORT_PIN_EXPIRATION + + if store_pin(pin, expiration) + { status: :success, pin: pin, expires_at: expiration } + else + { status: :error, message: 'Failed to create support PIN' } + end + end + + private + + def generate_pin + SecureRandom.random_number(100000..999999).to_s + end + + def store_pin(pin, expiration) + Gitlab::Redis::Cache.with do |redis| + key = pin_key + redis.set(key, pin) + redis.expireat(key, expiration.to_i) + end + end + end + end +end diff --git a/lib/api/entities/user_support_pin.rb b/lib/api/entities/user_support_pin.rb new file mode 100644 index 0000000000000000000000000000000000000000..11a40e583d34dbd5e68423573280e827e42a0afb --- /dev/null +++ b/lib/api/entities/user_support_pin.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module API + module Entities + class UserSupportPin < Grape::Entity + expose :pin, documentation: { type: 'string', desc: 'The security PIN' } + expose :expires_at, + documentation: { type: 'string', format: 'date-time', desc: 'The expiration time of the PIN' } + end + end +end diff --git a/lib/api/users.rb b/lib/api/users.rb index eb0ca097ca85993b201b83f71bc26c05b50b7de7..fb63981707d5cc2382c63d6b7459a276999a8426 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -101,6 +101,33 @@ def reorder_users(users) end end + desc 'Get support PIN for a user. Available only for admins.' do + detail 'This feature allows administrators to retrieve the support PIN for a specified user' + success Entities::UserSupportPin + is_array false + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + end + get ":id/support_pin", feature_category: :user_management do + authenticated_as_admin! + + user = User.find_by_id(params[:id]) + not_found!('User') unless user + + begin + result = ::Users::SupportPin::RetrieveService.new(user).execute + rescue StandardError + error!("Error retrieving Support PIN for user.", :unprocessable_entity) + end + + if result + present result, with: Entities::UserSupportPin + else + not_found!('Support PIN not found or expired') + end + end + desc 'Get the list of users' do success Entities::UserBasic end @@ -1333,6 +1360,41 @@ def set_user_status(include_missing_params:) end end + desc 'Create a new Support PIN for the authenticated user' do + detail 'This feature creates a temporary Support PIN for the authenticated user' + success Entities::UserSupportPin + end + post "support_pin", feature_category: :user_profile do + authenticate! + + result = ::Users::SupportPin::UpdateService.new(current_user).execute + + if result[:status] == :success + present({ pin: result[:pin], expires_at: result[:expires_at] }, with: Entities::UserSupportPin) + else + error!(result[:message], :unprocessable_entity) + end + end + + desc 'Get the current Support PIN for the authenticated user' do + detail 'This feature retrieves the temporary Support PIN for the authenticated user' + success Entities::UserSupportPin + end + get "support_pin", feature_category: :user_profile do + authenticate! + + result = ::Users::SupportPin::RetrieveService.new(current_user).execute + + if result + # Convert the Time object to ISO 8601 format + expires_at = result[:expires_at].iso8601 + + present({ pin: result[:pin], expires_at: expires_at }, with: Entities::UserSupportPin) + else + not_found!('Support PIN not found or expired') + end + end + desc "Update the current user's preferences" do success Entities::UserPreferences detail 'This feature was introduced in GitLab 13.10.' diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2ea45caa503e708ecddd55b131d366c69ab851af..f11dc7d7c734f3a6e2b440319dbcd6e7bccc62e8 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -9181,4 +9181,62 @@ def owner_class_attribute_default; end expect(user.uploads_sharding_key).to eq({}) end end + + describe 'support pin methods' do + let_it_be(:user_with_pin) { create(:user) } + let_it_be(:user_no_pin) { create(:user) } + let(:pin_data) { { pin: '123456', expires_at: 7.days.from_now } } + let(:retrieve_service) { instance_double(Users::SupportPin::RetrieveService) } + + before do + allow(Users::SupportPin::RetrieveService).to receive(:new).and_return(retrieve_service) + end + + describe '#support_pin' do + it 'returns the pin when it exists' do + allow(retrieve_service).to receive(:execute).and_return(pin_data) + + expect(user_with_pin.support_pin).to eq('123456') + end + + it 'returns nil when no pin exists' do + allow(retrieve_service).to receive(:execute).and_return(nil) + + expect(user_no_pin.support_pin).to be_nil + end + + it 'returns nil when pin key is missing' do + allow(retrieve_service).to receive(:execute).and_return({}) + + expect(user_no_pin.support_pin).to be_nil + end + end + + describe '#support_pin_expires_at' do + it 'returns the expiration time when it exists' do + allow(retrieve_service).to receive(:execute).and_return(pin_data) + + expect(user_with_pin.support_pin_expires_at).to be_within(1.second).of(pin_data[:expires_at]) + end + + it 'returns nil when no expiration time exists' do + allow(retrieve_service).to receive(:execute).and_return(nil) + + expect(user_no_pin.support_pin_expires_at).to be_nil + end + + it 'returns nil when expires_at key is missing' do + allow(retrieve_service).to receive(:execute).and_return({}) + + expect(user_no_pin.support_pin_expires_at).to be_nil + end + end + + it 'only calls the retrieve service once for multiple method calls' do + expect(retrieve_service).to receive(:execute).once.and_return(pin_data) + + user_with_pin.support_pin + user_with_pin.support_pin_expires_at + end + end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 226702b07242216f85d12095981579605664bef5..a704d35ba4aab300b4b13bfbb30fd31ef5242449 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -24,6 +24,7 @@ let(:internal_user) { create(:user, :bot) } let(:user_with_2fa) { create(:user, :two_factor_via_otp) } let(:admin_with_2fa) { create(:admin, :two_factor_via_otp) } + let(:user_without_pin) { create(:user) } context 'admin notes' do let_it_be(:admin) { create(:admin, note: '2019-10-06 | 2FA added | user requested | www.gitlab.com') } @@ -5391,4 +5392,83 @@ def update_password(user, admin, password = User.random_password) let(:attributable) { user } let(:other_attributable) { admin } end + + describe 'POST /api/v4/user/support_pin' do + context 'when authenticated' do + it 'creates a new support PIN' do + post api('/user/support_pin', user) + + expect(response).to have_gitlab_http_status(:created) + expect(json_response).to include('pin', 'expires_at') + end + + it "handles errors when creating a support PIN" do + allow_next_instance_of(Users::SupportPin::UpdateService) do |instance| + allow(instance).to receive(:execute).and_return({ status: :error, message: "Failed to create support PIN" }) + end + post api("/user/support_pin", user) + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response["error"]).to eq("Failed to create support PIN") + end + end + + context 'when not authenticated' do + it 'returns 401 Unauthorized' do + post api('/user/support_pin') + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + describe 'GET /api/v4/user/support_pin' do + context 'when authenticated' do + it 'retrieves the current support PIN' do + get api('/user/support_pin', user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include('pin', 'expires_at') + end + + it 'returns 404 Not Found when no PIN exists' do + get api('/user/support_pin', user_without_pin) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'GET /api/v4/users/:id/support_pin' do + context 'when authenticated as admin' do + it 'retrieves the support PIN for a user' do + get api("/users/#{user.id}/support_pin", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include('pin', 'expires_at') + end + + it 'returns 404 Not Found when no PIN exists' do + get api("/users/#{user_without_pin.id}/support_pin", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it "handles errors when retrieving the support PIN" do + allow_next_instance_of(Users::SupportPin::RetrieveService) do |instance| + allow(instance).to receive(:execute).and_raise(StandardError) + end + get api("/users/#{user.id}/support_pin", admin, admin_mode: true) + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response["error"]).to eq("Error retrieving Support PIN for user.") + end + end + + context 'when authenticated as non-admin' do + it 'returns 403 Forbidden' do + get api("/users/#{user.id}/support_pin", user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end end diff --git a/spec/services/users/support_pin/retrieve_service_spec.rb b/spec/services/users/support_pin/retrieve_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e867e87fd5c04d552f4057c70192b913d2997988 --- /dev/null +++ b/spec/services/users/support_pin/retrieve_service_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::SupportPin::RetrieveService, feature_category: :user_management do + let(:user) { create(:user) } + let(:service) { described_class.new(user) } + + describe '#execute' do + context 'when a PIN exists' do + let!(:pin) { Users::SupportPin::UpdateService.new(user).execute[:pin] } + + it 'retrieves the existing PIN' do + result = service.execute + + expect(result[:pin]).to eq(pin) + expect(result[:expires_at]).to be_within(1.minute).of(described_class::SUPPORT_PIN_EXPIRATION) + end + end + + context 'when no PIN exists' do + it 'returns nil' do + expect(service.execute).to be_nil + end + end + end +end diff --git a/spec/services/users/support_pin/update_service_spec.rb b/spec/services/users/support_pin/update_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e0e19990533d4df9117b5994d7e2e89589f711f9 --- /dev/null +++ b/spec/services/users/support_pin/update_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::SupportPin::UpdateService, feature_category: :user_management do + let(:user) { create(:user) } + let(:service) { described_class.new(user) } + + describe '#execute' do + it 'creates a new support PIN' do + result = service.execute + + expect(result[:status]).to eq(:success) + expect(result[:pin]).to match(/^\d{6}$/) + expect(result[:expires_at]).to be_within(1.minute).of(described_class::SUPPORT_PIN_EXPIRATION) + end + + it 'stores the PIN in Redis' do + result = service.execute + key = "support_pin:#{user.id}" + pin = result[:pin] + + Gitlab::Redis::Cache.with do |redis| + expect(redis.get(key)).to eq(pin) + end + end + + it "returns an error when storing the PIN fails" do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:store_pin).and_return(false) + end + result = described_class.new(user).execute + expect(result).to eq({ status: :error, message: 'Failed to create support PIN' }) + end + end +end