Merge branch '27520-option-to-prevent-signing-in-from-multiple-ips' into 'master'

GitLab should have an option to prevent users from signing in from multiple IPs

Closes #27520

See merge request !8998
This commit is contained in:
Sean McGivern 2017-03-06 16:22:46 +00:00
commit a4dd579261
22 changed files with 368 additions and 20 deletions

View File

@ -138,6 +138,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:two_factor_grace_period,
:user_default_external,
:user_oauth_applications,
:unique_ips_limit_per_user,
:unique_ips_limit_time_window,
:unique_ips_limit_enabled,
:version_check_enabled,
:terminal_max_session_time,

View File

@ -40,6 +40,10 @@ class ApplicationController < ActionController::Base
render_403
end
rescue_from Gitlab::Auth::TooManyIps do |e|
head :forbidden, retry_after: Gitlab::Auth::UniqueIpsLimiter.config.unique_ips_limit_time_window
end
def redirect_back_or_default(default: root_path, options: {})
redirect_to request.referer.present? ? :back : default, options
end

View File

@ -64,6 +64,16 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
if: :akismet_enabled
validates :unique_ips_limit_per_user,
numericality: { greater_than_or_equal_to: 1 },
presence: true,
if: :unique_ips_limit_enabled
validates :unique_ips_limit_time_window,
numericality: { greater_than_or_equal_to: 0 },
presence: true,
if: :unique_ips_limit_enabled
validates :koding_url,
presence: true,
if: :koding_enabled
@ -184,6 +194,9 @@ class ApplicationSetting < ActiveRecord::Base
domain_whitelist: Settings.gitlab['domain_whitelist'],
gravatar_enabled: Settings.gravatar['enabled'],
help_page_text: nil,
unique_ips_limit_per_user: 10,
unique_ips_limit_time_window: 3600,
unique_ips_limit_enabled: false,
housekeeping_bitmaps_enabled: true,
housekeeping_enabled: true,
housekeeping_full_repack_period: 50,

View File

@ -360,6 +360,29 @@
Generate API key at
%a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :unique_ips_limit_enabled do
= f.check_box :unique_ips_limit_enabled
Limit sign in from multiple ips
%span.help-block#unique_ip_help_block
Helps prevent malicious users hide their activity
.form-group
= f.label :unique_ips_limit_per_user, 'IPs per user', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :unique_ips_limit_per_user, class: 'form-control'
.help-block
Maximum number of unique IPs per user
.form-group
= f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :unique_ips_limit_time_window, class: 'form-control'
.help-block
How many seconds an IP will be counted towards the limit
%fieldset
%legend Abuse reports
.form-group

View File

@ -0,0 +1,4 @@
---
title: Option to prevent signing in from multiple ips
merge_request: 8998
author:

View File

@ -7,6 +7,7 @@ Bundler.require(:default, Rails.env)
module Gitlab
class Application < Rails::Application
require_dependency Rails.root.join('lib/gitlab/redis')
require_dependency Rails.root.join('lib/gitlab/request_context')
# Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers

View File

@ -0,0 +1,3 @@
Rails.application.configure do |config|
config.middleware.insert_after RequestStore::Middleware, Gitlab::RequestContext
end

View File

@ -0,0 +1,5 @@
Rails.application.configure do |config|
Warden::Manager.after_set_user do |user, auth, opts|
Gitlab::Auth::UniqueIpsLimiter.limit_user!(user)
end
end

View File

@ -0,0 +1,17 @@
class AddUniqueIpsLimitToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column :application_settings, :unique_ips_limit_per_user, :integer
add_column :application_settings, :unique_ips_limit_time_window, :integer
add_column_with_default :application_settings, :unique_ips_limit_enabled, :boolean, default: false
end
def down
remove_column :application_settings, :unique_ips_limit_per_user
remove_column :application_settings, :unique_ips_limit_time_window
remove_column :application_settings, :unique_ips_limit_enabled
end
end

View File

@ -112,6 +112,9 @@ ActiveRecord::Schema.define(version: 20170305203726) do
t.integer "max_pages_size", default: 100, null: false
t.integer "terminal_max_session_time", default: 0, null: false
t.string "default_artifacts_expire_in", default: "0", null: false
t.integer "unique_ips_limit_per_user"
t.integer "unique_ips_limit_time_window"
t.boolean "unique_ips_limit_enabled", default: false, null: false
end
create_table "audit_events", force: :cascade do |t|

View File

@ -60,6 +60,10 @@ module API
error! e.message, e.status, e.headers
end
rescue_from Gitlab::Auth::TooManyIps do |e|
rack_response({ 'message' => '403 Forbidden' }.to_json, 403)
end
rescue_from :all do |exception|
handle_api_exception(exception)
end

View File

