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
dump.rdb
*.ids
.rbenv-version

56
README.md

@ -32,6 +32,20 @@ Finally, run the migration with:
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
@ -42,23 +56,22 @@ To manually enable two factor authentication for the User model, you should add
:recoverable, :rememberable, :trackable, :validatable, :two_factor_authenticatable
```
Two default parameters
Add the following line to your model to fully enable two-factor auth:
```ruby
config.devise.login_code_random_pattern = /\w+/
config.devise.max_login_attempts = 3
```
has_one_time_password
Possible random patterns
Set config values if desired for maximum second factor attempts count and allowed time drift for one-time passwords:
```ruby
/\d{5}/
/\w{4,8}/
```
config.max_login_attempts = 3
config.allowed_otp_drift_seconds = 30
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:
@ -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
Your send sms logic should be in this method in your User model:
```ruby
def send_two_factor_authentication_code(code)
puts code
end
```
This example just puts the code in the logs.
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:
### 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),
you might need install it:
```bash
apt-get install wbritish # or whichever you require
```
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`

4
app/controllers/devise/two_factor_authentication_controller.rb

@ -7,8 +7,8 @@ class Devise::TwoFactorAuthenticationController < DeviseController
def update
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
sign_in resource_name, resource, :bypass => true
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
def change
def up
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
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

13
lib/two_factor_authentication.rb

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

5
lib/two_factor_authentication/hooks/two_factor_authenticatable.rb

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

62
lib/two_factor_authentication/models/two_factor_authenticatable.rb

@ -5,23 +5,61 @@ module Devise
extend ActiveSupport::Concern
module ClassMethods
::Devise::Models.config(self, :login_code_random_pattern, :max_login_attempts)
end
def has_one_time_password(options = {})
def need_two_factor_authentication?(request)
true
end
cattr_accessor :otp_column_name
self.otp_column_name = "otp_secret_key"
def generate_two_factor_code
self.class.login_code_random_pattern.gen
end
include InstanceMethodsOnActivation
before_create { self.otp_column = ROTP::Base32.random_base32 }
def send_two_factor_authentication_code(code)
p "Code is #{code}"
if respond_to?(:attributes_protected_by_default)
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
def max_login_attempts?
second_factor_attempts_count >= self.class.max_login_attempts
module InstanceMethodsOnActivation
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

4
lib/two_factor_authentication/schema.rb

@ -1,7 +1,7 @@
module TwoFactorAuthentication
module Schema
def second_factor_pass_code
apply_devise_schema :second_factor_pass_code, String, :limit => 32
def otp_secret_key
apply_devise_schema :otp_secret_key, String
end
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.require_paths = ["lib"]
s.add_development_dependency "rspec"
s.add_runtime_dependency 'rails', '>= 3.1.1'
s.add_runtime_dependency 'devise'
s.add_runtime_dependency 'randexp'
s.add_runtime_dependency 'rotp'
s.add_development_dependency 'bundler'
end

Loading…
Cancel
Save