Browse Source
**Why**: To provide an additional layer of security. The TOTP spec (RFC 6238) recommends encrypting the keys. http://tools.ietf.org/html/rfc6238 **How**: Borrow the encryption code from the `attr_encrypted` gem and use it to encrypt and decrypt the `otp_secret_key` attribute. Allow users to add encryption by passing in `encrypted: true` to `has_one_time_password`. This provides backwards-compatibility for existing users of the gem. See the README updates for more detailed instructions for both new and existing users.master
Moncef Belyamani
9 years ago
24 changed files with 718 additions and 275 deletions
@ -1,15 +1,10 @@
|
||||
class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration |
||||
def up |
||||
change_table :<%= table_name %> do |t| |
||||
t.string :otp_secret_key |
||||
t.integer :second_factor_attempts_count, :default => 0 |
||||
end |
||||
def change |
||||
add_column :<%= table_name %>, :second_factor_attempts_count, :integer, default: 0 |
||||
add_column :<%= table_name %>, :encrypted_otp_secret_key, :string |
||||
add_column :<%= table_name %>, :encrypted_otp_secret_key_iv, :string |
||||
add_column :<%= table_name %>, :encrypted_otp_secret_key_salt, :string |
||||
|
||||
add_index :<%= table_name %>, :otp_secret_key, :unique => true |
||||
end |
||||
|
||||
def down |
||||
remove_column :<%= table_name %>, :otp_secret_key |
||||
remove_column :<%= table_name %>, :second_factor_attempts_count |
||||
add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true |
||||
end |
||||
end |
||||
|
@ -1,11 +1,19 @@
|
||||
module TwoFactorAuthentication |
||||
module Schema |
||||
def otp_secret_key |
||||
apply_devise_schema :otp_secret_key, String |
||||
end |
||||
|
||||
def second_factor_attempts_count |
||||
apply_devise_schema :second_factor_attempts_count, Integer, :default => 0 |
||||
end |
||||
|
||||
def encrypted_otp_secret_key |
||||
apply_devise_schema :encrypted_otp_secret_key, String |
||||
end |
||||
|
||||
def encrypted_otp_secret_key_iv |
||||
apply_devise_schema :encrypted_otp_secret_key_iv, String |
||||
end |
||||
|
||||
def encrypted_otp_secret_key_salt |
||||
apply_devise_schema :encrypted_otp_secret_key_salt, String |
||||
end |
||||
end |
||||
end |
||||
|
@ -1,18 +0,0 @@
|
||||
require 'spec_helper' |
||||
|
||||
include Warden::Test::Helpers |
||||
|
||||
describe HomeController, :type => :controller do |
||||
context "passed only 1st factor auth" do |
||||
let(:user) { create_user } |
||||
|
||||
describe "is_fully_authenticated helper" do |
||||
it "should be true" do |
||||
login_as user, scope: :user |
||||
visit user_two_factor_authentication_path |
||||
|
||||
expect(controller.is_fully_authenticated?).to be_truthy |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,33 @@
|
||||
require 'spec_helper' |
||||
|
||||
describe Devise::TwoFactorAuthenticationController, type: :controller do |
||||
describe 'is_fully_authenticated? helper' do |
||||
before do |
||||
sign_in |
||||
end |
||||
|
||||
context 'after user enters valid OTP code' do |
||||
it 'returns true' do |
||||
post :update, code: controller.current_user.otp_code |
||||
|
||||
expect(subject.is_fully_authenticated?).to eq true |
||||
end |
||||
end |
||||
|
||||
context 'when user has not entered any OTP yet' do |
||||
it 'returns false' do |
||||
get :show |
||||
|
||||
expect(subject.is_fully_authenticated?).to eq false |
||||
end |
||||
end |
||||
|
||||
context 'when user enters an invalid OTP' do |
||||
it 'returns false' do |
||||
post :update, code: '12345' |
||||
|
||||
expect(subject.is_fully_authenticated?).to eq false |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,36 @@
|
||||
require 'spec_helper' |
||||
|
||||
require 'generators/active_record/two_factor_authentication_generator' |
||||
|
||||
describe ActiveRecord::Generators::TwoFactorAuthenticationGenerator, type: :generator do |
||||
destination File.expand_path('../../../../../tmp', __FILE__) |
||||
|
||||
before do |
||||
prepare_destination |
||||
end |
||||
|
||||
it 'runs all methods in the generator' do |
||||
gen = generator %w(users) |
||||
expect(gen).to receive(:copy_two_factor_authentication_migration) |
||||
gen.invoke_all |
||||
end |
||||
|
||||
describe 'the generated files' do |
||||
before do |
||||
run_generator %w(users) |
||||
end |
||||
|
||||
describe 'the migration' do |
||||
subject { migration_file('db/migrate/two_factor_authentication_add_to_users.rb') } |
||||
|
||||
it { is_expected.to exist } |
||||
it { is_expected.to be_a_migration } |
||||
it { is_expected.to contain /def change/ } |
||||
it { is_expected.to contain /add_column :users, :second_factor_attempts_count, :integer, default: 0/ } |
||||
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key, :string/ } |
||||
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_iv, :string/ } |
||||
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_salt, :string/ } |
||||
it { is_expected.to contain /add_index :users, :encrypted_otp_secret_key, unique: true/ } |
||||
end |
||||
end |
||||
end |
@ -1,168 +1,264 @@
|
||||
require 'spec_helper' |
||||
include AuthenticatedModelHelper |
||||
|
||||
describe Devise::Models::TwoFactorAuthenticatable, '#otp_code' do |
||||
let(:instance) { build_guest_user } |
||||
subject { instance.otp_code(time) } |
||||
let(:time) { 1392852456 } |
||||
|
||||
it "should return an error if no secret is set" do |
||||
expect { |
||||
subject |
||||
}.to raise_error Exception |
||||
describe Devise::Models::TwoFactorAuthenticatable do |
||||
describe '#otp_code' do |
||||
shared_examples 'otp_code' do |instance| |
||||
subject { instance.otp_code(time) } |
||||
let(:time) { 1_392_852_456 } |
||||
|
||||
it 'returns an error if no secret is set' do |
||||
expect { subject }.to raise_error Exception |
||||
end |
||||
|
||||
context 'secret is set' do |
||||
before :each do |
||||
instance.otp_secret_key = '2z6hxkdwi3uvrnpn' |
||||
end |
||||
|
||||
it 'does not return an error' do |
||||
subject |
||||
end |
||||
|
||||
it 'matches Devise configured length' do |
||||
expect(subject.length).to eq(Devise.otp_length) |
||||
end |
||||
|
||||
context 'with a known time' do |
||||
let(:time) { 1_392_852_756 } |
||||
|
||||
it 'returns a known result' do |
||||
expect(subject). |
||||
to eq('0000000524562202'.split(//).last(Devise.otp_length).join) |
||||
end |
||||
end |
||||
|
||||
context 'with a known time yielding a result with less than 6 digits' do |
||||
let(:time) { 1_393_065_856 } |
||||
|
||||
it 'returns a known result padded with zeroes' do |
||||
expect(subject). |
||||
to eq('0000001608007672'.split(//).last(Devise.otp_length).join) |
||||
end |
||||
end |
||||
end |
||||
end |
||||
|
||||
it_behaves_like 'otp_code', GuestUser.new |
||||
it_behaves_like 'otp_code', EncryptedUser.new |
||||
end |
||||
|
||||
context "secret is set" do |
||||
before :each do |
||||
instance.otp_secret_key = "2z6hxkdwi3uvrnpn" |
||||
describe '#authenticate_otp' do |
||||
shared_examples 'authenticate_otp' do |instance| |
||||
before :each do |
||||
instance.otp_secret_key = '2z6hxkdwi3uvrnpn' |
||||
end |
||||
|
||||
def do_invoke(code, user) |
||||
user.authenticate_otp(code) |
||||
end |
||||
|
||||
it 'authenticates a recently created code' do |
||||
code = instance.otp_code |
||||
expect(do_invoke(code, instance)).to eq(true) |
||||
end |
||||
|
||||
it 'does not authenticate an old code' do |
||||
code = instance.otp_code(1.minutes.ago.to_i) |
||||
expect(do_invoke(code, instance)).to eq(false) |
||||
end |
||||
end |
||||
|
||||
it "should not return an error" do |
||||
subject |
||||
it_behaves_like 'authenticate_otp', GuestUser.new |
||||
it_behaves_like 'authenticate_otp', EncryptedUser.new |
||||
end |
||||
|
||||
describe '#send_two_factor_authentication_code' do |
||||
let(:instance) { build_guest_user } |
||||
|
||||
it 'raises an error by default' do |
||||
expect { instance.send_two_factor_authentication_code }. |
||||
to raise_error(NotImplementedError) |
||||
end |
||||
|
||||
it "should be configured length" do |
||||
expect(subject.length).to eq(Devise.otp_length) |
||||
it 'is overrideable' do |
||||
def instance.send_two_factor_authentication_code |
||||
'Code sent' |
||||
end |
||||
expect(instance.send_two_factor_authentication_code).to eq('Code sent') |
||||
end |
||||
end |
||||
|
||||
context "with a known time" do |
||||
let(:time) { 1392852756 } |
||||
describe '#provisioning_uri' do |
||||
shared_examples 'provisioning_uri' do |instance| |
||||
before do |
||||
instance.email = 'houdini@example.com' |
||||
instance.run_callbacks :create |
||||
end |
||||
|
||||
it "should return a known result" do |
||||
expect(subject).to eq("0000000524562202".split(//).last(Devise.otp_length).join) |
||||
it "returns uri with user's email" do |
||||
expect(instance.provisioning_uri). |
||||
to match(%r{otpauth://totp/houdini@example.com\?secret=\w{16}}) |
||||
end |
||||
|
||||
it 'returns uri with issuer option' do |
||||
expect(instance.provisioning_uri('houdini')). |
||||
to match(%r{otpauth://totp/houdini\?secret=\w{16}$}) |
||||
end |
||||
end |
||||
|
||||
context "with a known time yielding a result with less than 6 digits" do |
||||
let(:time) { 1393065856 } |
||||
it 'returns uri with issuer option' do |
||||
require 'cgi' |
||||
|
||||
it "should return a known result padded with zeroes" do |
||||
expect(subject).to eq("0000001608007672".split(//).last(Devise.otp_length).join) |
||||
uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic')) |
||||
params = CGI.parse(uri.query) |
||||
|
||||
expect(uri.scheme).to eq('otpauth') |
||||
expect(uri.host).to eq('totp') |
||||
expect(uri.path).to eq('/houdini') |
||||
expect(params['issuer'].shift).to eq('Magic') |
||||
expect(params['secret'].shift).to match(/\w{16}/) |
||||
end |
||||
end |
||||
|
||||
it_behaves_like 'provisioning_uri', GuestUser.new |
||||
it_behaves_like 'provisioning_uri', EncryptedUser.new |
||||
end |
||||
end |
||||
|
||||
describe Devise::Models::TwoFactorAuthenticatable, '#authenticate_otp' do |
||||
let(:instance) { build_guest_user } |
||||
describe '#populate_otp_column' do |
||||
shared_examples 'populate_otp_column' do |klass| |
||||
let(:instance) { klass.new } |
||||
|
||||
before :each do |
||||
instance.otp_secret_key = "2z6hxkdwi3uvrnpn" |
||||
end |
||||
it 'populates otp_column on create' do |
||||
expect(instance.otp_secret_key).to be_nil |
||||
|
||||
def do_invoke code, options = {} |
||||
instance.authenticate_otp(code, options) |
||||
end |
||||
# populate_otp_column called via before_create |
||||
instance.run_callbacks :create |
||||
|
||||
it "should be able to authenticate a recently created code" do |
||||
code = instance.otp_code |
||||
expect(do_invoke(code)).to eq(true) |
||||
end |
||||
expect(instance.otp_secret_key).to match(/\w{16}/) |
||||
end |
||||
|
||||
it "should not authenticate an old code" do |
||||
code = instance.otp_code(1.minutes.ago.to_i) |
||||
expect(do_invoke(code)).to eq(false) |
||||
end |
||||
end |
||||
it 'repopulates otp_column' do |
||||
instance.run_callbacks :create |
||||
original_key = instance.otp_secret_key |
||||
|
||||
describe Devise::Models::TwoFactorAuthenticatable, '#send_two_factor_authentication_code' do |
||||
let(:instance) { build_guest_user } |
||||
instance.populate_otp_column |
||||
|
||||
it "should raise an error by default" do |
||||
expect { |
||||
instance.send_two_factor_authentication_code |
||||
}.to raise_error(NotImplementedError) |
||||
expect(instance.otp_secret_key).to match(/\w{16}/) |
||||
expect(instance.otp_secret_key).to_not eq(original_key) |
||||
end |
||||
end |
||||
|
||||
it_behaves_like 'populate_otp_column', GuestUser |
||||
it_behaves_like 'populate_otp_column', EncryptedUser |
||||
end |
||||
|
||||
it "should be overrideable" do |
||||
def instance.send_two_factor_authentication_code |
||||
"Code sent" |
||||
describe '#max_login_attempts' do |
||||
let(:instance) { build_guest_user } |
||||
|
||||
before do |
||||
@original_max_login_attempts = GuestUser.max_login_attempts |
||||
GuestUser.max_login_attempts = 3 |
||||
end |
||||
expect(instance.send_two_factor_authentication_code).to eq("Code sent") |
||||
end |
||||
end |
||||
|
||||
describe Devise::Models::TwoFactorAuthenticatable, '#provisioning_uri' do |
||||
let(:instance) { build_guest_user } |
||||
after { GuestUser.max_login_attempts = @original_max_login_attempts } |
||||
|
||||
before do |
||||
instance.email = "houdini@example.com" |
||||
instance.run_callbacks :create |
||||
end |
||||
it 'returns class setting' do |
||||
expect(instance.max_login_attempts).to eq(3) |
||||
end |
||||
|
||||
it "should return uri with user's email" do |
||||
expect(instance.provisioning_uri).to match(%r{otpauth://totp/houdini@example.com\?secret=\w{16}}) |
||||
end |
||||
it 'returns false as boolean' do |
||||
instance.second_factor_attempts_count = nil |
||||
expect(instance.max_login_attempts?).to be_falsey |
||||
instance.second_factor_attempts_count = 0 |
||||
expect(instance.max_login_attempts?).to be_falsey |
||||
instance.second_factor_attempts_count = 1 |
||||
expect(instance.max_login_attempts?).to be_falsey |
||||
instance.second_factor_attempts_count = 2 |
||||
expect(instance.max_login_attempts?).to be_falsey |
||||
end |
||||
|
||||
it "should return uri with issuer option" do |
||||
expect(instance.provisioning_uri("houdini")).to match(%r{otpauth://totp/houdini\?secret=\w{16}$}) |
||||
it 'returns true as boolean after too many attempts' do |
||||
instance.second_factor_attempts_count = 3 |
||||
expect(instance.max_login_attempts?).to be_truthy |
||||
instance.second_factor_attempts_count = 4 |
||||
expect(instance.max_login_attempts?).to be_truthy |
||||
end |
||||
end |
||||
|
||||
it "should return uri with issuer option" do |
||||
require 'cgi' |
||||
describe '.has_one_time_password' do |
||||
context 'when encrypted: true option is passed' do |
||||
let(:instance) { EncryptedUser.new } |
||||
|
||||
uri = URI.parse(instance.provisioning_uri("houdini", issuer: 'Magic')) |
||||
params = CGI::parse(uri.query) |
||||
it 'encrypts otp_secret_key with iv, salt, and encoding' do |
||||
instance.otp_secret_key = '2z6hxkdwi3uvrnpn' |
||||
|
||||
expect(uri.scheme).to eq("otpauth") |
||||
expect(uri.host).to eq("totp") |
||||
expect(uri.path).to eq("/houdini") |
||||
expect(params['issuer'].shift).to eq('Magic') |
||||
expect(params['secret'].shift).to match(%r{\w{16}}) |
||||
end |
||||
end |
||||
expect(instance.encrypted_otp_secret_key).to match(/.{44}/) |
||||
|
||||
expect(instance.encrypted_otp_secret_key_iv).to match(/.{24}/) |
||||
|
||||
describe Devise::Models::TwoFactorAuthenticatable, '#populate_otp_column' do |
||||
let(:instance) { build_guest_user } |
||||
expect(instance.encrypted_otp_secret_key_salt).to match(/.{25}/) |
||||
end |
||||
|
||||
it "populates otp_column on create" do |
||||
expect(instance.otp_secret_key).to be_nil |
||||
it 'does not encrypt a nil otp_secret_key' do |
||||
instance.otp_secret_key = nil |
||||
|
||||
instance.run_callbacks :create # populate_otp_column called via before_create |
||||
expect(instance.encrypted_otp_secret_key).to be_nil |
||||
|
||||
expect(instance.otp_secret_key).to match(%r{\w{16}}) |
||||
end |
||||
expect(instance.encrypted_otp_secret_key_iv).to be_nil |
||||
|
||||
it "repopulates otp_column" do |
||||
instance.run_callbacks :create |
||||
original_key = instance.otp_secret_key |
||||
expect(instance.encrypted_otp_secret_key_salt).to be_nil |
||||
end |
||||
|
||||
instance.populate_otp_column |
||||
it 'does not encrypt an empty otp_secret_key' do |
||||
instance.otp_secret_key = '' |
||||
|
||||
expect(instance.otp_secret_key).to match(%r{\w{16}}) |
||||
expect(instance.otp_secret_key).to_not eq(original_key) |
||||
end |
||||
end |
||||
expect(instance.encrypted_otp_secret_key).to eq '' |
||||
|
||||
describe Devise::Models::TwoFactorAuthenticatable, '#max_login_attempts' do |
||||
let(:instance) { build_guest_user } |
||||
expect(instance.encrypted_otp_secret_key_iv).to be_nil |
||||
|
||||
before do |
||||
@original_max_login_attempts = GuestUser.max_login_attempts |
||||
GuestUser.max_login_attempts = 3 |
||||
end |
||||
expect(instance.encrypted_otp_secret_key_salt).to be_nil |
||||
end |
||||
|
||||
after { GuestUser.max_login_attempts = @original_max_login_attempts } |
||||
it 'raises an error when Devise.otp_secret_encryption_key is not set' do |
||||
allow(Devise).to receive(:otp_secret_encryption_key).and_return nil |
||||
|
||||
it "returns class setting" do |
||||
expect(instance.max_login_attempts).to eq(3) |
||||
end |
||||
# This error is raised by the encryptor gem |
||||
expect { instance.otp_secret_key = '2z6hxkdwi3uvrnpn' }. |
||||
to raise_error ArgumentError, 'must specify a :key' |
||||
end |
||||
|
||||
it "returns false as boolean" do |
||||
instance.second_factor_attempts_count = nil |
||||
expect(instance.max_login_attempts?).to be_falsey |
||||
instance.second_factor_attempts_count = 0 |
||||
expect(instance.max_login_attempts?).to be_falsey |
||||
instance.second_factor_attempts_count = 1 |
||||
expect(instance.max_login_attempts?).to be_falsey |
||||
instance.second_factor_attempts_count = 2 |
||||
expect(instance.max_login_attempts?).to be_falsey |
||||
end |
||||
it 'passes in the correct options to Encryptor' do |
||||
instance.otp_secret_key = 'testing' |
||||
iv = instance.encrypted_otp_secret_key_iv |
||||
salt = instance.encrypted_otp_secret_key_salt |
||||
|
||||
encrypted = Encryptor.encrypt( |
||||
value: 'testing', |
||||
key: Devise.otp_secret_encryption_key, |
||||
iv: iv.unpack('m').first, |
||||
salt: salt.unpack('m').first |
||||
) |
||||
|
||||
it "returns true as boolean after too many attempts" do |
||||
instance.second_factor_attempts_count = 3 |
||||
expect(instance.max_login_attempts?).to be_truthy |
||||
instance.second_factor_attempts_count = 4 |
||||
expect(instance.max_login_attempts?).to be_truthy |
||||
expect(instance.encrypted_otp_secret_key).to eq [encrypted].pack('m') |
||||
end |
||||
|
||||
it 'varies the iv per instance' do |
||||
instance.otp_secret_key = 'testing' |
||||
user2 = EncryptedUser.new |
||||
user2.otp_secret_key = 'testing' |
||||
|
||||
expect(user2.encrypted_otp_secret_key_iv). |
||||
to_not eq instance.encrypted_otp_secret_key_iv |
||||
end |
||||
|
||||
it 'varies the salt per instance' do |
||||
instance.otp_secret_key = 'testing' |
||||
user2 = EncryptedUser.new |
||||
user2.otp_secret_key = 'testing' |
||||
|
||||
expect(user2.encrypted_otp_secret_key_salt). |
||||
to_not eq instance.encrypted_otp_secret_key_salt |
||||
end |
||||
end |
||||
end |
||||
end |
||||
|
@ -0,0 +1,14 @@
|
||||
class EncryptedUser |
||||
extend ActiveModel::Callbacks |
||||
include ActiveModel::Validations |
||||
include Devise::Models::TwoFactorAuthenticatable |
||||
|
||||
define_model_callbacks :create |
||||
attr_accessor :encrypted_otp_secret_key, |
||||
:encrypted_otp_secret_key_iv, |
||||
:encrypted_otp_secret_key_salt, |
||||
:email, |
||||
:second_factor_attempts_count |
||||
|
||||
has_one_time_password(encrypted: true) |
||||
end |
@ -0,0 +1,9 @@
|
||||
class AddEncryptedColumnsToUser < ActiveRecord::Migration |
||||
def change |
||||
add_column :users, :encrypted_otp_secret_key, :string |
||||
add_column :users, :encrypted_otp_secret_key_iv, :string |
||||
add_column :users, :encrypted_otp_secret_key_salt, :string |
||||
|
||||
add_index :users, :encrypted_otp_secret_key, unique: true |
||||
end |
||||
end |
@ -0,0 +1,19 @@
|
||||
class PopulateOtpColumn < ActiveRecord::Migration |
||||
def up |
||||
User.reset_column_information |
||||
|
||||
User.find_each do |user| |
||||
user.otp_secret_key = user.read_attribute('otp_secret_key') |
||||
user.save! |
||||
end |
||||
end |
||||
|
||||
def down |
||||
User.reset_column_information |
||||
|
||||
User.find_each do |user| |
||||
user.otp_secret_key = ROTP::Base32.random_base32 |
||||
user.save! |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,5 @@
|
||||
class RemoveOtpSecretKeyFromUser < ActiveRecord::Migration |
||||
def change |
||||
remove_column :users, :otp_secret_key, :string |
||||
end |
||||
end |
@ -0,0 +1,16 @@
|
||||
module ControllerHelper |
||||
def sign_in(user = create_user('not_encrypted')) |
||||
allow(warden).to receive(:authenticated?).with(:user).and_return(true) |
||||
allow(controller).to receive(:current_user).and_return(user) |
||||
warden.session(:user)[TwoFactorAuthentication::NEED_AUTHENTICATION] = true |
||||
end |
||||
end |
||||
|
||||
RSpec.configure do |config| |
||||
config.include Devise::TestHelpers, type: :controller |
||||
config.include ControllerHelper, type: :controller |
||||
|
||||
config.before(:example, type: :controller) do |
||||
@request.env['devise.mapping'] = Devise.mappings[:user] |
||||
end |
||||
end |
Loading…
Reference in new issue