@ -336,16 +336,17 @@ module API
def initial_current_user
return @initial_current_user if defined?(@initial_current_user)
Gitlab::Auth::UniqueIpsLimiter.limit_user! do
@initial_current_user ||= find_user_by_private_token(scopes: @scopes)
@initial_current_user ||= doorkeeper_guard(scopes: @scopes)
@initial_current_user ||= find_user_from_warden
@initial_current_user ||= find_user_by_private_token(scopes: @scopes)
@initial_current_user ||= doorkeeper_guard(scopes: @scopes)
@initial_current_user ||= find_user_from_warden
unless @initial_current_user && Gitlab::UserAccess.new(@initial_current_user).allowed?
@initial_current_user = nil
end
unless @initial_current_user && Gitlab::UserAccess.new(@initial_current_user).allowed?
@initial_current_user = nil
@initial_current_user
end
@initial_current_user
end
def sudo!

View File

@ -23,22 +23,25 @@ module Gitlab
Gitlab::Auth::Result.new
rate_limit!(ip, success: result.success?, login: login)
Gitlab::Auth::UniqueIpsLimiter.limit_user!(result.actor)
result
end
def find_with_user_password(login, password)
user = User.by_login(login)
Gitlab::Auth::UniqueIpsLimiter.limit_user! do
user = User.by_login(login)
# If no user is found, or it's an LDAP server, try LDAP.
# LDAP users are only authenticated via LDAP
if user.nil? || user.ldap_user?
# Second chance - try LDAP authentication
return nil unless Gitlab::LDAP::Config.enabled?
# If no user is found, or it's an LDAP server, try LDAP.
# LDAP users are only authenticated via LDAP
if user.nil? || user.ldap_user?
# Second chance - try LDAP authentication
return nil unless Gitlab::LDAP::Config.enabled?
Gitlab::LDAP::Authentication.login(login, password)
else
user if user.valid_password?(password)
Gitlab::LDAP::Authentication.login(login, password)
else
user if user.valid_password?(password)
end
end
end

View File

@ -0,0 +1,17 @@
module Gitlab
module Auth
class TooManyIps < StandardError
attr_reader :user_id, :ip, :unique_ips_count
def initialize(user_id, ip, unique_ips_count)
@user_id = user_id
@ip = ip
@unique_ips_count = unique_ips_count
end
def message
"User #{user_id} from IP: #{ip} tried logging from too many ips: #{unique_ips_count}"
end
end
end
end

View File

@ -0,0 +1,43 @@
module Gitlab
module Auth
class UniqueIpsLimiter
USER_UNIQUE_IPS_PREFIX = 'user_unique_ips'.freeze
class << self
def limit_user_id!(user_id)
if config.unique_ips_limit_enabled
ip = RequestContext.client_ip
unique_ips = update_and_return_ips_count(user_id, ip)
raise TooManyIps.new(user_id, ip, unique_ips) if unique_ips > config.unique_ips_limit_per_user
end
end
def limit_user!(user = nil)
user ||= yield if block_given?
limit_user_id!(user.id) unless user.nil?
user
end
def config
Gitlab::CurrentSettings.current_application_settings
end
def update_and_return_ips_count(user_id, ip)
time = Time.now.utc.to_i
key = "#{USER_UNIQUE_IPS_PREFIX}:#{user_id}"
Gitlab::Redis.with do |redis|
unique_ips_count = nil
redis.multi do |r|
r.zadd(key, time, ip)
r.zremrangebyscore(key, 0, time - config.unique_ips_limit_time_window)
unique_ips_count = r.zcard(key)
end
unique_ips_count.value
end
end
end
end
end
end

View File

@ -0,0 +1,21 @@
module Gitlab
class RequestContext
class << self
def client_ip
RequestStore[:client_ip]
end
end
def initialize(app)
@app = app
end
def call(env)
req = Rack::Request.new(env)
RequestStore[:client_ip] = req.ip
@app.call(env)
end
end
end

View File

@ -25,9 +25,17 @@ describe SessionsController do
expect(subject.current_user). to eq user
end
it "creates an audit log record" do
it 'creates an audit log record' do
expect { post(:create, user: { login: user.username, password: user.password }) }.to change { SecurityEvent.count }.by(1)
expect(SecurityEvent.last.details[:with]).to eq("standard")
expect(SecurityEvent.last.details[:with]).to eq('standard')
end
include_examples 'user login request with unique ip limit', 302 do
def request
post(:create, user: { login: user.username, password: user.password })
expect(subject.current_user).to eq user
subject.sign_out user
end
end
end
end

View File

