parent
1092a8c79d
commit
453af2947c
@ -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
|
@ -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
|
@ -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…
Reference in new issue