├── .rspec ├── demo ├── log │ └── .keep ├── app │ ├── mailers │ │ └── .keep │ ├── models │ │ ├── .keep │ │ ├── concerns │ │ │ └── .keep │ │ └── user.rb │ ├── assets │ │ ├── images │ │ │ └── .keep │ │ ├── stylesheets │ │ │ ├── home.css.scss │ │ │ └── application.css │ │ └── javascripts │ │ │ ├── home.js.coffee │ │ │ └── application.js │ ├── controllers │ │ ├── concerns │ │ │ └── .keep │ │ ├── home_controller.rb │ │ ├── application_controller.rb │ │ └── users_controller.rb │ ├── helpers │ │ ├── home_helper.rb │ │ └── application_helper.rb │ └── views │ │ ├── devise │ │ ├── mailer │ │ │ ├── confirmation_instructions.html.erb │ │ │ ├── unlock_instructions.html.erb │ │ │ └── reset_password_instructions.html.erb │ │ ├── unlocks │ │ │ └── new.html.erb │ │ ├── passwords │ │ │ ├── new.html.erb │ │ │ └── edit.html.erb │ │ ├── confirmations │ │ │ └── new.html.erb │ │ ├── registrations │ │ │ ├── new.html.erb │ │ │ └── edit.html.erb │ │ ├── sessions │ │ │ └── new.html.erb │ │ └── shared │ │ │ └── _links.erb │ │ ├── layouts │ │ └── application.html.erb │ │ └── home │ │ └── index.html.erb ├── lib │ ├── assets │ │ └── .keep │ └── tasks │ │ └── .keep ├── public │ ├── favicon.ico │ ├── robots.txt │ ├── 500.html │ ├── 422.html │ └── 404.html ├── test │ ├── helpers │ │ ├── .keep │ │ └── home_helper_test.rb │ ├── mailers │ │ └── .keep │ ├── models │ │ ├── .keep │ │ └── user_test.rb │ ├── controllers │ │ ├── .keep │ │ └── home_controller_test.rb │ ├── fixtures │ │ ├── .keep │ │ └── users.yml │ ├── integration │ │ └── .keep │ └── test_helper.rb ├── vendor │ └── assets │ │ ├── javascripts │ │ └── .keep │ │ └── stylesheets │ │ └── .keep ├── bin │ ├── rake │ ├── bundle │ └── rails ├── config │ ├── initializers │ │ ├── cookies_serializer.rb │ │ ├── session_store.rb │ │ ├── mime_types.rb │ │ ├── filter_parameter_logging.rb │ │ ├── backtrace_silencers.rb │ │ ├── wrap_parameters.rb │ │ ├── devise.rb │ │ └── inflections.rb │ ├── environment.rb │ ├── boot.rb │ ├── routes.rb │ ├── locales │ │ ├── en.yml │ │ └── devise.en.yml │ ├── secrets.yml │ ├── application.rb │ ├── environments │ │ ├── development.rb │ │ ├── test.rb │ │ └── production.rb │ └── database.yml ├── config.ru ├── Rakefile ├── db │ ├── seeds.rb │ ├── migrate │ │ ├── 20140516191259_add_devise_two_factor_to_users.rb │ │ └── 20140515190128_devise_create_users.rb │ └── schema.rb ├── .gitignore ├── README.rdoc └── Gemfile ├── Gemfile ├── lib ├── devise_two_factor │ ├── version.rb │ ├── models.rb │ ├── strategies.rb │ ├── spec_helpers.rb │ ├── strategies │ │ ├── two_factor_backupable.rb │ │ └── two_factor_authenticatable.rb │ ├── models │ │ ├── two_factor_authenticatable.rb │ │ └── two_factor_backupable.rb │ └── spec_helpers │ │ ├── two_factor_authenticatable_shared_examples.rb │ │ └── two_factor_backupable_shared_examples.rb ├── devise-two-factor.rb └── generators │ └── devise_two_factor │ └── devise_two_factor_generator.rb ├── .travis.yml ├── spec ├── devise │ └── models │ │ ├── two_factor_authenticatable_spec.rb │ │ └── two_factor_backupable_spec.rb └── spec_helper.rb ├── Rakefile ├── .gitignore ├── LICENSE ├── CONTRIBUTING.md ├── devise-two-factor.gemspec ├── certs ├── tinfoilsecurity-gems-cert.pem └── tinfoil-cacert.pem └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /demo/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/test/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/test/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/test/fixtures/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/test/integration/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /demo/app/helpers/home_helper.rb: -------------------------------------------------------------------------------- 1 | module HomeHelper 2 | end 3 | -------------------------------------------------------------------------------- /demo/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /lib/devise_two_factor/version.rb: -------------------------------------------------------------------------------- 1 | module DeviseTwoFactor 2 | VERSION = '1.0.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /demo/app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | class HomeController < ApplicationController 2 | def index 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /demo/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /demo/test/helpers/home_helper_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class HomeHelperTest < ActionView::TestCase 4 | end 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | rvm: 4 | - "1.9.3" 5 | - "2.0.0" 6 | - jruby-19mode # JRuby in 1.9 mode 7 | -------------------------------------------------------------------------------- /demo/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /lib/devise_two_factor/models.rb: -------------------------------------------------------------------------------- 1 | require 'devise_two_factor/models/two_factor_authenticatable' 2 | require 'devise_two_factor/models/two_factor_backupable' 3 | -------------------------------------------------------------------------------- /lib/devise_two_factor/strategies.rb: -------------------------------------------------------------------------------- 1 | require 'devise_two_factor/strategies/two_factor_authenticatable' 2 | require 'devise_two_factor/strategies/two_factor_backupable' 3 | -------------------------------------------------------------------------------- /demo/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /demo/test/models/user_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class UserTest < ActiveSupport::TestCase 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /demo/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.action_dispatch.cookies_serializer = :json -------------------------------------------------------------------------------- /demo/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /demo/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /demo/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | -------------------------------------------------------------------------------- /demo/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_DeviseTwoFactorDemo_session' 4 | -------------------------------------------------------------------------------- /lib/devise_two_factor/spec_helpers.rb: -------------------------------------------------------------------------------- 1 | require 'devise_two_factor/spec_helpers/two_factor_authenticatable_shared_examples' 2 | require 'devise_two_factor/spec_helpers/two_factor_backupable_shared_examples' 3 | -------------------------------------------------------------------------------- /demo/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /demo/app/assets/stylesheets/home.css.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the home controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /demo/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | get 'home/index' 3 | post 'users/enable_otp' 4 | post 'users/disable_otp' 5 | 6 | devise_for :users 7 | 8 | root to: "home#index", via: [:get, :post] 9 | end 10 | -------------------------------------------------------------------------------- /demo/public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /demo/test/controllers/home_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class HomeControllerTest < ActionController::TestCase 4 | test "should get index" do 5 | get :index 6 | assert_response :success 7 | end 8 | 9 | end 10 | -------------------------------------------------------------------------------- /demo/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /demo/app/assets/javascripts/home.js.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /demo/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | end 6 | -------------------------------------------------------------------------------- /demo/app/views/devise/mailer/confirmation_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Welcome <%= @email %>!

2 | 3 |

You can confirm your account email through the link below:

4 | 5 |

<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>

6 | -------------------------------------------------------------------------------- /demo/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /demo/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | devise :two_factor_authenticatable, 3 | :otp_secret_encryption_key => ENV['your_encryption_key_here'] 4 | 5 | devise :registerable, 6 | :recoverable, :rememberable, :trackable, :validatable 7 | end 8 | -------------------------------------------------------------------------------- /demo/app/views/devise/mailer/unlock_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @resource.email %>!

2 | 3 |

Your account has been locked due to an excessive number of unsuccessful sign in attempts.

4 | 5 |

Click the link below to unlock your account:

6 | 7 |

<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>

8 | -------------------------------------------------------------------------------- /demo/db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | -------------------------------------------------------------------------------- /demo/db/migrate/20140516191259_add_devise_two_factor_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddDeviseTwoFactorToUsers < ActiveRecord::Migration 2 | def change 3 | add_column :users, :encrypted_otp_secret, :string 4 | add_column :users, :encrypted_otp_secret_iv, :string 5 | add_column :users, :encrypted_otp_secret_salt, :string 6 | add_column :users, :otp_required_for_login, :boolean 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /demo/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | DeviseTwoFactorDemo 5 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> 6 | <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %> 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /demo/test/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | # This model initially had no columns defined. If you add columns to the 4 | # model remove the '{}' from the fixture names and add the columns immediately 5 | # below each fixture, per the syntax in the comments below 6 | # 7 | one: {} 8 | # column: value 9 | # 10 | two: {} 11 | # column: value 12 | -------------------------------------------------------------------------------- /demo/app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | def disable_otp 3 | current_user.otp_required_for_login = false 4 | current_user.save! 5 | redirect_to home_index_path 6 | end 7 | 8 | def enable_otp 9 | current_user.otp_secret = User.generate_otp_secret 10 | current_user.otp_required_for_login = true 11 | current_user.save! 12 | redirect_to home_index_path 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /demo/app/views/devise/unlocks/new.html.erb: -------------------------------------------------------------------------------- 1 |

Resend unlock instructions

2 | 3 | <%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= devise_error_messages! %> 5 | 6 |
<%= f.label :email %>
7 | <%= f.email_field :email, autofocus: true %>
8 | 9 |
<%= f.submit "Resend unlock instructions" %>
10 | <% end %> 11 | 12 | <%= render "devise/shared/links" %> 13 | -------------------------------------------------------------------------------- /demo/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /demo/app/views/devise/passwords/new.html.erb: -------------------------------------------------------------------------------- 1 |

