Commit a23c5a4c authored by Arturo Herrero's avatar Arturo Herrero

Merge branch 'if-299088-git_2fa_to_ee' into 'master'

Move OTP for git over SSH to EE

See merge request gitlab-org/gitlab!53166
parents 9a798c8d ffb72bba
......@@ -110,9 +110,10 @@ Each scenario can be a third-level heading, e.g. `### Getting error message X`.
If you have none to add when creating a doc, leave this section in place
but commented out to help encourage others to add to it in the future. -->
## Two-factor Authentication (2FA) for Git over SSH operations
## Two-factor Authentication (2FA) for Git over SSH operations **(PREMIUM)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/270554) in GitLab 13.7.
> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/299088) from GitLab Free to GitLab Premium in 13.9.
> - It's [deployed behind a feature flag](../user/feature_flags.md), disabled by default.
> - It's disabled on GitLab.com.
> - It's not recommended for production use.
......
......@@ -22,6 +22,33 @@ module EE
super
end
end
override :two_factor_otp_check
def two_factor_otp_check
return { success: false, message: 'Feature is not available' } unless ::License.feature_available?(:git_two_factor_enforcement)
return { success: false, message: 'Feature flag is disabled' } unless ::Feature.enabled?(:two_factor_for_cli)
actor.update_last_used_at!
user = actor.user
error_message = validate_actor_key(actor, params[:key_id])
return { success: false, message: error_message } if error_message
return { success: false, message: 'Deploy keys cannot be used for Two Factor' } if actor.key.is_a?(DeployKey)
return { success: false, message: 'Two-factor authentication is not enabled for this user' } unless user.two_factor_enabled?
otp_validation_result = ::Users::ValidateOtpService.new(user).execute(params.fetch(:otp_attempt))
if otp_validation_result[:status] == :success
::Gitlab::Auth::Otp::SessionEnforcer.new(actor.key).update_session
{ success: true }
else
{ success: false, message: 'Invalid OTP' }
end
end
end
end
end
......
......@@ -13,6 +13,7 @@ module EE
check_maintenance_mode!(cmd)
check_geo_license!
check_smartcard_access!
check_otp_session!
super
end
......@@ -94,6 +95,32 @@ module EE
end
end
def check_otp_session!
return unless ::License.feature_available?(:git_two_factor_enforcement)
return unless ::Feature.enabled?(:two_factor_for_cli)
return unless ssh?
return if !key? || deploy_key?
return unless user.two_factor_enabled?
if ::Gitlab::Auth::Otp::SessionEnforcer.new(actor).access_restricted?
message = "OTP verification is required to access the repository.\n\n"\
" Use: #{build_ssh_otp_verify_command}"
raise ::Gitlab::GitAccess::ForbiddenError, message
end
end
def build_ssh_otp_verify_command
user = "#{::Gitlab.config.gitlab_shell.ssh_user}@" unless ::Gitlab.config.gitlab_shell.ssh_user.empty?
user_host = "#{user}#{::Gitlab.config.gitlab_shell.ssh_host}"
if ::Gitlab.config.gitlab_shell.ssh_port != 22
"ssh #{user_host} -p #{::Gitlab.config.gitlab_shell.ssh_port} 2fa_verify"
else
"ssh #{user_host} 2fa_verify"
end
end
def check_maintenance_mode!(cmd)
return unless cmd == 'git-receive-pack'
return unless ::Gitlab.maintenance_mode?
......
......@@ -6,8 +6,13 @@ RSpec.describe Gitlab::Auth::Otp::SessionEnforcer, :clean_gitlab_redis_shared_st
let_it_be(:key) { create(:key)}
describe '#update_session' do
let(:redis) { double(:redis) }
before do
stub_licensed_features(git_two_factor_enforcement: true)
end
it 'registers a session in Redis' do
redis = double(:redis)
expect(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis)
session_expiry_in_seconds = Gitlab::CurrentSettings.git_two_factor_session_expiry.minutes.to_i
......@@ -20,11 +25,27 @@ RSpec.describe Gitlab::Auth::Otp::SessionEnforcer, :clean_gitlab_redis_shared_st
described_class.new(key).update_session
end
context 'when licensed feature is not available' do
before do
stub_licensed_features(git_two_factor_enforcement: false)
end
it 'does not register a session in Redis' do
expect(redis).not_to receive(:setex)
described_class.new(key).update_session
end
end
end
describe '#access_restricted?' do
subject { described_class.new(key).access_restricted? }
before do
stub_licensed_features(git_two_factor_enforcement: true)
end
context 'with existing session' do
before do
Gitlab::Redis::SharedState.with do |redis|
......
......@@ -745,6 +745,176 @@ RSpec.describe Gitlab::GitAccess do
end
end
describe '#check_otp_session!' do
let_it_be(:user) { create(:user, :two_factor_via_otp)}
let_it_be(:key) { create(:key, user: user) }
let_it_be(:actor) { key }
let(:protocol) { 'ssh' }
before do
project.add_developer(user)
stub_feature_flags(two_factor_for_cli: true)
stub_licensed_features(git_two_factor_enforcement: true)
end
context 'with an OTP session', :clean_gitlab_redis_shared_state do
before do
Gitlab::Redis::SharedState.with do |redis|
redis.set("#{Gitlab::Auth::Otp::SessionEnforcer::OTP_SESSIONS_NAMESPACE}:#{key.id}", true)
end
end
it 'allows push and pull access' do
aggregate_failures do
expect { push_changes }.not_to raise_error
expect { pull_changes }.not_to raise_error
end
end
context 'based on the duration set by the `git_two_factor_session_expiry` setting' do
let_it_be(:git_two_factor_session_expiry) { 20 }
let_it_be(:redis_key_expiry_at) { git_two_factor_session_expiry.minutes.from_now }
before do
stub_application_setting(git_two_factor_session_expiry: git_two_factor_session_expiry)
end
def value_of_key
key_expired = Time.current > redis_key_expiry_at
return if key_expired
true
end
def stub_redis
redis = double(:redis)
expect(Gitlab::Redis::SharedState).to receive(:with).at_most(:twice).and_yield(redis)
expect(redis).to(
receive(:get)
.with("#{Gitlab::Auth::Otp::SessionEnforcer::OTP_SESSIONS_NAMESPACE}:#{key.id}"))
.at_most(:twice)
.and_return(value_of_key)
end
context 'at a time before the stipulated expiry' do
it 'allows push and pull access' do
travel_to(10.minutes.from_now) do
stub_redis
aggregate_failures do
expect { push_changes }.not_to raise_error
expect { pull_changes }.not_to raise_error
end
end
end
end
context 'at a time after the stipulated expiry' do
it 'does not allow push and pull access' do
travel_to(30.minutes.from_now) do
stub_redis
aggregate_failures do
expect { push_changes }.to raise_error(::Gitlab::GitAccess::ForbiddenError)
expect { pull_changes }.to raise_error(::Gitlab::GitAccess::ForbiddenError)
end
end
end
end
end
end
context 'without OTP session' do
it 'does not allow push or pull access' do
user = 'jane.doe'
host = 'fridge.ssh'
port = 42
stub_config(
gitlab_shell: {
ssh_user: user,
ssh_host: host,
ssh_port: port
}
)
error_message = "OTP verification is required to access the repository.\n\n"\
" Use: ssh #{user}@#{host} -p #{port} 2fa_verify"
aggregate_failures do
expect { push_changes }.to raise_forbidden(error_message)
expect { pull_changes }.to raise_forbidden(error_message)
end
end
context 'when protocol is HTTP' do
let(:protocol) { 'http' }
it 'allows push and pull access' do
aggregate_failures do
expect { push_changes }.not_to raise_error
expect { pull_changes }.not_to raise_error
end
end
end
context 'when actor is not an SSH key' do
let(:deploy_key) { create(:deploy_key, user: user) }
let(:actor) { deploy_key }
before do
deploy_key.deploy_keys_projects.create(project: project, can_push: true)
end
it 'allows push and pull access' do
aggregate_failures do
expect { push_changes }.not_to raise_error
expect { pull_changes }.not_to raise_error
end
end
end
context 'when 2FA is not enabled for the user' do
let(:user) { create(:user)}
let(:actor) { create(:key, user: user) }
it 'allows push and pull access' do
aggregate_failures do
expect { push_changes }.not_to raise_error
expect { pull_changes }.not_to raise_error
end
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(two_factor_for_cli: false)
end
it 'allows push and pull access' do
aggregate_failures do
expect { push_changes }.not_to raise_error
expect { pull_changes }.not_to raise_error
end
end
end
context 'when licensed feature is not available' do
before do
stub_licensed_features(git_two_factor_enforcement: false)
end
it 'allows push and pull access' do
aggregate_failures do
expect { push_changes }.not_to raise_error
expect { pull_changes }.not_to raise_error
end
end
end
end
end
describe '#check_maintenance_mode!' do
let(:changes) { Gitlab::GitAccess::ANY }
......
......@@ -10,11 +10,11 @@ RSpec.describe API::Internal::Base do
let_it_be(:primary_node, reload: true) { create(:geo_node, :primary, url: primary_url) }
let_it_be(:secondary_node, reload: true) { create(:geo_node, url: secondary_url) }
let_it_be(:user) { create(:user) }
let(:secret_token) { Gitlab::Shell.secret_token }
describe 'POST /internal/post_receive', :geo do
let(:key) { create(:key, user: user) }
let_it_be(:project, reload: true) { create(:project, :repository, :wiki_repo) }
let(:secret_token) { Gitlab::Shell.secret_token }
let(:gl_repository) { "project-#{project.id}" }
let(:reference_counter) { double('ReferenceCounter') }
......@@ -74,7 +74,6 @@ RSpec.describe API::Internal::Base do
describe "POST /internal/allowed" do
let_it_be(:key) { create(:key, user: user) }
let(:secret_token) { Gitlab::Shell.secret_token }
context "project alias" do
let(:project) { create(:project, :public, :repository) }
......@@ -279,7 +278,6 @@ RSpec.describe API::Internal::Base do
describe "POST /internal/lfs_authenticate", :geo do
let(:project) { create(:project, :repository) }
let(:secret_token) { Gitlab::Shell.secret_token }
context 'for a secondary node' do
before do
......@@ -312,9 +310,7 @@ RSpec.describe API::Internal::Base do
describe 'POST /internal/personal_access_token' do
let_it_be(:key) { create(:key, user: user) }
let(:instance_level_max_personal_access_token_lifetime) { nil }
let(:secret_token) { Gitlab::Shell.secret_token }
before do
stub_licensed_features(personal_access_token_expiration_policy: !!instance_level_max_personal_access_token_lifetime)
......@@ -362,4 +358,120 @@ RSpec.describe API::Internal::Base do
end
end
end
describe 'POST /internal/two_factor_otp_check' do
let_it_be(:key) { create(:key, user: user) }
let(:key_id) { key.id }
let(:otp) { '123456'}
before do
stub_feature_flags(two_factor_for_cli: true)
stub_licensed_features(git_two_factor_enforcement: true)
end
subject do
post api('/internal/two_factor_otp_check'),
params: {
secret_token: secret_token,
key_id: key_id,
otp_attempt: otp
}
end
it_behaves_like 'actor key validations'
context 'when the key is a deploy key' do
let(:key_id) { create(:deploy_key).id }
it 'returns an error message' do
subject
expect(json_response['success']).to be_falsey
expect(json_response['message']).to eq('Deploy keys cannot be used for Two Factor')
end
end
context 'when the two factor is enabled' do
before do
allow_any_instance_of(User).to receive(:two_factor_enabled?).and_return(true) # rubocop:disable RSpec/AnyInstanceOf
end
context 'when the OTP is valid' do
it 'registers a new OTP session and returns success' do
allow_next_instance_of(Users::ValidateOtpService) do |service|
allow(service).to receive(:execute).with(otp).and_return(status: :success)
end
expect_next_instance_of(::Gitlab::Auth::Otp::SessionEnforcer) do |session_enforcer|
expect(session_enforcer).to receive(:update_session).once
end
subject
expect(json_response['success']).to be_truthy
end
end
context 'when the OTP is invalid' do
it 'is not success' do
allow_next_instance_of(Users::ValidateOtpService) do |service|
allow(service).to receive(:execute).with(otp).and_return(status: :error)
end
subject
expect(json_response['success']).to be_falsey
end
end
end
context 'when the two factor is disabled' do
before do
allow_any_instance_of(User).to receive(:two_factor_enabled?).and_return(false) # rubocop:disable RSpec/AnyInstanceOf
end
it 'returns an error message' do
subject
expect(json_response['success']).to be_falsey
expect(json_response['message']).to eq 'Two-factor authentication is not enabled for this user'
end
end
context 'feature flag is disabled' do
before do
stub_feature_flags(two_factor_for_cli: false)
end
context 'when two-factor is enabled for the user' do
it 'returns user two factor config' do
allow_next_instance_of(User) do |instance|
allow(instance).to receive(:two_factor_enabled?).and_return(true)
end
subject
expect(json_response['success']).to be_falsey
end
end
end
context 'licensed feature is not available' do
before do
stub_licensed_features(git_two_factor_enforcement: false)
end
context 'when two-factor is enabled for the user' do
it 'returns user two factor config' do
allow_next_instance_of(User) do |instance|
allow(instance).to receive(:two_factor_enabled?).and_return(true)
end
subject
expect(json_response['success']).to be_falsey
end
end
end
end
end
......@@ -116,6 +116,10 @@ module API
'Could not find a user for the given key' unless actor.user
end
def two_factor_otp_check
{ success: false, message: 'Feature is not available' }
end
end
namespace 'internal' do
......@@ -278,6 +282,11 @@ module API
present response, with: Entities::InternalPostReceive::Response
end
# This endpoint was added in https://gitlab.com/gitlab-org/gitlab/-/issues/212308
# It was added with the plan to be used by GitLab PAM module but we
# decided to pursue a different approach, so it's currently not used.
# We might revive the PAM module though as it provides better user
# flow.
post '/two_factor_config', feature_category: :authentication_and_authorization do
status 200
......@@ -303,28 +312,7 @@ module API
post '/two_factor_otp_check', feature_category: :authentication_and_authorization do
status 200
break { success: false, message: 'Feature flag is disabled' } unless Feature.enabled?(:two_factor_for_cli)
actor.update_last_used_at!
user = actor.user
error_message = validate_actor_key(actor, params[:key_id])
break { success: false, message: error_message } if error_message
break { success: false, message: 'Deploy keys cannot be used for Two Factor' } if actor.key.is_a?(DeployKey)
break { success: false, message: 'Two-factor authentication is not enabled for this user' } unless user.two_factor_enabled?
otp_validation_result = ::Users::ValidateOtpService.new(user).execute(params.fetch(:otp_attempt))
if otp_validation_result[:status] == :success
::Gitlab::Auth::Otp::SessionEnforcer.new(actor.key).update_session
{ success: true }
else
{ success: false, message: 'Invalid OTP' }
end
two_factor_otp_check
end
end
end
......
......@@ -77,7 +77,6 @@ module Gitlab
check_authentication_abilities!
check_command_disabled!
check_command_existence!
check_otp_session!
custom_action = check_custom_action
return custom_action if custom_action
......@@ -255,31 +254,6 @@ module Gitlab
end
end
def check_otp_session!
return unless ssh?
return if !key? || deploy_key?
return unless Feature.enabled?(:two_factor_for_cli)
return unless user.two_factor_enabled?
if ::Gitlab::Auth::Otp::SessionEnforcer.new(actor).access_restricted?
message = "OTP verification is required to access the repository.\n\n"\
" Use: #{build_ssh_otp_verify_command}"
raise ForbiddenError, message
end
end
def build_ssh_otp_verify_command
user = "#{Gitlab.config.gitlab_shell.ssh_user}@" unless Gitlab.config.gitlab_shell.ssh_user.empty?
user_host = "#{user}#{Gitlab.config.gitlab_shell.ssh_host}"
if Gitlab.config.gitlab_shell.ssh_port != 22
"ssh #{user_host} -p #{Gitlab.config.gitlab_shell.ssh_port} 2fa_verify"
else
"ssh #{user_host} 2fa_verify"
end
end
def check_db_accessibility!
return unless receive_pack?
......
......@@ -388,161 +388,6 @@ RSpec.describe Gitlab::GitAccess do
end
end
describe '#check_otp_session!' do
let_it_be(:user) { create(:user, :two_factor_via_otp)}
let_it_be(:key) { create(:key, user: user) }
let_it_be(:actor) { key }
before do
project.add_developer(user)
stub_feature_flags(two_factor_for_cli: true)
end
context 'with an OTP session', :clean_gitlab_redis_shared_state do
before do
Gitlab::Redis::SharedState.with do |redis|
redis.set("#{Gitlab::Auth::Otp::SessionEnforcer::OTP_SESSIONS_NAMESPACE}:#{key.id}", true)
end
end
it 'allows push and pull access' do
aggregate_failures do
expect { push_access_check }.not_to raise_error
expect { pull_access_check }.not_to raise_error
end
end
context 'based on the duration set by the `git_two_factor_session_expiry` setting' do
let_it_be(:git_two_factor_session_expiry) { 20 }
let_it_be(:redis_key_expiry_at) { git_two_factor_session_expiry.minutes.from_now }
before do
stub_application_setting(git_two_factor_session_expiry: git_two_factor_session_expiry)
end
def value_of_key
key_expired = Time.current > redis_key_expiry_at
return if key_expired
true
end
def stub_redis
redis = double(:redis)
expect(Gitlab::Redis::SharedState).to receive(:with).at_most(:twice).and_yield(redis)
expect(redis).to(
receive(:get)
.with("#{Gitlab::Auth::Otp::SessionEnforcer::OTP_SESSIONS_NAMESPACE}:#{key.id}"))
.at_most(:twice)
.and_return(value_of_key)
end
context 'at a time before the stipulated expiry' do
it 'allows push and pull access' do
travel_to(10.minutes.from_now) do
stub_redis
aggregate_failures do
expect { push_access_check }.not_to raise_error
expect { pull_access_check }.not_to raise_error
end
end
end
end
context 'at a time after the stipulated expiry' do
it 'does not allow push and pull access' do
travel_to(30.minutes.from_now) do
stub_redis
aggregate_failures do
expect { push_access_check }.to raise_error
expect { pull_access_check }.to raise_error
end
end
end
end
end
end
context 'without OTP session' do
it 'does not allow push or pull access' do
user = 'jane.doe'
host = 'fridge.ssh'
port = 42
stub_config(
gitlab_shell: {
ssh_user: user,
ssh_host: host,
ssh_port: port
}
)
error_message = "OTP verification is required to access the repository.\n\n"\
" Use: ssh #{user}@#{host} -p #{port} 2fa_verify"
aggregate_failures do
expect { push_access_check }.to raise_forbidden(error_message)
expect { pull_access_check }.to raise_forbidden(error_message)
end
end
context 'when protocol is HTTP' do
let(:protocol) { 'http' }
it 'allows push and pull access' do
aggregate_failures do
expect { push_access_check }.not_to raise_error
expect { pull_access_check }.not_to raise_error
end
end
end
context 'when actor is not an SSH key' do
let(:deploy_key) { create(:deploy_key, user: user) }
let(:actor) { deploy_key }
before do
deploy_key.deploy_keys_projects.create(project: project, can_push: true)
end
it 'allows push and pull access' do
aggregate_failures do
expect { push_access_check }.not_to raise_error
expect { pull_access_check }.not_to raise_error
end
end
end
context 'when 2FA is not enabled for the user' do
let(:user) { create(:user)}
let(:actor) { create(:key, user: user) }
it 'allows push and pull access' do
aggregate_failures do
expect { push_access_check }.not_to raise_error
expect { pull_access_check }.not_to raise_error
end
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(two_factor_for_cli: false)
end
it 'allows push and pull access' do
aggregate_failures do
expect { push_access_check }.not_to raise_error
expect { pull_access_check }.not_to raise_error
end
end
end
end
end
describe '#check_db_accessibility!' do
context 'when in a read-only GitLab instance' do
before do
......
......@@ -50,41 +50,6 @@ RSpec.describe API::Internal::Base do
end
end
shared_examples 'actor key validations' do
context 'key id is not provided' do
let(:key_id) { nil }
it 'returns an error message' do
subject
expect(json_response['success']).to be_falsey
expect(json_response['message']).to eq('Could not find a user without a key')
end
end
context 'key does not exist' do
let(:key_id) { non_existing_record_id }
it 'returns an error message' do
subject
expect(json_response['success']).to be_falsey
expect(json_response['message']).to eq('Could not find the given key')
end
end
context 'key without user' do
let(:key_id) { create(:key, user: nil).id }
it 'returns an error message' do
subject
expect(json_response['success']).to be_falsey
expect(json_response['message']).to eq('Could not find a user for the given key')
end
end
end
describe 'GET /internal/two_factor_recovery_codes' do
let(:key_id) { key.id }
......@@ -1406,10 +1371,6 @@ RSpec.describe API::Internal::Base do
let(:key_id) { key.id }
let(:otp) { '123456'}
before do
stub_feature_flags(two_factor_for_cli: true)
end
subject do
post api('/internal/two_factor_otp_check'),
params: {
......@@ -1419,76 +1380,10 @@ RSpec.describe API::Internal::Base do
}
end
it_behaves_like 'actor key validations'
context 'when the key is a deploy key' do
let(:key_id) { create(:deploy_key).id }
it 'returns an error message' do
subject
expect(json_response['success']).to be_falsey
expect(json_response['message']).to eq('Deploy keys cannot be used for Two Factor')
end
end
context 'when the two factor is enabled' do
before do
allow_any_instance_of(User).to receive(:two_factor_enabled?).and_return(true)
end
context 'when the OTP is valid' do
it 'registers a new OTP session and returns success' do
allow_any_instance_of(Users::ValidateOtpService).to receive(:execute).with(otp).and_return(status: :success)
expect_next_instance_of(::Gitlab::Auth::Otp::SessionEnforcer) do |session_enforcer|
expect(session_enforcer).to receive(:update_session).once
end
subject
expect(json_response['success']).to be_truthy
end
end
context 'when the OTP is invalid' do
it 'is not success' do
allow_any_instance_of(Users::ValidateOtpService).to receive(:execute).with(otp).and_return(status: :error)
subject
expect(json_response['success']).to be_falsey
end
end
end
context 'when the two factor is disabled' do
before do
allow_any_instance_of(User).to receive(:two_factor_enabled?).and_return(false)
end
it 'is not available' do
subject
it 'returns an error message' do
subject
expect(json_response['success']).to be_falsey
expect(json_response['message']).to eq 'Two-factor authentication is not enabled for this user'
end
end
context 'two_factor_for_cli feature is disabled' do
before do
stub_feature_flags(two_factor_for_cli: false)
end
context 'when two-factor is enabled for the user' do
it 'returns user two factor config' do
allow_any_instance_of(User).to receive(:two_factor_enabled?).and_return(true)
subject
expect(json_response['success']).to be_falsey
end
end
expect(json_response['success']).to be_falsey
end
end
......
# frozen_string_literal: true
RSpec.shared_examples 'actor key validations' do
context 'key id is not provided' do
let(:key_id) { nil }
it 'returns an error message' do
subject
expect(json_response['success']).to be_falsey
expect(json_response['message']).to eq('Could not find a user without a key')
end
end
context 'key does not exist' do
let(:key_id) { non_existing_record_id }
it 'returns an error message' do
subject
expect(json_response['success']).to be_falsey
expect(json_response['message']).to eq('Could not find the given key')
end
end
context 'key without user' do
let(:key_id) { create(:key, user: nil).id }
it 'returns an error message' do
subject
expect(json_response['success']).to be_falsey
expect(json_response['message']).to eq('Could not find a user for the given key')
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment