add MFA models, controllers, etc.

pull/14359/head
Alberto Romeu 6 years ago
parent 1092a8c79d
commit 453af2947c

@ -83,6 +83,10 @@ gem 'net-telnet'
# This is weird. In ruby 2 test-unit is required. We don't know why for sure
gem 'test-unit'
# Multifactor Authentication
gem 'rotp', '~> 3.3', '>= 3.3.1'
gem 'rqrcode', '~> 0.10.1'
group :test do
gem 'simplecov', '0.13.0', require: false
gem 'simplecov-json'

@ -293,6 +293,9 @@ GEM
nokogiri
rubyzip
spreadsheet (> 0.6.4)
rotp (3.3.1)
rqrcode (0.10.1)
chunky_png (~> 1.0)
rspec-core (2.12.2)
rspec-expectations (2.12.1)
diff-lcs (~> 1.1.3)
@ -459,6 +462,8 @@ DEPENDENCIES
retriable (= 1.4.1)
rollbar (~> 2.11.1)
roo (= 1.13.2)
rotp (~> 3.3, >= 3.3.1)
rqrcode (~> 0.10.1)
rspec-rails (= 2.12.0)
ruby-prof (= 0.15.1)
ruby-saml (= 1.4.1)

@ -5,7 +5,8 @@ Development
- None yet
### Features
- None yet
* MFA
* Migration, models and controllers (#14335)
### Bug fixes / enhancements
- Scrollbar resized after notifications (#12953)

@ -0,0 +1,30 @@
require 'json'
module Carto
module Api
class MultifactorAuthPresenter
def initialize(multifactor_auth)
@multifactor_auth = multifactor_auth
end
def to_poro
return {} unless @multifactor_auth
{
id: @multifactor_auth.id,
user: @multifactor_auth.user.username,
type: @multifactor_auth.type,
enabled: @multifactor_auth.enabled,
created_at: @multifactor_auth.created_at.to_s,
updated_at: @multifactor_auth.updated_at.to_s
}
end
def to_poro_with_qrcode
to_poro.merge(
qrcode: @multifactor_auth.qr_code
)
end
end
end
end

@ -0,0 +1,109 @@
class Carto::Api::MultifactorAuthsController < ::Api::ApplicationController
include Carto::ControllerHelper
include Carto::UUIDHelper
include Carto::Api::AuthApiAuthentication
ssl_required :create, :destroy, :validate_code, :show, :index
# before_filter :any_api_authorization_required, only: [:index, :show]
# skip_filter :api_authorization_required, only: [:index, :show]
before_action :check_shared_secret_not_present, only: [:create, :validate_code]
before_action :load_user
before_action :load_multifactor_auth, only: [:validate_code, :destroy]
# before_filter :load_api_key, only: [:destroy, :regenerate_token, :show]
# rescue_from Carto::OrderParamInvalidError, with: :rescue_from_carto_error
# rescue_from Carto::LoadError, with: :rescue_from_carto_error
rescue_from Carto::UnprocesableEntityError, with: :rescue_from_carto_error
rescue_from Carto::UnauthorizedError, with: :rescue_from_carto_error
def create
multifactor_auth = @carto_viewer.user_multifactor_auths.create!(create_params)
render_jsonp(Carto::Api::MultifactorAuthPresenter.new(multifactor_auth).to_poro_with_qrcode, 201)
rescue ActiveRecord::RecordInvalid => e
raise Carto::UnprocesableEntityError.new(e.message)
end
def validate_code
@multifactor_auth.verify!(params[:code])
render_jsonp(Carto::Api::MultifactorAuthPresenter.new(@multifactor_auth).to_poro, 200)
rescue ActiveRecord::RecordInvalid => e
raise Carto::UnprocesableEntityError.new(e.message)
end
def destroy
@multifactor_auth.destroy
render_jsonp(Carto::Api::MultifactorAuthPresenter.new(@multifactor_auth).to_poro, 200)
end
# def regenerate_token
# @viewed_api_key.regenerate_token!
# render_jsonp(Carto::Api::ApiKeyPresenter.new(@viewed_api_key).to_poro, 200)
# end
# def index
# page, per_page, order = page_per_page_order_params(VALID_ORDER_PARAMS)
# api_keys = Carto::User.find(current_user.id).api_keys.user_visible.order_weighted_by_type
# api_keys = request_api_key.master? ? api_keys : api_keys.where(id: request_api_key.id)
# filtered_api_keys = Carto::PagedModel.paged_association(api_keys, page, per_page, order)
# result = filtered_api_keys.map { |api_key| json_for_api_key(api_key) }
# render_jsonp(
# paged_result(
# result: result,
# total_count: api_keys.count,
# page: page,
# per_page: per_page,
# order: order
# ) { |params| api_keys_url(params) },
# 200
# )
# end
# def show
# render_jsonp(Carto::Api::ApiKeyPresenter.new(@viewed_api_key).to_poro, 200)
# end
# private
# def check_engine_enabled
# render_404 unless current_viewer.try(:engine_enabled?)
# end
# def load_api_key
# name = params[:id]
# @viewed_api_key = Carto::ApiKey.where(user_id: current_viewer.id, name: name).user_visible.first
# if !@viewed_api_key || !request_api_key.master? && @viewed_api_key != request_api_key
# raise Carto::LoadError.new("API key not found: #{name}")
# end
# end
# def json_for_api_key(api_key)
# Carto::Api::ApiKeyPresenter.new(api_key).to_poro.merge(
# _links: {
# self: api_key_url(id: CGI::escape(api_key.name))
# }
# )
# end
private
def load_user
@carto_viewer = Carto::User.find(current_viewer.id)
end
def load_multifactor_auth
@multifactor_auth = @carto_viewer.user_multifactor_auths.find(params[:id])
end
def check_shared_secret_not_present
error_msg = "The 'shared_secret' parameter is not allowed for this endpoint".freeze
raise Carto::UnprocesableEntityError.new(error_msg) if params[:shared_secret].present?
end
def create_params
params.permit(:type)
end
end

@ -79,6 +79,7 @@ class Carto::User < ActiveRecord::Base
has_many :received_notifications, inverse_of: :user
has_many :api_keys, inverse_of: :user
has_many :user_multifactor_auths, class_name: Carto::UserMultifactorAuth
has_many :oauth_apps, inverse_of: :user, dependent: :destroy
has_many :oauth_app_users, inverse_of: :user, dependent: :destroy

@ -0,0 +1,62 @@
require 'rotp'
require 'rqrcode'
module Carto
class UserMultifactorAuth < ActiveRecord::Base
TYPE_TOTP = 'totp'.freeze
VALID_TYPES = [TYPE_TOTP].freeze
DRIFT = 5
INTERVAL = 30
ISSUER = 'CARTO'.freeze
belongs_to :user, foreign_key: :user_id
validates :type, inclusion: { in: VALID_TYPES }
validate :shared_secret_not_changed
before_create :create_shared_secret
self.inheritance_column = :_type
def verify!(code)
timestamp = verify(code)
raise Carto::UnauthorizedError.new('The code is not valid') unless timestamp
update!(code: code, enabled: true, last_login: timestamp)
end
def verify(code = self.code)
timestamp = totp.verify_with_drift_and_prior(code.to_s, DRIFT, last_login.try(:to_i))
Time.at(timestamp) if timestamp
end
def create_shared_secret
self.shared_secret = ROTP::Base32.random_base32
end
def disabled?
!enabled
end
def provisioning_uri
totp.provisioning_uri(user.username)
end
def qr_code
qrcode = RQRCode::QRCode.new(totp.provisioning_uri(user.username))
qrcode.as_png.to_data_url
end
def totp
@totp ||= ROTP::TOTP.new(shared_secret, issuer: ISSUER, interval: INTERVAL)
end
private
def shared_secret_not_changed
if shared_secret_changed? && persisted?
errors.add(:shared_secret, "Change of shared_secret not allowed!")
end
end
end
end

@ -419,6 +419,10 @@ class User < Sequel::Model
Carto::ApiKey.where(user_id: id)
end
def user_multifactor_auths
Carto::UserMultifactorAuths.where(user_id: id)
end
def shared_entities
CartoDB::SharedEntity.join(:visualizations, id: :entity_id).where(user_id: id)
end

@ -636,6 +636,12 @@ CartoDB::Application.routes.draw do
controller: :received_notifications,
constraints: { id: UUID_REGEXP }
end
# Multi-factor authentication
resources :multifactor_auths, only: [:create, :destroy], constraints: { id: /[^\/]+/ }
scope 'multifactor_auths/:id' do
post 'validate_code' => 'multifactor_auths#validate_code', as: :validate_multifactor_auth
end
end
scope 'v2/' do

@ -7,10 +7,10 @@ migration(
create_table :user_multifactor_auths do
Uuid :id, primary_key: true, default: 'uuid_generate_v4()'.lit
foreign_key :user_id, :users, type: :uuid, null: false, index: true, on_delete: :cascade
String :mfa_type, null: false
String :type, null: false
String :shared_secret, null: false
Boolean :enabled, null: false, default: false
String :login_code, null: true
String :code, null: true
DateTime :last_login, null: true
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP

@ -0,0 +1,5 @@
FactoryGirl.define do
factory :totp, class: Carto::UserMultifactorAuth do
type Carto::UserMultifactorAuth::TYPE_TOTP
end
end

@ -0,0 +1,122 @@
# encoding: utf-8
require 'spec_helper'
describe Carto::UserMultifactorAuth do
before :all do
@valid_type = 'totp'
end
before :each do
@carto_user = FactoryGirl.create(:carto_user)
end
after :each do
@carto_user.destroy
end
describe '#create' do
it 'does not validate an unsupported multi-factor authentication type' do
mfa = Carto::UserMultifactorAuth.new(user_id: @carto_user.id, type: 'wadus')
mfa.valid?.should eq(false)
end
it 'validates supported multi-factor authentication types' do
mfa = Carto::UserMultifactorAuth.new(user_id: @carto_user.id, type: @valid_type)
expect { mfa.valid? }.to be_true
end
it 'creates a new multifactor auth for a user' do
expect {
Carto::UserMultifactorAuth.create!(user_id: @carto_user.id, type: @valid_type)
}.to_not raise_error
end
it 'populates shared_secret' do
mfa = Carto::UserMultifactorAuth.create!(user_id: @carto_user.id, type: @valid_type)
expect { mfa.shared_secret }.to_not be_nil
end
it 'is disabled by default' do
mfa = Carto::UserMultifactorAuth.create!(user_id: @carto_user.id, type: @valid_type)
expect { mfa.disabled? }.to be_true
end
end
describe '#save' do
before :each do
@multifactor_auth = FactoryGirl.create(:totp, user: @carto_user)
end
after :each do
@multifactor_auth.destroy
end
it 'does not allow updating the shared_scret field' do
@multifactor_auth.shared_secret = 'wadus'
expect {
@multifactor_auth.save!
}.to raise_error(/Change of shared_secret not allowed!/)
end
end
describe '#verify' do
it 'verifies a valid code' do
mfa = Carto::UserMultifactorAuth.create!(user_id: @carto_user.id, type: @valid_type)
mfa.code = mfa.totp.now
expect { mfa.verify }.to be
end
it 'does not allow reuse of a valid code' do
mfa = Carto::UserMultifactorAuth.create!(user_id: @carto_user.id, type: @valid_type)
mfa.code = mfa.totp.now
expect { mfa.verify }.to be
mfa.verify.should_not be
end
it 'does not verify an invalid code' do
mfa = Carto::UserMultifactorAuth.create!(user_id: @carto_user.id, type: @valid_type)
totp = ROTP::TOTP.new(ROTP::Base32.random_base32)
mfa.code = totp.now
mfa.verify.should_not be
end
end
describe '#verify!' do
it 'verifies a valid code' do
mfa = Carto::UserMultifactorAuth.create!(user_id: @carto_user.id, type: @valid_type)
code = mfa.totp.now
mfa.verify!(code)
expect { mfa.enabled }.to be_true
end
it 'does not allow reuse of a valid code' do
mfa = Carto::UserMultifactorAuth.create!(user_id: @carto_user.id, type: @valid_type)
code = mfa.totp.now
mfa.verify!(code)
expect { mfa.verify!(code) }.to raise_error("The code is not valid")
end
it 'does not verify an invalid code' do
mfa = Carto::UserMultifactorAuth.create!(user_id: @carto_user.id, type: @valid_type)
totp = ROTP::TOTP.new(ROTP::Base32.random_base32)
expect { mfa.verify!(totp.now) }.to raise_error("The code is not valid")
end
end
describe '#provisioning_uri' do
before :each do
@multifactor_auth = FactoryGirl.create(:totp, user: @carto_user)
end
after :each do
@multifactor_auth.destroy
end
it 'provides a provisioning_uri' do
uri = "otpauth://totp/CARTO:#{@carto_user.username}?secret=#{@multifactor_auth.shared_secret}&issuer=CARTO"
@multifactor_auth.provisioning_uri.should eq uri
end
end
end

@ -0,0 +1,178 @@
require 'spec_helper_min'
require 'support/helpers'
describe Carto::Api::MultifactorAuthsController do
include HelperMethods
before :all do
@user = FactoryGirl.create(:carto_user)
end
after :all do
@user.destroy
end
after :each do
@user.user_multifactor_auths.each(&:destroy)
end
def auth_headers
http_json_headers
end
def auth_params
{ user_domain: @user.username, api_key: @user.api_key }
end
let(:create_payload) do
{
type: 'totp',
user_id: @user.id
}
end
describe '#create' do
it 'creates a totp multifactor auth' do
post_json multifactor_auths_url, auth_params.merge(create_payload), auth_headers do |response|
response.status.should eq 201
response = response.body
response[:id].should be
response[:type].should eq 'totp'
response[:enabled].should eq false
response[:qrcode].should be
response[:user].should eq(@user.username)
end
end
it 'raises error if shared_secret is sent' do
params = auth_params.merge(create_payload).merge(shared_secret: 'wadus')
post_json multifactor_auths_url, params, auth_headers do |response|
response.status.should eq 422
response = response.body
response[:errors].should include("The 'shared_secret' parameter is not allowed for this endpoint")
end
end
it 'creates a totp multifactor auth and ignores additional params' do
params = auth_params.merge(create_payload).merge(code: 'wadus', enabled: true)
post_json multifactor_auths_url, params, auth_headers do |response|
response.status.should eq 201
response = response.body
response[:id].should be
response[:type].should eq 'totp'
response[:enabled].should eq false
response[:qrcode].should be
response[:user].should eq(@user.username)
@user.user_multifactor_auths.find(response[:id]).code.should be_nil
end
end
end
describe '#validate_code' do
before :each do
@multifactor_auth = FactoryGirl.create(:totp, user: @user)
end
after :each do
@multifactor_auth.destroy
end
it 'validates a totp multifactor auth code' do
params = auth_params.merge(code: @multifactor_auth.totp.now)
post_json validate_multifactor_auth_url(id: @multifactor_auth.id), params, auth_headers do |response|
response.status.should eq 200
response = response.body
response[:id].should be
response[:type].should eq 'totp'
response[:enabled].should eq true
response[:qrcode].should_not be
response[:user].should eq(@user.username)
@multifactor_auth.reload
@multifactor_auth.code.should eq params[:code]
@multifactor_auth.disabled?.should be_false
@multifactor_auth.last_login.should be
end
end
it 'raises error if not valid code' do
params = auth_params.merge(code: '123456')
post_json validate_multifactor_auth_url(id: @multifactor_auth.id), params, auth_headers do |response|
response.status.should eq 403
response = response.body
response[:errors].should include("The code is not valid")
end
end
it 'raises error if shared_secret is sent' do
params = auth_params.merge(shared_secret: 'wadus')
post_json validate_multifactor_auth_url(id: @multifactor_auth.id), params, auth_headers do |response|
response.status.should eq 422
response = response.body
response[:errors].should include("The 'shared_secret' parameter is not allowed for this endpoint")
end
end
it 'validates and ignores additional params' do
params = auth_params.merge(code: @multifactor_auth.totp.now, last_login: Time.now - 1.year)
post_json validate_multifactor_auth_url(id: @multifactor_auth.id), params, auth_headers do |response|
response.status.should eq 200
response = response.body
response[:id].should be
response[:type].should eq 'totp'
response[:enabled].should eq true
response[:qrcode].should_not be
response[:user].should eq(@user.username)
@multifactor_auth.reload
@multifactor_auth.code.should eq params[:code]
@multifactor_auth.disabled?.should be_false
@multifactor_auth.last_login.year.should eq Time.now.year
end
end
it 'updates last_login' do
@multifactor_auth.verify!(@multifactor_auth.totp.now)
last_login = @multifactor_auth.last_login
params = auth_params.merge(code: @multifactor_auth.totp.now)
post_json validate_multifactor_auth_url(id: @multifactor_auth.id), params, auth_headers do |response|
response.status.should eq 200
response = response.body
response[:id].should be
response[:type].should eq 'totp'
response[:enabled].should eq true
response[:qrcode].should_not be
response[:user].should eq(@user.username)
@multifactor_auth.reload
@multifactor_auth.code.should eq params[:code]
@multifactor_auth.disabled?.should be_false
@multifactor_auth.last_login.should_not eq last_login
end
end
end
describe '#destroy' do
before :each do
@multifactor_auth = FactoryGirl.create(:totp, user: @user)
end
after :each do
@multifactor_auth.destroy
end
it 'destroys a multifactor auth instance' do
delete_json multifactor_auth_url(id: @multifactor_auth.id), auth_params, auth_headers do |response|
response.status.should eq 200
response = response.body
response[:id].should be
response[:type].should eq 'totp'
response[:qrcode].should_not be
response[:user].should eq(@user.username)
@user.user_multifactor_auths.where(id: response[:id]).should be_empty
end
end
end
end
Loading…
Cancel
Save