Forgot your password?

2 | 3 | <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= devise_error_messages! %> 5 | 6 |
<%= f.label :email %>
7 | <%= f.email_field :email, autofocus: true %>
8 | 9 |
<%= f.submit "Send me reset password instructions" %>
10 | <% end %> 11 | 12 | <%= render "devise/shared/links" %> 13 | -------------------------------------------------------------------------------- /demo/app/views/devise/mailer/reset_password_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @resource.email %>!

2 | 3 |

Someone has requested a link to change your password. You can do this through the link below.

4 | 5 |

<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>

6 | 7 |

If you didn't request this, please ignore this email.

8 |

Your password won't change until you access the link above and create a new one.

9 | -------------------------------------------------------------------------------- /demo/app/views/devise/confirmations/new.html.erb: -------------------------------------------------------------------------------- 1 |

Resend confirmation instructions

2 | 3 | <%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= devise_error_messages! %> 5 | 6 |
<%= f.label :email %>
7 | <%= f.email_field :email, autofocus: true %>
8 | 9 |
<%= f.submit "Resend confirmation instructions" %>
10 | <% end %> 11 | 12 | <%= render "devise/shared/links" %> 13 | -------------------------------------------------------------------------------- /demo/test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | require File.expand_path('../../config/environment', __FILE__) 3 | require 'rails/test_help' 4 | 5 | class ActiveSupport::TestCase 6 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 7 | # 8 | # Note: You'll currently still have to declare fixtures explicitly in integration tests 9 | # -- they do not yet inherit this setting 10 | fixtures :all 11 | 12 | # Add more helper methods to be used by all tests here... 13 | end 14 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/*.log 16 | /tmp 17 | -------------------------------------------------------------------------------- /spec/devise/models/two_factor_authenticatable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class TwoFactorAuthenticatableDouble 4 | include ::ActiveModel::Validations::Callbacks 5 | extend ::Devise::Models 6 | 7 | devise :two_factor_authenticatable, :otp_secret_encryption_key => 'test-key' 8 | end 9 | 10 | describe ::Devise::Models::TwoFactorAuthenticatable do 11 | context 'When included in a class' do 12 | subject { TwoFactorAuthenticatableDouble.new } 13 | 14 | it_behaves_like 'two_factor_authenticatable' 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/devise/models/two_factor_backupable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class TwoFactorBackupableDouble 4 | include ::ActiveModel::Validations::Callbacks 5 | extend ::Devise::Models 6 | 7 | devise :two_factor_authenticatable, :two_factor_backupable, 8 | :otp_secret_encryption_key => 'test-key' 9 | 10 | attr_accessor :otp_backup_codes 11 | end 12 | 13 | describe ::Devise::Models::TwoFactorBackupable do 14 | context 'When included in a class' do 15 | subject { TwoFactorBackupableDouble.new } 16 | 17 | it_behaves_like 'two_factor_backupable' 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /demo/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /demo/README.rdoc: -------------------------------------------------------------------------------- 1 | == README 2 | 3 | This README would normally document whatever steps are necessary to get the 4 | application up and running. 5 | 6 | Things you may want to cover: 7 | 8 | * Ruby version 9 | 10 | * System dependencies 11 | 12 | * Configuration 13 | 14 | * Database creation 15 | 16 | * Database initialization 17 | 18 | * How to run the test suite 19 | 20 | * Services (job queues, cache servers, search engines, etc.) 21 | 22 | * Deployment instructions 23 | 24 | * ... 25 | 26 | 27 | Please feel free to use a different markup language if you do not plan to run 28 | rake doc:app. 29 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | begin 6 | Bundler.setup(:default, :development) 7 | rescue Bundler::BundlerError => e 8 | $stderr.puts e.message 9 | $stderr.puts "Run `bundle install` to install missing gems" 10 | exit e.status_code 11 | end 12 | require 'rake' 13 | 14 | require 'rspec/core' 15 | require 'rspec/core/rake_task' 16 | RSpec::Core::RakeTask.new(:spec) do |spec| 17 | spec.pattern = FileList['spec/**/*_spec.rb'] 18 | end 19 | 20 | desc "Code coverage detail" 21 | task :simplecov do 22 | ENV['COVERAGE'] = "true" 23 | Rake::Task['spec'].execute 24 | end 25 | 26 | task :default => :spec 27 | -------------------------------------------------------------------------------- /demo/app/views/devise/registrations/new.html.erb: -------------------------------------------------------------------------------- 1 |

Sign up

2 | 3 | <%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %> 4 | <%= devise_error_messages! %> 5 | 6 |
<%= f.label :email %>
7 | <%= f.email_field :email, autofocus: true %>
8 | 9 |
<%= f.label :password %>
10 | <%= f.password_field :password, autocomplete: "off" %>
11 | 12 |
<%= f.label :password_confirmation %>
13 | <%= f.password_field :password_confirmation, autocomplete: "off" %>
14 | 15 |
<%= f.submit "Sign up" %>
16 | <% end %> 17 | 18 | <%= render "devise/shared/links" %> 19 | -------------------------------------------------------------------------------- /demo/config/initializers/devise.rb: -------------------------------------------------------------------------------- 1 | Devise.setup do |config| 2 | config.warden do |manager| 3 | manager.default_strategies(:scope => :user).unshift :two_factor_authenticatable 4 | end 5 | 6 | config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com' 7 | 8 | require 'devise/orm/active_record' 9 | 10 | config.case_insensitive_keys = [ :email ] 11 | config.strip_whitespace_keys = [ :email ] 12 | config.skip_session_storage = [:http_auth] 13 | config.stretches = Rails.env.test? ? 1 : 10 14 | config.reconfirmable = true 15 | config.password_length = 8..128 16 | config.reset_password_within = 6.hours 17 | config.sign_out_via = :delete 18 | end 19 | -------------------------------------------------------------------------------- /demo/app/views/devise/passwords/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Change your password

2 | 3 | <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %> 4 | <%= devise_error_messages! %> 5 | <%= f.hidden_field :reset_password_token %> 6 | 7 |
<%= f.label :password, "New password" %>
8 | <%= f.password_field :password, autofocus: true, autocomplete: "off" %>
9 | 10 |
<%= f.label :password_confirmation, "Confirm new password" %>
11 | <%= f.password_field :password_confirmation, autocomplete: "off" %>
12 | 13 |
<%= f.submit "Change my password" %>
14 | <% end %> 15 | 16 | <%= render "devise/shared/links" %> 17 | -------------------------------------------------------------------------------- /demo/app/views/devise/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 |

Sign in