@ -0,0 +1,57 @@
require 'spec_helper'
describe Gitlab::Auth::UniqueIpsLimiter, :redis, lib: true do
include_context 'unique ips sign in limit'
let(:user) { create(:user) }
describe '#count_unique_ips' do
context 'non unique IPs' do
it 'properly counts them' do
expect(described_class.update_and_return_ips_count(user.id, 'ip1')).to eq(1)
expect(described_class.update_and_return_ips_count(user.id, 'ip1')).to eq(1)
end
end
context 'unique IPs' do
it 'properly counts them' do
expect(described_class.update_and_return_ips_count(user.id, 'ip2')).to eq(1)
expect(described_class.update_and_return_ips_count(user.id, 'ip3')).to eq(2)
end
end
it 'resets count after specified time window' do
Timecop.freeze do
expect(described_class.update_and_return_ips_count(user.id, 'ip2')).to eq(1)
expect(described_class.update_and_return_ips_count(user.id, 'ip3')).to eq(2)
Timecop.travel(Time.now.utc + described_class.config.unique_ips_limit_time_window) do
expect(described_class.update_and_return_ips_count(user.id, 'ip4')).to eq(1)
expect(described_class.update_and_return_ips_count(user.id, 'ip5')).to eq(2)
end
end
end
end
describe '#limit_user!' do
include_examples 'user login operation with unique ip limit' do
def operation
described_class.limit_user! { user }
end
end
context 'allow 2 unique ips' do
before { current_application_settings.update!(unique_ips_limit_per_user: 2) }
it 'blocks user trying to login from third ip' do
change_ip('ip1')
expect(described_class.limit_user! { user }).to eq(user)
change_ip('ip2')
expect(described_class.limit_user! { user }).to eq(user)
change_ip('ip3')
expect { described_class.limit_user! { user } }.to raise_error(Gitlab::Auth::TooManyIps)
end
end
end
end

View File

@ -58,6 +58,14 @@ describe Gitlab::Auth, lib: true do
expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities))
end
include_examples 'user login operation with unique ip limit' do
let(:user) { create(:user, password: 'password') }
def operation
expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities))
end
end
context 'while using LFS authenticate' do
it 'recognizes user lfs tokens' do
user = create(:user)
@ -196,6 +204,12 @@ describe Gitlab::Auth, lib: true do
expect( gl_auth.find_with_user_password(username, password) ).not_to eql user
end
include_examples 'user login operation with unique ip limit' do
def operation
expect(gl_auth.find_with_user_password(username, password)).to eq(user)
end
end
context "with ldap enabled" do
before do
allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)

View File

@ -0,0 +1,30 @@
require 'spec_helper'
describe Gitlab::RequestContext, lib: true do
describe '#client_ip' do
subject { Gitlab::RequestContext.client_ip }
let(:app) { -> (env) {} }
let(:env) { Hash.new }
context 'when RequestStore::Middleware is used' do
around(:each) do |example|
RequestStore::Middleware.new(-> (env) { example.run }).call({})
end
context 'request' do
let(:ip) { '192.168.1.11' }
before do
allow_any_instance_of(Rack::Request).to receive(:ip).and_return(ip)
Gitlab::RequestContext.new(app).call(env)
end
it { is_expected.to eq(ip) }
end
context 'before RequestContext middleware run' do
it { is_expected.to be_nil }
end
end
end
end

View File

@ -1,17 +1,23 @@
require 'spec_helper'
describe API::API, api: true do
describe API::API, api: true do
include ApiHelpers
let!(:user) { create(:user) }
let!(:application) { Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) }
let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "api" }
describe "when unauthenticated" do
describe "unauthenticated" do
it "returns authentication success" do
get api("/user"), access_token: token.token
expect(response).to have_http_status(200)
end
include_examples 'user login request with unique ip limit' do
def request
get api('/user'), access_token: token.token
end
end
end
describe "when token invalid" do
@ -26,5 +32,11 @@ describe API::API, api: true do
get api("/user", user)
expect(response).to have_http_status(200)
end
include_examples 'user login request with unique ip limit' do
def request
get api('/user', user)
end
end
end
end

View File

@ -0,0 +1,62 @@
shared_context 'unique ips sign in limit' do
include StubENV
before(:each) do
Gitlab::Redis.with(&:flushall)
end
before do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
current_application_settings.update!(
unique_ips_limit_enabled: true,
unique_ips_limit_time_window: 10000
)
end
def change_ip(ip)
allow(Gitlab::RequestContext).to receive(:client_ip).and_return(ip)
end
def request_from_ip(ip)
change_ip(ip)
request
response
end
def operation_from_ip(ip)
change_ip(ip)
operation
end
end
shared_examples 'user login operation with unique ip limit' do
include_context 'unique ips sign in limit' do
before { current_application_settings.update!(unique_ips_limit_per_user: 1) }
it 'allows user authenticating from the same ip' do
expect { operation_from_ip('ip') }.not_to raise_error
expect { operation_from_ip('ip') }.not_to raise_error
end
it 'blocks user authenticating from two distinct ips' do
expect { operation_from_ip('ip') }.not_to raise_error
expect { operation_from_ip('ip2') }.to raise_error(Gitlab::Auth::TooManyIps)
end
end
end
shared_examples 'user login request with unique ip limit' do |success_status = 200|
include_context 'unique ips sign in limit' do
before { current_application_settings.update!(unique_ips_limit_per_user: 1) }
it 'allows user authenticating from the same ip' do
expect(request_from_ip('ip')).to have_http_status(success_status)
expect(request_from_ip('ip')).to have_http_status(success_status)
end
it 'blocks user authenticating from two distinct ips' do
expect(request_from_ip('ip')).to have_http_status(success_status)
expect(request_from_ip('ip2')).to have_http_status(403)
end
end
end