Browse Source

Merge pull request #16 from checkdin/master

Use time-based authentication codes
master
Dmitrii Golub 11 years ago
parent
commit
4d5e28942e
  1. 1
      .gitignore
  2. 56
      README.md
  3. 4
      app/controllers/devise/two_factor_authentication_controller.rb
  4. 11
      lib/generators/active_record/templates/migration.rb
  5. 13
      lib/two_factor_authentication.rb
  6. 5
      lib/two_factor_authentication/hooks/two_factor_authenticatable.rb
  7. 62
      lib/two_factor_authentication/models/two_factor_authenticatable.rb
  8. 4
      lib/two_factor_authentication/schema.rb
  9. 70
      spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb
  10. 21
      spec/spec_helper.rb
  11. 29
      spec/support/authenticated_model_helper.rb
  12. 2
      two_factor_authentication.gemspec

1
.gitignore vendored

@ -18,3 +18,4 @@ patches-*
capybara-*.html capybara-*.html
dump.rdb dump.rdb
*.ids *.ids
.rbenv-version

56
README.md

@ -32,6 +32,20 @@ Finally, run the migration with:
bundle exec rake db:migrate bundle exec rake db:migrate
Add the following line to your model to fully enable two-factor auth:
has_one_time_password
Set config values if desired for maximum second factor attempts count and allowed time drift for one-time passwords:
config.max_login_attempts = 3
config.allowed_otp_drift_seconds = 30
Override the method to send one-time passwords in your model, this is automatically called when a user logs in:
def send_two_factor_authentication_code
# use Model#otp_code and send via SMS, etc.
end
### Manual installation ### Manual installation
@ -42,23 +56,22 @@ To manually enable two factor authentication for the User model, you should add
:recoverable, :rememberable, :trackable, :validatable, :two_factor_authenticatable :recoverable, :rememberable, :trackable, :validatable, :two_factor_authenticatable
``` ```
Two default parameters Add the following line to your model to fully enable two-factor auth:
```ruby has_one_time_password
config.devise.login_code_random_pattern = /\w+/
config.devise.max_login_attempts = 3
```
Possible random patterns Set config values if desired for maximum second factor attempts count and allowed time drift for one-time passwords:
```ruby config.max_login_attempts = 3
/\d{5}/ config.allowed_otp_drift_seconds = 30
/\w{4,8}/
```
see more https://github.com/benburkert/randexp Override the method to send one-time passwords in your model, this is automatically called when a user logs in:
### Customisation def send_two_factor_authentication_code
# use Model#otp_code and send via SMS, etc.
end
### Customisation and Usage
By default second factor authentication enabled for each user, you can change it with this method in your User model: By default second factor authentication enabled for each user, you can change it with this method in your User model:
@ -70,21 +83,8 @@ By default second factor authentication enabled for each user, you can change it
this will disable two factor authentication for local users this will disable two factor authentication for local users
Your send sms logic should be in this method in your User model: This gem is compatible with Google Authenticator (https://support.google.com/accounts/answer/1066447?hl=en). You can generate provisioning uris by invoking the following method on your model:
```ruby
def send_two_factor_authentication_code(code)
puts code
end
```
This example just puts the code in the logs.
### External dependencies user.provisioning_uri #This assumes a user model with an email attributes
Randexp requires words files (Check if it is installed in /usr/share/dict/words or /usr/dict/words), This provisioning uri can then be turned in to a QR code if desired so that users may add the app to Google Authenticator easily. Once this is done they may retrieve a one-time password directly from the Google Authenticator app as well as through whatever method you define in `send_two_factor_authentication_code`
you might need install it:
```bash
apt-get install wbritish # or whichever you require
```

4
app/controllers/devise/two_factor_authentication_controller.rb

@ -7,8 +7,8 @@ class Devise::TwoFactorAuthenticationController < DeviseController
def update def update
render :show and return if params[:code].nil? render :show and return if params[:code].nil?
md5 = Digest::MD5.hexdigest(params[:code])
if md5.eql?(resource.second_factor_pass_code) if resource.authenticate_otp(params[:code])
warden.session(resource_name)[:need_two_factor_authentication] = false warden.session(resource_name)[:need_two_factor_authentication] = false
sign_in resource_name, resource, :bypass => true sign_in resource_name, resource, :bypass => true
redirect_to stored_location_for(resource_name) || :root redirect_to stored_location_for(resource_name) || :root

11
lib/generators/active_record/templates/migration.rb

@ -1,8 +1,15 @@
class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration
def change def up
change_table :<%= table_name %> do |t| change_table :<%= table_name %> do |t|
t.string :second_factor_pass_code , :limit => 32 t.string :otp_secret_key
t.integer :second_factor_attempts_count, :default => 0 t.integer :second_factor_attempts_count, :default => 0
end end
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
end end
end end

13
lib/two_factor_authentication.rb

@ -1,15 +1,18 @@
require 'two_factor_authentication/version' require 'two_factor_authentication/version'
require 'randexp'
require 'devise' require 'devise'
require 'digest'
require 'active_support/concern' require 'active_support/concern'
require "active_model"
require "active_record"
require "active_support/core_ext/class/attribute_accessors"
require "cgi"
require "rotp"
module Devise module Devise
mattr_accessor :login_code_random_pattern
@@login_code_random_pattern = /\w+/
mattr_accessor :max_login_attempts mattr_accessor :max_login_attempts
@@max_login_attempts = 3 @@max_login_attempts = 3
mattr_accessor :allowed_otp_drift_seconds
@@allowed_otp_drift_seconds = 30
end end
module TwoFactorAuthentication module TwoFactorAuthentication

5
lib/two_factor_authentication/hooks/two_factor_authenticatable.rb

@ -1,10 +1,7 @@
Warden::Manager.after_authentication do |user, auth, options| Warden::Manager.after_authentication do |user, auth, options|
if user.respond_to?(:need_two_factor_authentication?) if user.respond_to?(:need_two_factor_authentication?)
if auth.session(options[:scope])[:need_two_factor_authentication] = user.need_two_factor_authentication?(auth.request) if auth.session(options[:scope])[:need_two_factor_authentication] = user.need_two_factor_authentication?(auth.request)
code = user.generate_two_factor_code user.send_two_factor_authentication_code
user.second_factor_pass_code = Digest::MD5.hexdigest(code)
user.save
user.send_two_factor_authentication_code(code)
end end
end end
end end

62
lib/two_factor_authentication/models/two_factor_authenticatable.rb

@ -5,23 +5,61 @@ module Devise
extend ActiveSupport::Concern extend ActiveSupport::Concern
module ClassMethods module ClassMethods
::Devise::Models.config(self, :login_code_random_pattern, :max_login_attempts) def has_one_time_password(options = {})
end
def need_two_factor_authentication?(request) cattr_accessor :otp_column_name
true self.otp_column_name = "otp_secret_key"
end
def generate_two_factor_code include InstanceMethodsOnActivation
self.class.login_code_random_pattern.gen
end before_create { self.otp_column = ROTP::Base32.random_base32 }
def send_two_factor_authentication_code(code) if respond_to?(:attributes_protected_by_default)
p "Code is #{code}" def self.attributes_protected_by_default #:nodoc:
super + [self.otp_column_name]
end
end
end
::Devise::Models.config(self, :max_login_attempts, :allowed_otp_drift_seconds)
end end
def max_login_attempts? module InstanceMethodsOnActivation
second_factor_attempts_count >= self.class.max_login_attempts def authenticate_otp(code, options = {})
totp = ROTP::TOTP.new(self.otp_column)
drift = options[:drift] || self.class.allowed_otp_drift_seconds
totp.verify_with_drift(code, drift)
end
def otp_code(time = Time.now)
ROTP::TOTP.new(self.otp_column).at(time)
end
def provisioning_uri(account = nil)
account ||= self.email if self.respond_to?(:email)
ROTP::TOTP.new(self.otp_column).provisioning_uri(account)
end
def otp_column
self.send(self.class.otp_column_name)
end
def otp_column=(attr)
self.send("#{self.class.otp_column_name}=", attr)
end
def need_two_factor_authentication?(request)
true
end
def send_two_factor_authentication_code
raise NotImplementedError.new("No default implementation - please define in your class.")
end
def max_login_attempts?
second_factor_attempts_count >= self.class.max_login_attempts
end
end end
end end
end end

4
lib/two_factor_authentication/schema.rb

@ -1,7 +1,7 @@
module TwoFactorAuthentication module TwoFactorAuthentication
module Schema module Schema
def second_factor_pass_code def otp_secret_key
apply_devise_schema :second_factor_pass_code, String, :limit => 32 apply_devise_schema :otp_secret_key, String
end end
def second_factor_attempts_count def second_factor_attempts_count

70
spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb

@ -0,0 +1,70 @@
require 'spec_helper'
include AuthenticatedModelHelper
describe Devise::Models::TwoFactorAuthenticatable, '#otp_code' do
let(:instance) { AuthenticatedModelHelper.create_new_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
end
context "secret is set" do
before :each do
instance.otp_secret_key = "2z6hxkdwi3uvrnpn"
end
it "should not return an error" do
subject
end
context "with a known time" do
let(:time) { 1392852756 }
it "should return a known result" do
expect(subject).to eq(562202)
end
end
end
end
describe Devise::Models::TwoFactorAuthenticatable, '#authenticate_otp' do
let(:instance) { AuthenticatedModelHelper.create_new_user }
before :each do
instance.otp_secret_key = "2z6hxkdwi3uvrnpn"
end
def do_invoke code, options = {}
instance.authenticate_otp(code, options)
end
it "should be able to authenticate a recently created code" do
code = instance.otp_code
expect(do_invoke(code)).to eq(true)
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
describe Devise::Models::TwoFactorAuthenticatable, '#send_two_factor_authentication_code' do
it "should raise an error by default" do
instance = AuthenticatedModelHelper.create_new_user
expect {
instance.send_two_factor_authentication_code
}.to raise_error(NotImplementedError)
end
it "should be overrideable" do
instance = AuthenticatedModelHelper.create_new_user_with_overrides
expect(instance.send_two_factor_authentication_code).to eq("Code sent")
end
end

21
spec/spec_helper.rb

@ -0,0 +1,21 @@
require "rubygems"
require "bundler/setup"
require 'two_factor_authentication'
Dir["#{Dir.pwd}/spec/support/**/*.rb"].each {|f| require f}
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|
config.treat_symbols_as_metadata_keys_with_true_values = true
config.run_all_when_everything_filtered = true
config.filter_run :focus
# Run specs in random order to surface order dependencies. If you find an
# order dependency and want to debug it, you can fix the order by providing
# the seed, which is printed after each run.
# --seed 1234
config.order = 'random'
end

29
spec/support/authenticated_model_helper.rb

@ -0,0 +1,29 @@
module AuthenticatedModelHelper
class User
extend ActiveModel::Callbacks
include ActiveModel::Validations
include Devise::Models::TwoFactorAuthenticatable
define_model_callbacks :create
attr_accessor :otp_secret_key, :email
has_one_time_password
end
class UserWithOverrides < User
def send_two_factor_authentication_code
"Code sent"
end
end
def create_new_user
User.new
end
def create_new_user_with_overrides
UserWithOverrides.new
end
end

2
two_factor_authentication.gemspec

@ -24,9 +24,11 @@ Gem::Specification.new do |s|
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
s.require_paths = ["lib"] s.require_paths = ["lib"]
s.add_development_dependency "rspec"
s.add_runtime_dependency 'rails', '>= 3.1.1' s.add_runtime_dependency 'rails', '>= 3.1.1'
s.add_runtime_dependency 'devise' s.add_runtime_dependency 'devise'
s.add_runtime_dependency 'randexp' s.add_runtime_dependency 'randexp'
s.add_runtime_dependency 'rotp'
s.add_development_dependency 'bundler' s.add_development_dependency 'bundler'
end end

Loading…
Cancel
Save