2 | 3 | <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %> 4 |
<%= f.label :email %>
5 | <%= f.email_field :email, autofocus: true %>
6 | 7 |
<%= f.label :password %>
8 | <%= f.password_field :password, autocomplete: "off" %>
9 | 10 |
<%= f.label :otp_attempt %>
11 | <%= f.text_field :otp_attempt %>
12 | 13 | <% if devise_mapping.rememberable? -%> 14 |
<%= f.check_box :remember_me %> <%= f.label :remember_me %>
15 | <% end -%> 16 | 17 |
<%= f.submit "Sign in" %>
18 | <% end %> 19 | 20 | <%= render "devise/shared/links" %> 21 | -------------------------------------------------------------------------------- /demo/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /demo/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /demo/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | //= require turbolinks 16 | //= require_tree . 17 | -------------------------------------------------------------------------------- /demo/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | 3 | module SimpleCov::Configuration 4 | def clean_filters 5 | @filters = [] 6 | end 7 | end 8 | 9 | SimpleCov.configure do 10 | clean_filters 11 | load_profile 'test_frameworks' 12 | end 13 | 14 | ENV["COVERAGE"] && SimpleCov.start do 15 | add_filter "/.rvm/" 16 | end 17 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 18 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 19 | 20 | require 'rspec' 21 | require 'faker' 22 | require 'timecop' 23 | require 'devise-two-factor' 24 | require 'devise_two_factor/spec_helpers' 25 | 26 | # Requires supporting files with custom matchers and macros, etc, 27 | # in ./support/ and its subdirectories. 28 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f} 29 | 30 | RSpec.configure do |config| 31 | config.order = 'random' 32 | end 33 | -------------------------------------------------------------------------------- /demo/app/views/home/index.html.erb: -------------------------------------------------------------------------------- 1 | <% if !current_user %> 2 | <%= link_to "Sign up", new_user_registration_path %> 3 | <%= link_to "Login", new_user_session_path %> 4 | <% end %> 5 | 6 | <% if current_user %> 7 | <% if !current_user.otp_required_for_login %> 8 | <%= button_to "Enable 2FA", users_enable_otp_path, :method => :post %> 9 | <% end %> 10 | 11 | <% if current_user.otp_required_for_login %> 12 | <%= button_to "Disable 2FA", users_disable_otp_path, :method => :post %> 13 | <%= raw RQRCode::render_qrcode(current_user.otp_provisioning_uri(current_user.email, issuer: "Devise-Two-Factor-Demo"), 14 | :svg, 15 | :level => :l, 16 | :unit => 2) %> 17 |
18 | <% end %> 19 | <%= link_to "Log out", destroy_user_session_path, :method => :delete %> 20 | <% end %> 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | *.gem 3 | 4 | # rcov generated 5 | coverage 6 | coverage.data 7 | 8 | # yard generated 9 | doc 10 | .yardoc 11 | 12 | # bundler 13 | .bundle 14 | 15 | # jeweler generated 16 | pkg 17 | 18 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 19 | # 20 | # * Create a file at ~/.gitignore 21 | # * Include files you want ignored 22 | # * Run: git config --global core.excludesfile ~/.gitignore 23 | # 24 | # After doing this, these files will be ignored in all your git projects, 25 | # saving you from having to 'pollute' every project you touch with them 26 | # 27 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 28 | # 29 | # For MacOS: 30 | # 31 | #.DS_Store 32 | 33 | # For TextMate 34 | #*.tmproj 35 | #tmtags 36 | 37 | # For emacs: 38 | #*~ 39 | #\#* 40 | #.\#* 41 | 42 | # For vim: 43 | #*.swp 44 | 45 | # For redcar: 46 | #.redcar 47 | 48 | # For rubinius: 49 | #*.rbc 50 | -------------------------------------------------------------------------------- /lib/devise_two_factor/strategies/two_factor_backupable.rb: -------------------------------------------------------------------------------- 1 | module Devise 2 | module Strategies 3 | class TwoFactorBackupable < Devise::Strategies::DatabaseAuthenticatable 4 | 5 | def authenticate! 6 | resource = mapping.to.find_for_database_authentication(authentication_hash) 7 | 8 | if validate(resource) { resource.invalidate_otp_backup_code!(params[scope]['otp_attempt']) } 9 | # Devise fails to authenticate invalidated resources, but if we've 10 | # gotten here, the object changed (Since we deleted a recovery code) 11 | resource.save! 12 | super 13 | end 14 | 15 | fail(:not_found_in_database) unless resource 16 | 17 | # We want to cascade to the next strategy if this one fails, 18 | # but database authenticatable automatically halts on a bad password 19 | @halted = false if @result == :failure 20 | end 21 | end 22 | end 23 | end 24 | 25 | Warden::Strategies.add(:two_factor_backupable, Devise::Strategies::TwoFactorBackupable) 26 | -------------------------------------------------------------------------------- /demo/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: c8c845b85a69d2b67ec99c1912ddcde63f0fcd05a74052f396daf68a2688d576c203bdc239c9423a0cf88218e201d30616959d5eeceea00eedf915677ecd373b 15 | 16 | test: 17 | secret_key_base: df468dd66c4b0f143354d216d96cdb8542ffe215afd78ebf977f7a8bfdb93010db134320cb738d27385bd7884cd73f3f22f90b147d4d176c3c8d0d63d5427fa9 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /demo/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'rails/all' 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module DeviseTwoFactorDemo 10 | class Application < Rails::Application 11 | # Settings in config/environments/* take precedence over those specified here. 12 | # Application configuration should go into files in config/initializers 13 | # -- all .rb files in that directory are automatically loaded. 14 | 15 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 16 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 17 | # config.time_zone = 'Central Time (US & Canada)' 18 | 19 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 20 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 21 | # config.i18n.default_locale = :de 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Tinfoil Security, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/devise_two_factor/strategies/two_factor_authenticatable.rb: -------------------------------------------------------------------------------- 1 | module Devise 2 | module Strategies 3 | class TwoFactorAuthenticatable < Devise::Strategies::DatabaseAuthenticatable 4 | 5 | def authenticate! 6 | resource = mapping.to.find_for_database_authentication(authentication_hash) 7 | # We authenticate in two cases: 8 | # 1. The password and the OTP are correct 9 | # 2. The password is correct, and OTP is not required for login 10 | # We check the OTP, then defer to DatabaseAuthenticatable 11 | if validate(resource) { !resource.otp_required_for_login || 12 | resource.valid_otp?(params[scope]['otp_attempt']) } 13 | super 14 | end 15 | 16 | fail(:not_found_in_database) unless resource 17 | 18 | # We want to cascade to the next strategy if this one fails, 19 | # but database authenticatable automatically halts on a bad password 20 | @halted = false if @result == :failure 21 | end 22 | end 23 | end 24 | end 25 | 26 | Warden::Strategies.add(:two_factor_authenticatable, Devise::Strategies::TwoFactorAuthenticatable) 27 | -------------------------------------------------------------------------------- /lib/devise-two-factor.rb: -------------------------------------------------------------------------------- 1 | require 'devise' 2 | require 'devise_two_factor/models' 3 | require 'devise_two_factor/strategies' 4 | 5 | module Devise 6 | # The length of generated OTP secrets 7 | mattr_accessor :otp_secret_length 8 | @@otp_secret_length = 128 9 | 10 | # The number of seconds before and after the current 11 | # time for which codes will be accepted 12 | mattr_accessor :otp_allowed_drift 13 | @@otp_allowed_drift = 30 14 | 15 | # The key used to encrypt OTP secrets in the database 16 | mattr_accessor :otp_secret_encryption_key 17 | @@otp_secret_encryption_key = nil 18 | 19 | # The length of all generated OTP backup codes 20 | mattr_accessor :otp_backup_code_length 21 | @@otp_backup_code_length = 16 22 | 23 | # The number of backup codes generated by a call to 24 | # generate_otp_backup_codes! 25 | mattr_accessor :otp_number_of_backup_codes 26 | @@otp_number_of_backup_codes = 5 27 | end 28 | 29 | Devise.add_module(:two_factor_authenticatable, :route => :session, :strategy => true, 30 | :controller => :sessions, :model => true) 31 | 32 | Devise.add_module(:two_factor_backupable, :route => :session, :strategy => true, 33 | :controller => :sessions, :model => true) 34 | -------------------------------------------------------------------------------- /demo/app/views/devise/shared/_links.erb: -------------------------------------------------------------------------------- 1 | <%- if controller_name != 'sessions' %> 2 | <%= link_to "Sign in", new_session_path(resource_name) %>
3 | <% end -%> 4 | 5 | <%- if devise_mapping.registerable? && controller_name != 'registrations' %> 6 | <%= link_to "Sign up", new_registration_path(resource_name) %>
7 | <% end -%> 8 | 9 | <%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> 10 | <%= link_to "Forgot your password?", new_password_path(resource_name) %>
11 | <% end -%> 12 | 13 | <%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> 14 | <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
15 | <% end -%> 16 | 17 | <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> 18 | <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
19 | <% end -%> 20 | 21 | <%- if devise_mapping.omniauthable? %> 22 | <%- resource_class.omniauth_providers.each do |provider| %> 23 | <%= link_to "Sign in with #{provider.to_s.titleize}", omniauth_authorize_path(resource_name, provider) %>
24 | <% end -%> 25 | <% end -%> 26 | -------------------------------------------------------------------------------- /demo/app/views/devise/registrations/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Edit <%= resource_name.to_s.humanize %>

2 | 3 | <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %> 4 | <%= devise_error_messages! %> 5 | 6 |
<%= f.label :email %>
7 | <%= f.email_field :email, autofocus: true %>
8 | 9 | <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %> 10 |
Currently waiting confirmation for: <%= resource.unconfirmed_email %>
11 | <% end %> 12 | 13 |
<%= f.label :password %> (leave blank if you don't want to change it)
14 | <%= f.password_field :password, autocomplete: "off" %>
15 | 16 |
<%= f.label :password_confirmation %>
17 | <%= f.password_field :password_confirmation, autocomplete: "off" %>
18 | 19 |
<%= f.label :current_password %> (we need your current password to confirm your changes)
20 | <%= f.password_field :current_password, autocomplete: "off" %>
21 | 22 |
<%= f.submit "Update" %>
23 | <% end %> 24 | 25 |

Cancel my account

26 | 27 |

Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete %>

28 | 29 | <%= link_to "Back", :back %> 30 | -------------------------------------------------------------------------------- /demo/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | 4 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 5 | gem 'rails', '4.1.0' 6 | # Use postgresql as the database for Active Record 7 | gem 'pg' 8 | # Use SCSS for stylesheets 9 | gem 'sass-rails', '~> 4.0.3' 10 | # Use Uglifier as compressor for JavaScript assets 11 | gem 'uglifier', '>= 1.3.0' 12 | # Use CoffeeScript for .js.coffee assets and views 13 | gem 'coffee-rails', '~> 4.0.0' 14 | # See https://github.com/sstephenson/execjs#readme for more supported runtimes 15 | # gem 'therubyracer', platforms: :ruby 16 | 17 | # Use jquery as the JavaScript library 18 | gem 'jquery-rails' 19 | # Turbolinks makes following links in your web application faster. Read more: https://github.com/rails/turbolinks 20 | gem 'turbolinks' 21 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 22 | gem 'jbuilder', '~> 2.0' 23 | # bundle exec rake doc:rails generates the API under doc/api. 24 | gem 'sdoc', '~> 0.4.0', group: :doc 25 | 26 | gem 'devise' 27 | gem 'devise-two-factor', :path => '../' 28 | gem 'rqrcode-rails3' 29 | 30 | # Use ActiveModel has_secure_password 31 | # gem 'bcrypt', '~> 3.1.7' 32 | 33 | # Use unicorn as the app server 34 | # gem 'unicorn' 35 | 36 | # Use Capistrano for deployment 37 | # gem 'capistrano-rails', group: :development 38 | 39 | # Use debugger 40 | # gem 'debugger', group: [:development, :test] 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | We love pull requests. Here's a quick guide: 2 | 3 | 1. Fork the repo. 4 | 5 | 2. Run the tests. We only take pull requests with passing tests, and it's great 6 | to know that you have a clean slate: `bundle && rake` 7 | 8 | 3. Add a test for your change. Only refactoring and documentation changes 9 | require no new tests. If you are adding functionality or fixing a bug, we need 10 | a test! 11 | 12 | 4. Make the test pass. 13 | 14 | 5. Push to your fork and submit a pull request. 15 | 16 | 17 | At this point you're waiting on us. We like to at least comment on, if not 18 | accept, pull requests within three business days (and, typically, one business 19 | day). We may suggest some changes or improvements or alternatives. 20 | 21 | Some things that will increase the chance that your pull request is accepted, 22 | taken straight from the Ruby on Rails guide: 23 | 24 | * Use Rails idioms and helpers 25 | * Include tests that fail without your code, and pass with it 26 | * Update the documentation, the surrounding one, examples elsewhere, guides, 27 | whatever is affected by your contribution 28 | 29 | Syntax: 30 | 31 | * Two spaces, no tabs. 32 | * No trailing whitespace. Blank lines should not have any space. 33 | * Prefer &&/|| over and/or. 34 | * MyClass.my_method(my_arg) not my_method( my_arg ) or my_method my_arg. 35 | * a = b and not a=b. 36 | * Follow the conventions you see used in the source already. 37 | 38 | And in case we didn't emphasize it enough: we love tests! 39 | -------------------------------------------------------------------------------- /demo/db/migrate/20140515190128_devise_create_users.rb: -------------------------------------------------------------------------------- 1 | class DeviseCreateUsers < ActiveRecord::Migration 2 | def change 3 | create_table(:users) do |t| 4 | ## Database authenticatable 5 | t.string :email, null: false, default: "" 6 | t.string :encrypted_password, null: false, default: "" 7 | 8 | ## Recoverable 9 | t.string :reset_password_token 10 | t.datetime :reset_password_sent_at 11 | 12 | ## Rememberable 13 | t.datetime :remember_created_at 14 | 15 | ## Trackable 16 | t.integer :sign_in_count, default: 0, null: false 17 | t.datetime :current_sign_in_at 18 | t.datetime :last_sign_in_at 19 | t.string :current_sign_in_ip 20 | t.string :last_sign_in_ip 21 | 22 | ## Confirmable 23 | # t.string :confirmation_token 24 | # t.datetime :confirmed_at 25 | # t.datetime :confirmation_sent_at 26 | # t.string :unconfirmed_email # Only if using reconfirmable 27 | 28 | ## Lockable 29 | # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts 30 | # t.string :unlock_token # Only if unlock strategy is :email or :both 31 | # t.datetime :locked_at 32 | 33 | 34 | t.timestamps 35 | end 36 | 37 | add_index :users, :email, unique: true 38 | add_index :users, :reset_password_token, unique: true 39 | # add_index :users, :confirmation_token, unique: true 40 | # add_index :users, :unlock_token, unique: true 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /demo/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | 22 | # Raise an error on page load if there are pending migrations. 23 | config.active_record.migration_error = :page_load 24 | 25 | # Debug mode disables concatenation and preprocessing of assets. 26 | # This option may cause significant delays in view rendering with a large 27 | # number of complex assets. 28 | config.assets.debug = true 29 | 30 | # Adds additional error checking when serving assets at runtime. 31 | # Checks for improperly declared sprockets dependencies. 32 | # Raises helpful error messages. 33 | config.assets.raise_runtime_errors = true 34 | 35 | # Raises error for missing translations 36 | # config.action_view.raise_on_missing_translations = true 37 | end 38 | -------------------------------------------------------------------------------- /devise-two-factor.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path('../lib', __FILE__) 2 | require 'devise_two_factor/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'devise-two-factor' 6 | s.version = DeviseTwoFactor::VERSION.dup 7 | s.platform = Gem::Platform::RUBY 8 | s.licenses = ['MIT'] 9 | s.summary = 'Barebones two-factor authentication with Devise' 10 | s.email = 'engineers@tinfoilsecurity.com' 11 | s.homepage = 'https://github.com/tinfoil/devise-two-factor' 12 | s.description = 'Barebones two-factor authentication with Devise' 13 | s.authors = ['Shane Wilton'] 14 | 15 | s.cert_chain = [ 16 | 'certs/tinfoil-cacert.pem', 17 | 'certs/tinfoilsecurity-gems-cert.pem' 18 | ] 19 | s.signing_key = File.expand_path("~/.ssh/tinfoilsecurity-gems-key.pem") if $0 =~ /gem\z/ 20 | 21 | s.rubyforge_project = 'devise-two-factor' 22 | 23 | s.files = `git ls-files`.split("\n").delete_if { |x| x.match('demo/*') } 24 | s.test_files = `git ls-files -- spec/*`.split("\n") 25 | s.require_paths = ['lib'] 26 | 27 | s.add_runtime_dependency 'rails' # For generators 28 | s.add_runtime_dependency 'activesupport' 29 | s.add_runtime_dependency 'activemodel' 30 | s.add_runtime_dependency 'attr_encrypted' 31 | s.add_runtime_dependency 'devise' 32 | s.add_runtime_dependency 'rotp' 33 | 34 | s.add_development_dependency 'bundler', '> 1.0' 35 | s.add_development_dependency 'rspec', '~> 2.8' 36 | s.add_development_dependency 'simplecov' 37 | s.add_development_dependency 'faker' 38 | s.add_development_dependency 'timecop' 39 | end 40 | -------------------------------------------------------------------------------- /demo/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /demo/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static asset server for tests with Cache-Control for performance. 16 | config.serve_static_assets = true 17 | config.static_cache_control = 'public, max-age=3600' 18 | 19 | # Show full error reports and disable caching. 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | 23 | # Raise exceptions instead of rendering exception templates. 24 | config.action_dispatch.show_exceptions = false 25 | 26 | # Disable request forgery protection in test environment. 27 | config.action_controller.allow_forgery_protection = false 28 | 29 | # Tell Action Mailer not to deliver emails to the real world. 30 | # The :test delivery method accumulates sent emails in the 31 | # ActionMailer::Base.deliveries array. 32 | config.action_mailer.delivery_method = :test 33 | 34 | # Print deprecation notices to the stderr. 35 | config.active_support.deprecation = :stderr 36 | 37 | # Raises error for missing translations 38 | # config.action_view.raise_on_missing_translations = true 39 | end 40 | -------------------------------------------------------------------------------- /demo/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /demo/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /demo/db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(version: 20140516191259) do 15 | 16 | # These are extensions that must be enabled in order to support this database 17 | enable_extension "plpgsql" 18 | 19 | create_table "users", force: true do |t| 20 | t.string "email", default: "", null: false 21 | t.string "encrypted_password", default: "", null: false 22 | t.string "reset_password_token" 23 | t.datetime "reset_password_sent_at" 24 | t.datetime "remember_created_at" 25 | t.integer "sign_in_count", default: 0, null: false 26 | t.datetime "current_sign_in_at" 27 | t.datetime "last_sign_in_at" 28 | t.string "current_sign_in_ip" 29 | t.string "last_sign_in_ip" 30 | t.datetime "created_at" 31 | t.datetime "updated_at" 32 | t.string "encrypted_otp_secret" 33 | t.string "encrypted_otp_secret_iv" 34 | t.string "encrypted_otp_secret_salt" 35 | t.boolean "otp_required_for_login" 36 | end 37 | 38 | add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree 39 | add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree 40 | 41 | end 42 | -------------------------------------------------------------------------------- /lib/devise_two_factor/models/two_factor_authenticatable.rb: -------------------------------------------------------------------------------- 1 | require 'active_model' 2 | require 'attr_encrypted' 3 | require 'rotp' 4 | 5 | module Devise 6 | module Models 7 | module TwoFactorAuthenticatable 8 | extend ActiveSupport::Concern 9 | include Devise::Models::DatabaseAuthenticatable 10 | 11 | included do 12 | attr_encrypted :otp_secret, :key => self.otp_secret_encryption_key, 13 | :mode => :per_attribute_iv_and_salt 14 | 15 | attr_accessor :otp_attempt 16 | end 17 | 18 | def self.required_fields(klass) 19 | [:encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt] 20 | end 21 | 22 | # This defaults to the model's otp_secret 23 | # If this hasn't been generated yet, pass a secret as an option 24 | def valid_otp?(code, options = {}) 25 | otp_secret = options[:otp_secret] || self.otp_secret 26 | return false unless otp_secret.present? 27 | 28 | totp = self.otp(otp_secret) 29 | totp.verify_with_drift(code, self.class.otp_allowed_drift) 30 | end 31 | 32 | def otp(otp_secret = self.otp_secret) 33 | ROTP::TOTP.new(otp_secret) 34 | end 35 | 36 | def current_otp 37 | otp.at(Time.now) 38 | end 39 | 40 | def otp_provisioning_uri(account, options = {}) 41 | otp_secret = options[:otp_secret] || self.otp_secret 42 | ROTP::TOTP.new(otp_secret, options).provisioning_uri(account) 43 | end 44 | 45 | def clean_up_passwords 46 | self.otp_attempt = nil 47 | end 48 | 49 | protected 50 | 51 | module ClassMethods 52 | Devise::Models.config(self, :otp_secret_length, 53 | :otp_allowed_drift, 54 | :otp_secret_encryption_key) 55 | 56 | def generate_otp_secret(otp_secret_length = self.otp_secret_length) 57 | ROTP::Base32.random_base32(otp_secret_length) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /certs/tinfoilsecurity-gems-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFyjCCA7KgAwIBAgIJAK2u0LojMCNtMA0GCSqGSIb3DQEBDQUAMIGcMQswCQYD 3 | VQQGEwJVUzELMAkGA1UECBMCQ0ExEjAQBgNVBAcTCVBhbG8gQWx0bzEfMB0GA1UE 4 | ChMWVGluZm9pbCBTZWN1cml0eSwgSW5jLjEfMB0GA1UEAxMWVGluZm9pbCBTZWN1 5 | cml0eSwgSW5jLjEqMCgGCSqGSIb3DQEJARYbc3VwcG9ydEB0aW5mb2lsc2VjdXJp 6 | dHkuY29tMB4XDTE0MDUyMDIxMTAwMFoXDTE2MDUyMDIxMTAwMFowgZwxCzAJBgNV 7 | BAYTAlVTMQswCQYDVQQIEwJDQTESMBAGA1UEBxMJUGFsbyBBbHRvMR8wHQYDVQQK 8 | ExZUaW5mb2lsIFNlY3VyaXR5LCBJbmMuMR0wGwYDVQQDExR0aW5mb2lsc2VjdXJp 9 | dHktZ2VtczEsMCoGCSqGSIb3DQEJARYdZW5naW5lZXJzQHRpbmZvaWxzZWN1cml0 10 | eS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDNJYNH8D+8lACL 11 | t3KzjEIPs3XVBCPaMm2eD/Xk9OOTuDV/NqgMK0icD9MRxMUtS3SCrC9QcPocXT76 12 | f2LQ3yVJuK+rBUasymEES47PIx2czC4n4Hga0xPPuBpioO26oaRFsobyzh9RPOIb 13 | nYfpjyqtdrbm+YyM3sPR4XzFirv9xomT4E9T4RCLgOQHTcLKL9K9m+EN7PeVdVUX 14 | V0Pa7cVs2vJUKedsd7vnr6Lzbn8ToPk/7J/4W931PbaeI5yg9ZuaRa9K2IaY1TkP 15 | I67NW4qKitBVepRlXw6Sb7TYcUncWEQ/eC5CpnOmqUrG5tfGD8cc5aGZOkitW/VX 16 | ZgVj81xgCv1hk4HjErrqq4FBNAaCSNyBfwR0TUYqg1lN1nbNjOKwfb6YRn06R2ov 17 | cFJG0tmGhsQULCr6fW8u2TfSM+U9WFSIJx2griureY7EZPwg/MgsUiWUWMFemz3G 18 | VYXWJR3dN2pW9Uqr3rkjKZbA0bstGWahJO9HuFdDakQxoaTPYPtTQDC+kskkO6lK 19 | G1KLIoZ1iLZzB1Ks1vEeyE7lp1imWgpUq+q23PFkt1gIBi/4tGvzsLZye25QU2Y+ 20 | XLzldCNm+DyRFXZ+Q+bK33IveUeUWEOv4T1qTXHAOypyzmgodVRG/PrlsSMOBfE5 21 | 15kG1mDMGjRcCpEtlskgxUbf7qM7hQIDAQABow0wCzAJBgNVHRMEAjAAMA0GCSqG 22 | SIb3DQEBDQUAA4ICAQCLXzGJOr0isGHscTvm73ReEAOv4g0IOSXjfHfHAOWKhzdM 23 | g+D8nrzhy8wnARqAt2Ob4I+R9scEIfI5MPp/C5HHqWed4m0W0Uygx3V3qyixavkc 24 | nVUJMZ4TPS6W48IHdGGVD45hopx7ycFy+iPm7QUFk4sg044dO53mkScTetm2AvIE 25 | xDTotsFNpn/hrAnzXlH4MQQ7LKXAedPmFi8Jv5nJwv9BnEGJQJhvzQtdx7le4S8J 26 | a78vwAq429N2gVnfSdeU9v9QqdHOF1215lC6qaDI4bk8hVE4RyMxSBZmWKhnPLAm 27 | jFXGc6Wj2yF7HQ2YtEGAAEyjH+tAwF35STv+J3eweUFhyGZPH8Sf+b+UKZc9UF1D 28 | J/VmzQd9D9RaB+pOclDYR3Uiji5EBq1La2FPg48hA0uCd4KJ9dBGwNrxvjDNSBW0 29 | FiM1vjR6AVxIAhOwICeWG6QTvH5Jrnq/UDBnnK/KALCbFw3YbbhOyy+295jta7xf 30 | r32d4cJAvbDF1C6t2JRjNwi0ANgPw0cytl+8yvCyXpXMZpT0YpDk76XICo97SOHI 31 | 5C31v4YyRBnNCp0pN66nxYX2avEiQ8riTBP5mlkPPOhsIoYQHHe2Uj75aVpu0LZ3 32 | cdFzuO4GC1dV0Wv+dsDm+MyF7DT5E9pUPXpnMJuPvPrFpCb+wrFlszW9hGjXbQ== 33 | -----END CERTIFICATE----- 34 | -------------------------------------------------------------------------------- /lib/devise_two_factor/models/two_factor_backupable.rb: -------------------------------------------------------------------------------- 1 | require 'active_model' 2 | 3 | module Devise 4 | module Models 5 | # TwoFactorBackupable allows a user to generate backup codes which 6 | # provide one-time access to their account in the event that they have 7 | # lost access to their two-factor device 8 | module TwoFactorBackupable 9 | extend ActiveSupport::Concern 10 | 11 | def self.required_fields(klass) 12 | [:otp_backup_codes] 13 | end 14 | 15 | # 1) Invalidates all existing backup codes 16 | # 2) Generates otp_number_of_backup_codes backup codes 17 | # 3) Stores the hashed backup codes in the database 18 | # 4) Returns a plaintext array of the generated backup codes 19 | def generate_otp_backup_codes! 20 | codes = [] 21 | number_of_codes = self.class.otp_number_of_backup_codes 22 | code_length = self.class.otp_backup_code_length 23 | 24 | number_of_codes.times do 25 | codes << SecureRandom.hex(code_length / 2) # Hexstring has length 2*n 26 | end 27 | 28 | hashed_codes = codes.map { |code| Devise.bcrypt self.class, code } 29 | self.otp_backup_codes = hashed_codes 30 | 31 | codes 32 | end 33 | 34 | # Returns true and invalidates the given code 35 | # iff that code is a valid backup code. 36 | def invalidate_otp_backup_code!(code) 37 | codes = self.otp_backup_codes || [] 38 | 39 | codes.each do |backup_code| 40 | # We hashed the code with Devise.bcrypt, so if Devise changes that 41 | # method, we'll have to adjust our comparison here to match it 42 | # TODO Fork Devise and encapsulate this logic in a helper 43 | bcrypt = ::BCrypt::Password.new(backup_code) 44 | hashed_code = ::BCrypt::Engine.hash_secret("#{code}#{self.class.pepper}", 45 | bcrypt.salt) 46 | 47 | next unless Devise.secure_compare(hashed_code, backup_code) 48 | 49 | codes.delete(backup_code) 50 | self.otp_backup_codes = codes 51 | return true 52 | end 53 | 54 | false 55 | end 56 | 57 | protected 58 | 59 | module ClassMethods 60 | Devise::Models.config(self, :otp_backup_code_length, 61 | :otp_number_of_backup_codes, 62 | :pepper) 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /certs/tinfoil-cacert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIHSjCCBTKgAwIBAgIJAK2u0LojMCNgMA0GCSqGSIb3DQEBBQUAMIGcMQswCQYD 3 | VQQGEwJVUzELMAkGA1UECBMCQ0ExEjAQBgNVBAcTCVBhbG8gQWx0bzEfMB0GA1UE 4 | ChMWVGluZm9pbCBTZWN1cml0eSwgSW5jLjEfMB0GA1UEAxMWVGluZm9pbCBTZWN1 5 | cml0eSwgSW5jLjEqMCgGCSqGSIb3DQEJARYbc3VwcG9ydEB0aW5mb2lsc2VjdXJp 6 | dHkuY29tMB4XDTExMTIyNzA1MDc0N1oXDTIxMTIyNDA1MDc0N1owgZwxCzAJBgNV 7 | BAYTAlVTMQswCQYDVQQIEwJDQTESMBAGA1UEBxMJUGFsbyBBbHRvMR8wHQYDVQQK 8 | ExZUaW5mb2lsIFNlY3VyaXR5LCBJbmMuMR8wHQYDVQQDExZUaW5mb2lsIFNlY3Vy 9 | aXR5LCBJbmMuMSowKAYJKoZIhvcNAQkBFhtzdXBwb3J0QHRpbmZvaWxzZWN1cml0 10 | eS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCqbHvsSj0H0FB1 11 | 0gLYoDK1BKugkSB2DZeZZHP6B1UdWRahJXJP9oT1lhfQxx8iX4cgEi7JU3NqA6NR 12 | cIRFQ50eH/qlmgs7909gaf8pDaeC0vR3wd0GeRg6qr1eDEnkzIyr/D1AMiX6H1eP 13 | Y7J3SfrdaL3gft2iPRKGkgqsXR7oBNLA3n/ShiNgPXqRDl1CCj6aMY0cn5ROFScz 14 | vT2FUB4DEwPD2l18m1p99OnXqsOLL2J65qA2+cI8FtgFmlwIi5oSf+URvIdNx+cH 15 | lInlAtVHCvAKYLY0dlQ7czMQBcRpYjp2rwPt9f2ksq9b/voMTBABYHFV+IVn8svv 16 | GZ5e1+icjtr/R7dCGmCdEdFLXVxafmZhukymG9USv9DKuv1qh7r4q8KaPIE8n7nQ 17 | m97jENFfsgnwv+nUmIJ3tzuW5ZxO7A0tIIYdwzt0UjrO3ya4R5bTFXr4bnzZ/g/s 18 | CLknWqg1BCRlPd6LnpVGPT0gNDV1pEO25wE3A3Yy0Ujxudcgay/CgUhnlU11qOAc 19 | xmar2fhNZsviUhndd/220Ad5QMV2XzcAiopJIeu0juIVGRQM7x2h19Hsp0m6sOEF 20 | jfhvbdUa4nvmIFeYFY+hr/YkTmG9ZjyBa8YaZXhwjhSmKCQ374J7mn5e0Cryuvi5 21 | tYhwJn8rdwYZF/h2qqfEu8vaLoD09QIDAQABo4IBizCCAYcwHQYDVR0OBBYEFMmT 22 | /x412UH+5OHqgleeTjLOv6iHMIHRBgNVHSMEgckwgcaAFMmT/x412UH+5OHqglee 23 | TjLOv6iHoYGipIGfMIGcMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExEjAQBgNV 24 | BAcTCVBhbG8gQWx0bzEfMB0GA1UEChMWVGluZm9pbCBTZWN1cml0eSwgSW5jLjEf 25 | MB0GA1UEAxMWVGluZm9pbCBTZWN1cml0eSwgSW5jLjEqMCgGCSqGSIb3DQEJARYb 26 | c3VwcG9ydEB0aW5mb2lsc2VjdXJpdHkuY29tggkAra7QuiMwI2AwDwYDVR0TAQH/ 27 | BAUwAwEB/zARBglghkgBhvhCAQEEBAMCAQYwCQYDVR0SBAIwADArBglghkgBhvhC 28 | AQ0EHhYcVGlueUNBIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAmBgNVHREEHzAdgRtz 29 | dXBwb3J0QHRpbmZvaWxzZWN1cml0eS5jb20wDgYDVR0PAQH/BAQDAgEGMA0GCSqG 30 | SIb3DQEBBQUAA4ICAQAD7nsmdg1vStFTi8/P2rgSFxlXxZT0aaVVB1bFBe/m5phb 31 | MjvKQ7VAuiFZxEp3oBNdXTi4FzT1QjhRKdlYMgKZQnU+XDLLIYuoi+atxr5qGD4B 32 | m58eCGO6ZEutVs3Z7s63UOm5rG0zJ+IEWh8VHMvxgSwiX88QyJuhOtqeiKhIeSGZ 33 | 2/qGGMWgsScnPg3J/ZVOIKUn/4ljEDlC64Gh5Zz5PZUbGSXPMhdYbSD3EknDvEGA 34 | omYW4jlPMeK3GJgwAZu9yWC8hHGFpiMca/6W0W622cg7MX+CByOd+24dvWFnOHur 35 | NHBqI+kZo/7Sjdm8x7TWEOz9Rfh5RPMeVNRTj4iq0B6GzfaecT3Yn8y7HTRRiWns 36 | IYpP+iHCFYnZhDZsFi4ccKqxKtj6BGmhLf00FuNpgkvrsU3cXrhidkCaYGYj1SME 37 | 1CMfy0PPKVDpDKeFb6y0NvLf4d57vi99dZAvSJEO18rrNEHN2VUfCKRPA/mBSMLY 38 | RxKWAby1YVT/8iC9JWix9yvgsEUtTLyOFxLGtgj3PRiQSvbNe/jK4G9WAIFe6R9E 39 | 9+HUO2owcmyFXyU3rC/z/lBfDP+2pIRFdUVRGlYCMeUqR08PXpfva5+NQz21fC69 40 | FPRMZvXh70ntnFaWAq+j6NCss+AauC8ckECiQsTgbzJvJd6C3mJXYHkNCQODhg== 41 | -----END CERTIFICATE----- 42 | -------------------------------------------------------------------------------- /lib/devise_two_factor/spec_helpers/two_factor_authenticatable_shared_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples 'two_factor_authenticatable' do 2 | before :each do 3 | subject.otp_secret = subject.class.generate_otp_secret 4 | end 5 | 6 | describe 'required_fields' do 7 | it 'should have the attr_encrypted fields for otp_secret' do 8 | Devise::Models::TwoFactorAuthenticatable.required_fields(subject.class).should =~ ([:encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt]) 9 | end 10 | end 11 | 12 | describe '#otp_secret' do 13 | it 'should be of the configured length' do 14 | subject.otp_secret.length.should eq(subject.class.otp_secret_length) 15 | end 16 | 17 | it 'stores the encrypted otp_secret' do 18 | subject.encrypted_otp_secret.should_not be_nil 19 | end 20 | 21 | it 'stores an iv for otp_secret' do 22 | subject.encrypted_otp_secret_iv.should_not be_nil 23 | end 24 | 25 | it 'stores a salt for otp_secret' do 26 | subject.encrypted_otp_secret_salt.should_not be_nil 27 | end 28 | end 29 | 30 | describe '#valid_otp?' do 31 | let(:otp_secret) { '2z6hxkdwi3uvrnpn' } 32 | 33 | before :each do 34 | Timecop.freeze(Time.current) 35 | subject.otp_secret = otp_secret 36 | end 37 | 38 | after :each do 39 | Timecop.return 40 | end 41 | 42 | it 'validates a precisely correct OTP' do 43 | otp = ROTP::TOTP.new(otp_secret).at(Time.now) 44 | subject.valid_otp?(otp).should be_true 45 | end 46 | 47 | it 'validates an OTP within the allowed drift' do 48 | otp = ROTP::TOTP.new(otp_secret).at(Time.now + subject.class.otp_allowed_drift, true) 49 | subject.valid_otp?(otp).should be_true 50 | end 51 | 52 | it 'does not validate an OTP above the allowed drift' do 53 | otp = ROTP::TOTP.new(otp_secret).at(Time.now + subject.class.otp_allowed_drift * 2, true) 54 | subject.valid_otp?(otp).should be_false 55 | end 56 | 57 | it 'does not validate an OTP below the allowed drift' do 58 | otp = ROTP::TOTP.new(otp_secret).at(Time.now - subject.class.otp_allowed_drift * 2, true) 59 | subject.valid_otp?(otp).should be_false 60 | end 61 | end 62 | 63 | describe '#otp_provisioning_uri' do 64 | let(:otp_secret_length) { subject.class.otp_secret_length } 65 | let(:account) { Faker::Internet.email } 66 | let(:issuer) { "Tinfoil" } 67 | 68 | it "should return uri with specified account" do 69 | subject.otp_provisioning_uri(account).should match(%r{otpauth://totp/#{account}\?secret=\w{#{otp_secret_length}}}) 70 | end 71 | 72 | it 'should return uri with issuer option' do 73 | subject.otp_provisioning_uri(account, issuer: issuer).should match(%r{otpauth://totp/#{account}\?issuer=#{issuer}&secret=\w{#{otp_secret_length}}$}) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/generators/devise_two_factor/devise_two_factor_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | 3 | module DeviseTwoFactor 4 | module Generators 5 | class DeviseTwoFactorGenerator < Rails::Generators::NamedBase 6 | argument :encryption_key_env, :type => :string, :required => true 7 | 8 | desc 'Creates a migration to add the required attributes to NAME, and ' \ 9 | 'adds the necessary Devise directives to the model' 10 | 11 | def install_devise_two_factor 12 | create_devise_two_factor_migration 13 | inject_strategies_into_warden_config 14 | inject_devise_directives_into_model 15 | end 16 | 17 | private 18 | 19 | def create_devise_two_factor_migration 20 | migration_arguments = [ 21 | "add_devise_two_factor_to_#{plural_name}", 22 | "encrypted_otp_secret:string", 23 | "encrypted_otp_secret_iv:string", 24 | "encrypted_otp_secret_salt:string", 25 | "otp_required_for_login:boolean" 26 | ] 27 | 28 | Rails::Generators.invoke('active_record:migration', migration_arguments) 29 | end 30 | 31 | def inject_strategies_into_warden_config 32 | config_path = File.join('config', 'initializers', 'devise.rb') 33 | 34 | content = " config.warden do |manager|\n" \ 35 | " manager.default_strategies(:scope => :#{singular_table_name}).unshift :two_factor_authenticatable\n" \ 36 | " end\n\n" 37 | 38 | inject_into_file(config_path, content, after: "Devise.setup do |config|\n") 39 | end 40 | 41 | def inject_devise_directives_into_model 42 | model_path = File.join('app', 'models', "#{file_path}.rb") 43 | 44 | class_path = if namespaced? 45 | class_name.to_s.split("::") 46 | else 47 | [class_name] 48 | end 49 | 50 | indent_depth = class_path.size 51 | 52 | content = [ 53 | "devise :two_factor_authenticatable,", 54 | " :otp_secret_encryption_key => ENV['#{encryption_key_env}']\n" 55 | ] 56 | 57 | content << "attr_accessible :otp_attempt\n" if needs_attr_accessible? 58 | content = content.map { |line| " " * indent_depth + line }.join("\n") << "\n" 59 | 60 | inject_into_class(model_path, class_path.last, content) 61 | 62 | # Remove :database_authenticatable from the list of loaded models 63 | gsub_file(model_path, /(devise.*):(, )?database_authenticatable(, )?/, '\1\2') 64 | end 65 | 66 | def needs_attr_accessible? 67 | !strong_parameters_enabled? && mass_assignment_security_enabled? 68 | end 69 | 70 | def strong_parameters_enabled? 71 | defined?(ActionController::StrongParameters) 72 | end 73 | 74 | def mass_assignment_security_enabled? 75 | defined?(ActiveModel::MassAssignmentSecurity) 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/devise_two_factor/spec_helpers/two_factor_backupable_shared_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples 'two_factor_backupable' do 2 | describe 'required_fields' do 3 | it 'has the attr_encrypted fields for otp_backup_codes' do 4 | Devise::Models::TwoFactorBackupable.required_fields(subject.class).should =~ [:otp_backup_codes] 5 | end 6 | end 7 | 8 | describe '#generate_otp_backup_codes!' do 9 | context 'with no existing recovery codes' do 10 | before do 11 | @plaintext_codes = subject.generate_otp_backup_codes! 12 | end 13 | 14 | it 'generates the correct number of new recovery codes' do 15 | subject.otp_backup_codes.length.should 16 | eq(subject.class.otp_number_of_backup_codes) 17 | end 18 | 19 | it 'generates recovery codes of the correct length' do 20 | @plaintext_codes.each do |code| 21 | code.length.should eq(subject.class.otp_backup_code_length) 22 | end 23 | end 24 | 25 | it 'generates distinct recovery codes' do 26 | @plaintext_codes.uniq.should =~ @plaintext_codes 27 | end 28 | 29 | it 'stores the codes as BCrypt hashes' do 30 | subject.otp_backup_codes.each do |code| 31 | # $algorithm$cost$(22 character salt + 31 character hash) 32 | code.should =~ /\A\$[0-9a-z]{2}\$[0-9]{2}\$[A-Za-z0-9\.\/]{53}\z/ 33 | end 34 | end 35 | end 36 | 37 | context 'with existing recovery codes' do 38 | let(:old_codes) { Faker::Lorem.words } 39 | let(:old_codes_hashed) { old_codes.map { |x| Devise.bcrypt subject.class, x } } 40 | 41 | before do 42 | subject.otp_backup_codes = old_codes_hashed 43 | @plaintext_codes = subject.generate_otp_backup_codes! 44 | end 45 | 46 | it 'invalidates the existing recovery codes' do 47 | (subject.otp_backup_codes & old_codes_hashed).should =~ [] 48 | end 49 | end 50 | end 51 | 52 | describe '#invalidate_otp_backup_code!' do 53 | before do 54 | @plaintext_codes = subject.generate_otp_backup_codes! 55 | end 56 | 57 | context 'given an invalid recovery code' do 58 | it 'returns false' do 59 | subject.invalidate_otp_backup_code!('password').should be_false 60 | end 61 | end 62 | 63 | context 'given a valid recovery code' do 64 | it 'returns true' do 65 | @plaintext_codes.each do |code| 66 | subject.invalidate_otp_backup_code!(code).should be_true 67 | end 68 | end 69 | 70 | it 'invalidates that recovery code' do 71 | code = @plaintext_codes.sample 72 | 73 | subject.invalidate_otp_backup_code!(code) 74 | subject.invalidate_otp_backup_code!(code).should be_false 75 | end 76 | 77 | it 'does not invalidate the other recovery codes' do 78 | code = @plaintext_codes.sample 79 | subject.invalidate_otp_backup_code!(code) 80 | 81 | @plaintext_codes.delete(code) 82 | 83 | @plaintext_codes.each do |code| 84 | subject.invalidate_otp_backup_code!(code).should be_true 85 | end 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /demo/config/database.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL. Versions 8.2 and up are supported. 2 | # 3 | # Install the pg driver: 4 | # gem install pg 5 | # On OS X with Homebrew: 6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config 7 | # On OS X with MacPorts: 8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config 9 | # On Windows: 10 | # gem install pg 11 | # Choose the win32 build. 12 | # Install PostgreSQL and put its /bin directory on your path. 13 | # 14 | # Configure Using Gemfile 15 | # gem 'pg' 16 | # 17 | default: &default 18 | adapter: postgresql 19 | encoding: unicode 20 | # For details on connection pooling, see rails configuration guide 21 | # http://guides.rubyonrails.org/configuring.html#database-pooling 22 | pool: 5 23 | 24 | development: 25 | <<: *default 26 | database: DeviseTwoFactorDemo_development 27 | 28 | # The specified database role being used to connect to postgres. 29 | # To create additional roles in postgres see `$ createuser --help`. 30 | # When left blank, postgres will use the default role. This is 31 | # the same name as the operating system user that initialized the database. 32 | #username: DeviseTwoFactorDemo 33 | 34 | # The password associated with the postgres role (username). 35 | #password: 36 | 37 | # Connect on a TCP socket. Omitted by default since the client uses a 38 | # domain socket that doesn't need configuration. Windows does not have 39 | # domain sockets, so uncomment these lines. 40 | #host: localhost 41 | 42 | # The TCP port the server listens on. Defaults to 5432. 43 | # If your server runs on a different port number, change accordingly. 44 | #port: 5432 45 | 46 | # Schema search path. The server defaults to $user,public 47 | #schema_search_path: myapp,sharedapp,public 48 | 49 | # Minimum log levels, in increasing order: 50 | # debug5, debug4, debug3, debug2, debug1, 51 | # log, notice, warning, error, fatal, and panic 52 | # Defaults to warning. 53 | #min_messages: notice 54 | 55 | # Warning: The database defined as "test" will be erased and 56 | # re-generated from your development database when you run "rake". 57 | # Do not set this db to the same as development or production. 58 | test: 59 | <<: *default 60 | database: DeviseTwoFactorDemo_test 61 | 62 | # As with config/secrets.yml, you never want to store sensitive information, 63 | # like your database password, in your source code. If your source code is 64 | # ever seen by anyone, they now have access to your database. 65 | # 66 | # Instead, provide the password as a unix environment variable when you boot 67 | # the app. Read http://guides.rubyonrails.org/configuring.html#configuring-a-database 68 | # for a full rundown on how to provide these environment variables in a 69 | # production deployment. 70 | # 71 | # On Heroku and other platform providers, you may have a full connection URL 72 | # available as an environment variable. For example: 73 | # 74 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" 75 | # 76 | # You can use this database configuration with: 77 | # 78 | # production: 79 | # url: <%= ENV['DATABASE_URL'] %> 80 | # 81 | production: 82 | <<: *default 83 | database: DeviseTwoFactorDemo_production 84 | username: DeviseTwoFactorDemo 85 | password: <%= ENV['DEVISETWOFACTORDEMO_DATABASE_PASSWORD'] %> 86 | -------------------------------------------------------------------------------- /demo/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application 18 | # Add `rack-cache` to your Gemfile before enabling this. 19 | # For large-scale production use, consider using a caching reverse proxy like nginx, varnish or squid. 20 | # config.action_dispatch.rack_cache = true 21 | 22 | # Disable Rails's static asset server (Apache or nginx will already do this). 23 | config.serve_static_assets = false 24 | 25 | # Compress JavaScripts and CSS. 26 | config.assets.js_compressor = :uglifier 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fallback to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # Generate digests for assets URLs. 33 | config.assets.digest = true 34 | 35 | # Version of your assets, change this if you want to expire all your assets. 36 | config.assets.version = '1.0' 37 | 38 | # Specifies the header that your server uses for sending files. 39 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache 40 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx 41 | 42 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 43 | # config.force_ssl = true 44 | 45 | # Set to :debug to see everything in the log. 46 | config.log_level = :info 47 | 48 | # Prepend all log lines with the following tags. 49 | # config.log_tags = [ :subdomain, :uuid ] 50 | 51 | # Use a different logger for distributed setups. 52 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 53 | 54 | # Use a different cache store in production. 55 | # config.cache_store = :mem_cache_store 56 | 57 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 58 | # config.action_controller.asset_host = "http://assets.example.com" 59 | 60 | # Precompile additional assets. 61 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 62 | # config.assets.precompile += %w( search.js ) 63 | 64 | # Ignore bad email addresses and do not raise email delivery errors. 65 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 66 | # config.action_mailer.raise_delivery_errors = false 67 | 68 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 69 | # the I18n.default_locale when a translation cannot be found). 70 | config.i18n.fallbacks = true 71 | 72 | # Send deprecation notices to registered listeners. 73 | config.active_support.deprecation = :notify 74 | 75 | # Disable automatic flushing of the log to improve performance. 76 | # config.autoflush_log = false 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = ::Logger::Formatter.new 80 | 81 | # Do not dump schema after migrations. 82 | config.active_record.dump_schema_after_migration = false 83 | end 84 | -------------------------------------------------------------------------------- /demo/config/locales/devise.en.yml: -------------------------------------------------------------------------------- 1 | # Additional translations at https://github.com/plataformatec/devise/wiki/I18n 2 | 3 | en: 4 | devise: 5 | confirmations: 6 | confirmed: "Your account was successfully confirmed." 7 | send_instructions: "You will receive an email with instructions about how to confirm your account in a few minutes." 8 | send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions about how to confirm your account in a few minutes." 9 | failure: 10 | already_authenticated: "You are already signed in." 11 | inactive: "Your account is not activated yet." 12 | invalid: "Invalid email or password." 13 | locked: "Your account is locked." 14 | last_attempt: "You have one more attempt before your account will be locked." 15 | not_found_in_database: "Invalid email or password." 16 | timeout: "Your session expired. Please sign in again to continue." 17 | unauthenticated: "You need to sign in or sign up before continuing." 18 | unconfirmed: "You have to confirm your account before continuing." 19 | mailer: 20 | confirmation_instructions: 21 | subject: "Confirmation instructions" 22 | reset_password_instructions: 23 | subject: "Reset password instructions" 24 | unlock_instructions: 25 | subject: "Unlock Instructions" 26 | omniauth_callbacks: 27 | failure: "Could not authenticate you from %{kind} because \"%{reason}\"." 28 | success: "Successfully authenticated from %{kind} account." 29 | passwords: 30 | no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." 31 | send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." 32 | send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." 33 | updated: "Your password was changed successfully. You are now signed in." 34 | updated_not_active: "Your password was changed successfully." 35 | registrations: 36 | destroyed: "Bye! Your account was successfully cancelled. We hope to see you again soon." 37 | signed_up: "Welcome! You have signed up successfully." 38 | signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." 39 | signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." 40 | signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please open the link to activate your account." 41 | update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and click on the confirm link to finalize confirming your new email address." 42 | updated: "You updated your account successfully." 43 | sessions: 44 | signed_in: "Signed in successfully." 45 | signed_out: "Signed out successfully." 46 | unlocks: 47 | send_instructions: "You will receive an email with instructions about how to unlock your account in a few minutes." 48 | send_paranoid_instructions: "If your account exists, you will receive an email with instructions about how to unlock it in a few minutes." 49 | unlocked: "Your account has been unlocked successfully. Please sign in to continue." 50 | errors: 51 | messages: 52 | already_confirmed: "was already confirmed, please try signing in" 53 | confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" 54 | expired: "has expired, please request a new one" 55 | not_found: "not found" 56 | not_locked: "was not locked" 57 | not_saved: 58 | one: "1 error prohibited this %{resource} from being saved:" 59 | other: "%{count} errors prohibited this %{resource} from being saved:" 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Devise-Two-Factor Authentication 2 | By [Tinfoil Security](http://tinfoilsecurity.com/) 3 | 4 | [![Build Status](https://travis-ci.org/tinfoil/devise-two-factor.svg?branch=master)](https://travis-ci.org/tinfoil/devise-two-factor) 5 | 6 | Devise-two-factor is a minimalist extension to Devise which offers support for two-factor authentication. It: 7 | 8 | * Allows you to incorporate two-factor authentication into your existing models 9 | * Is opinionated about security, so you don't have to be 10 | * Integrates easily with two-factor applications like Google Authenticator and Authy 11 | * Is extensible, and includes two-factor backup codes as an example of how plugins can be structured 12 | 13 | ## Example App 14 | An example Rails 4 application is provided in demo/ 15 | 16 | It showcases a minimal example of devise-two-factor in action, and can act as a reference for integrating the gem into your own application. 17 | 18 | 19 | ## Getting Started 20 | Devise-two-factor doesn't require much to get started, but there are a few prerequisites before you can start using it in your application. 21 | 22 | First, you'll need a Rails application setup with Devise. Visit the Devise [homepage](https://github.com/plataformatec/devise) for instructions. 23 | 24 | Next, since devise-two-factor encrypts its secrets before storing them in the database, you'll need to generate an encryption key, and store it in an environment variable of your choice. 25 | 26 | Finally, you can automate all of the required setup by simply running: 27 | 28 | ```ruby 29 | rails generate devise_two_factor MODEL ENVIRONMENT_VARIABLE 30 | ``` 31 | 32 | Where MODEL is the name of the model you wish to add two-factor functionality to, and ENVIRONMENT_VARIABLE is the name of the variable you're storing your encryption key in. 33 | 34 | This generator will add a few columns to the specified model: 35 | 36 | * encrypted_otp_secret 37 | * encrypted_otp_secret_iv 38 | * encrypted_otp_secret_salt 39 | * otp_required_for_login 40 | 41 | It also adds the :two_factor_authenticatable directive to your model, and sets up your encryption key. If present, it will remove :database_authenticatable from the model, as the two strategies are incompatible. Lastly, the generator will add a Warden config block to your Devise initializer, which enables the strategies required for two-factor authenticatation. 42 | 43 | If you're running Rails 3, or do not have strong parameters enabled, the generator will also setup the required mass-assignment security options in your model. 44 | 45 | **After running the generator, verify that :database_authenticatable is not being loaded by your model. The generator will try to remove it, but if you have a non-standard Devise setup, this step may fail. Loading both :database_authenticatable and :two_factor_authenticatable in a model will allow users to bypass two-factor authenticatable due to the way Warden handles cascading strategies.** 46 | 47 | ## Designing Your Workflow 48 | Devise-two-factor only worries about the backend, leaving the details of the integration up to you. This means that you're responsible for building the UI that drives the gem. While there is an example Rails application included in the gem, it is important to remember that this gem is intentionally very open-ended, and you should build a user experience which fits your individual application. 49 | 50 | There are two key workflows you'll have to think about: 51 | 52 | 1. Logging in with two-factor authentication 53 | 2. Enabling two-factor authentication for a given user 54 | 55 | We chose to keep things as simple as possible, and our implemention can be found by registering at [Tinfoil Security](https://tinfoilsecurity.com/), and enabling two-factor authentication from the [security settings page](https://www.tinfoilsecurity.com/account/security). 56 | 57 | 58 | ### Logging In 59 | Logging in with two-factor authentication works extremely similarly to regular database authentication in Devise. The TwoFactorAuthenticatable strategy accepts three parameters: 60 | 61 | 1. email 62 | 2. password 63 | 3. otp_attempt (Their one-time password for this session) 64 | 65 | These parameters can be submitted to the standard Devise login route, and the strategy will handle the authentication of the user for you. 66 | 67 | ### Enabling Two-Factor Authentication 68 | Enabling two-factor authentication for a user is easy. For example, if my user model were named User, I could do the following: 69 | 70 | ```ruby 71 | current_user.otp_required_for_login = true 72 | current_user.otp_secret = User.generate_otp_secret 73 | current_user.save! 74 | ``` 75 | 76 | Before you can do this however, you need to decide how you're going to transmit two-factor tokens to a user. Common strategies include sending an SMS, or using a mobile application such as Google Authenticator. 77 | 78 | At Tinfoil Security, we opted to use the excellent [rqrcode-rails3](https://github.com/samvincent/rqrcode-rails3) gem to generate a QR-code representing the user's secret key, which can then be scanned by any mobile two-factor authentication client. 79 | 80 | However you decide to handle enrollment, there are a few important considerations to be made: 81 | 82 | * Whether you'll force the use of two-factor authentication, and if so, how you'll migrate existing users to system, and what your onboarding experience will look like 83 | * If you authenticate using SMS, you'll want to verify the user's ownership of the phone, in much the same way you're probably verifying their email address 84 | * How you'll handle device revocation in the event that a user loses access to their device, or that device is rendered temporarily unavailable (This gem includes TwoFactorBackupable as an example extension meant to solve this problem) 85 | 86 | It sounds like a lot of work, but most of these problems have been very elegantly solved by other people. We recommend taking a look at the excellent workflows used by Heroku and Google for inspiration. 87 | 88 | ## Backup Codes 89 | Devise-two-factor is designed with extensibility in mind. One such extension, TwoFactorBackupable, is included and serves as a good example of how to extend this gem. This plugin allows you to add the ability to generate single-use backup codes for a user, which they may use to bypass two-factor authentication, in the event that they lose access to their device. 90 | 91 | To install it, you need to add the :two_factor_backupable directive to your model. 92 | 93 | ```ruby 94 | devise :two_factor_backupable 95 | ``` 96 | 97 | You'll also be required to enable the :two_factor_backupable strategy, by adding the following line to your Warden config in your Devise initializer, substituting :user for the name of your Devise scope. 98 | 99 | ```ruby 100 | manager.default_strategies(:scope => :user).unshift :two_factor_backupable 101 | ``` 102 | 103 | The final installation step is dependent on your version of Rails. If you're not running Rails 4, skip to the next section. Otherwise, create the following migration: 104 | 105 | ```ruby 106 | class AddDeviseTwoFactorBackupableToUsers < ActiveRecord::Migration 107 | def change 108 | add_column :users, :otp_backup_codes, :string, array: true 109 | end 110 | end 111 | ``` 112 | 113 | You can then generate backup codes for a user: 114 | 115 | ```ruby 116 | codes = current_user.generate_otp_backup_codes! 117 | current_user.save! 118 | # Display codes to the user somehow! 119 | ``` 120 | 121 | The backup codes are stored in the database as bcrypt hashes, so be sure to display them to the user at this point. If all went well, the user should be able to login using each of the generated codes in place of their two-factor token. Each code is single-use, and generating a new set of backup codes for that user will invalidate all of the old ones. 122 | 123 | You can customize the length of each code, and the number of codes generated by passing the options into `:two_factor_backupable` in the Devise directive: 124 | 125 | ```ruby 126 | devise :two_factor_backupable, otp_backup_code_length: 32, 127 | otp_number_of_backup_codes: 10 128 | ``` 129 | 130 | ### Help! I'm not using Rails 4.0! 131 | Don't worry! TwoFactorBackupable stores the backup codes as an array of strings in the database. In Rails 4.0 this is supported natively, but in earlier versions you can use a gem to emulate this behaviour: we recommend [activerecord-postgres-array](https://github.com/tlconnor/activerecord-postgres-array). 132 | 133 | You'll then simply have to create a migration to add an array named `otp_backup_codes` to your model. If you use the above gem, this migration might look like: 134 | 135 | ```ruby 136 | class AddTwoFactorBackupCodesToUsers < ActiveRecord::Migration 137 | def change 138 | add_column :users, :otp_backup_codes, :string_array 139 | end 140 | end 141 | ``` 142 | 143 | Now just continue with the setup in the previous section, skipping the generator step. 144 | 145 | ## Testing 146 | Devise-two-factor includes shared-examples for both TwoFactorAuthenticatable and TwoFactorBackupable. Adding the following two lines to the specs for your two-factor enabled models will allow you to test your models for two-factor functionality: 147 | 148 | ```ruby 149 | require 'devise_two_factor/spec_helpers' 150 | 151 | it_behaves_like "two_factor_authenticatable" 152 | it_behaves_like "two_factor_backupable" 153 | ``` 154 | --------------------------------------------------------------------------------