diff --git a/.rspec b/.rspec
new file mode 100644
index 00000000..b3eb8b49
--- /dev/null
+++ b/.rspec
@@ -0,0 +1,2 @@
+--color
+--format documentation
\ No newline at end of file
diff --git a/README.md b/README.md
index dabdea41..ae7276f0 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
* Support for 2 types of OTP codes
1. Codes delivered directly to the user
2. TOTP (Google Authenticator) codes based on a shared secret (HMAC)
+* Option to enable or disable otp
* Configurable OTP code digit length
* Configurable max login attempts
* Customizable logic to determine if a user needs two factor authentication
@@ -43,6 +44,7 @@ Where MODEL is your model name (e.g. User or Admin). This generator will add
`:two_factor_authenticatable` to your model's Devise options and create a
migration in `db/migrate/`, which will add the following columns to your table:
+- `:otp_enabled`
- `:second_factor_attempts_count`
- `:encrypted_otp_secret_key`
- `:encrypted_otp_secret_key_iv`
@@ -64,14 +66,15 @@ devise :database_authenticatable, :registerable, :recoverable, :rememberable,
Then create your migration file using the Rails generator, such as:
```
-rails g migration AddTwoFactorFieldsToUsers second_factor_attempts_count:integer encrypted_otp_secret_key:string:index encrypted_otp_secret_key_iv:string encrypted_otp_secret_key_salt:string direct_otp:string direct_otp_sent_at:datetime totp_timestamp:timestamp
+rails g migration AddTwoFactorFieldsToUsers otp_enabled:boolean second_factor_attempts_count:integer encrypted_otp_secret_key:string:index encrypted_otp_secret_key_iv:string encrypted_otp_secret_key_salt:string direct_otp:string direct_otp_sent_at:datetime totp_timestamp:timestamp
```
Open your migration file (it will be in the `db/migrate` directory and will be
named something like `20151230163930_add_two_factor_fields_to_users.rb`), and
-add `unique: true` to the `add_index` line so that it looks like this:
+add `unique: true` to the `add_index` line and add default: false to otp_enabled, so that it looks like this:
```ruby
+add_column :users, :otp_enabled, :boolean, default: false
add_index :users, :encrypted_otp_secret_key, unique: true
```
Save the file.
@@ -102,9 +105,12 @@ The `otp_secret_encryption_key` must be a random key that is not stored in the
DB, and is not checked in to your repo. It is recommended to store it in an
environment variable, and you can generate it with `bundle exec rake secret`.
-Override the method in your model in order to send direct OTP codes. This is
-automatically called when a user logs in unless they have TOTP enabled (see
-below):
+#### Enabling two factor authentication
+By default when users login and otp is not enabled, the user is asked to enable two factor authentication.
+
+The user has the option to choose between using the app (for example [Google Authenticator](https://support.google.com/accounts/answer/1066447?hl=en)), or receiving direct OTP codes.
+
+Override the method in your model in order to send direct OTP codes:
```ruby
def send_two_factor_authentication_code(code)
@@ -112,6 +118,9 @@ def send_two_factor_authentication_code(code)
end
```
+Once the user has confirmed by entering the code from his app or direct code,
+two factor authentication is enabled.
+
### Customisation and Usage
By default, second factor authentication is required for each user. You can
@@ -126,36 +135,27 @@ end
In the example above, two factor authentication will not be required for local
users.
-This gem is compatible with [Google Authenticator](https://support.google.com/accounts/answer/1066447?hl=en).
-To enable this a shared secret must be generated by invoking the following
-method on your model:
-
-```ruby
-user.generate_totp_secret
-```
+#### Overriding the views
-This must then be shared via a provisioning uri:
+The default views that show the forms can be overridden by adding the following files
+in either ERB or haml:
-```ruby
-user.provisioning_uri # This assumes a user model with an email attribute
-```
+- `new.html.erb` Enabling two factor authentication
+- `edit.html.erb` Disabling two factor authentication
+- `show.html.erb` Verifying OTP code after login
-This provisioning uri can then be turned in to a QR code if desired so that
-users may add the app to Google Authenticator easily. Once this is done, they
-may retrieve a one-time password directly from the Google Authenticator app.
+inside `app/views/devise/two_factor_authentication/` and customizing it.
-#### Overriding the view
+Or you can use the generator:
-The default view that shows the form can be overridden by adding a
-file named `show.html.erb` (or `show.html.haml` if you prefer HAML)
-inside `app/views/devise/two_factor_authentication/` and customizing it.
-Below is an example using ERB:
+`bundle exec rails g two_factor_authentication:views`
+Below is an example for show using ERB:
```html
Hi, you received a code by email, please enter it below, thanks!
-<%= form_tag([resource_name, :two_factor_authentication], :method => :put) do %>
+<%= form_tag(verify_user_two_factor_authentication_path, method: :put) do %>
<%= text_field_tag :code %>
<%= submit_tag "Log in!" %>
<% end %>
@@ -164,22 +164,15 @@ Below is an example using ERB:
```
-#### Enable TOTP support for existing users
+#### Disable OTP by users
-If you have existing users that need to be provided with a OTP secret key, so
-they can use TOTP, create a rake task. It could look like this one below:
+If you want to give the users to option to disable OTP, you must add a route to edit_#{scope}_two_factor_authentication_path. In this view the user has to confirm with a code to disable his two factor authentication.
-```ruby
-desc 'rake task to update users with otp secret key'
-task :update_users_with_otp_secret_key => :environment do
- User.find_each do |user|
- user.generate_totp_secret
- user.save!
- puts "Rake[:update_users_with_otp_secret_key] => OTP secret key set to '#{key}' for User '#{user.email}'"
- end
-end
-```
-Then run the task with `bundle exec rake update_users_with_otp_secret_key`
+#### Filtering sensitive parameters from the logs
+
+To prevent two-factor authentication codes from leaking if your application logs get breached, you'll want to filter sensitive parameters from the Rails logs. Add the following to config/initializers/filter_parameter_logging.rb:
+
+Rails.application.config.filter_parameters += [:totp_secret]
#### Adding the TOTP encryption option to an existing app
diff --git a/app/controllers/devise/two_factor_authentication_controller.rb b/app/controllers/devise/two_factor_authentication_controller.rb
index cf9bd6e2..abe2cd21 100644
--- a/app/controllers/devise/two_factor_authentication_controller.rb
+++ b/app/controllers/devise/two_factor_authentication_controller.rb
@@ -1,14 +1,48 @@
+require 'rqrcode'
require 'devise/version'
class Devise::TwoFactorAuthenticationController < DeviseController
prepend_before_action :authenticate_scope!
before_action :prepare_and_validate, :handle_two_factor_authentication
+ before_action :set_qr, only: [:new, :create]
def show
+ unless resource.otp_enabled
+ return redirect_to({ action: :new }, notice: I18n.t('devise.two_factor_authentication.totp_not_enabled'))
+ end
+ end
+
+ def new
+ if resource.otp_enabled
+ return redirect_to({ action: :edit }, notice: I18n.t('devise.two_factor_authentication.totp_already_enabled'))
+ end
+ end
+
+ def edit
+ end
+
+ def create
+ return render :new if params[:code].nil? || params[:totp_secret].nil?
+ if resource.confirm_otp(params[:totp_secret], params[:code]) && resource.save
+ after_two_factor_success_for(resource)
+ else
+ set_flash_message :notice, :confirm_failed, now: true
+ render :new
+ end
end
def update
- render :show and return if params[:code].nil?
+ return render :edit if params[:code].nil?
+ if resource.authenticate_otp(params[:code]) && resource.disable_otp
+ redirect_to after_two_factor_success_path_for(resource), notice: I18n.t('devise.two_factor_authentication.remove_success')
+ else
+ set_flash_message :notice, :remove_failed, now: true
+ render :edit
+ end
+ end
+
+ def verify
+ return render :show if params[:code].nil?
if resource.authenticate_otp(params[:code])
after_two_factor_success_for(resource)
@@ -19,14 +53,23 @@ def update
def resend_code
resource.send_new_otp
- redirect_to send("#{resource_name}_two_factor_authentication_path"), notice: I18n.t('devise.two_factor_authentication.code_has_been_sent')
+
+ respond_to do |format|
+ format.html { redirect_to send("#{resource_name}_two_factor_authentication_path"), notice: I18n.t('devise.two_factor_authentication.code_has_been_sent') }
+ format.json { head :no_content, status: :ok }
+ end
end
private
+ def set_qr
+ @totp_secret = resource.generate_totp_secret
+ provisioning_uri = resource.provisioning_uri(nil, otp_secret_key: @totp_secret)
+ @qr = RQRCode::QRCode.new(provisioning_uri).as_png(size: 250).to_data_url
+ end
+
def after_two_factor_success_for(resource)
set_remember_two_factor_cookie(resource)
-
warden.session(resource_name)[TwoFactorAuthentication::NEED_AUTHENTICATION] = false
# For compatability with devise versions below v4.2.0
# https://github.com/plataformatec/devise/commit/2044fffa25d781fcbaf090e7728b48b65c854ccb
diff --git a/app/views/devise/two_factor_authentication/edit.html.erb b/app/views/devise/two_factor_authentication/edit.html.erb
new file mode 100644
index 00000000..d0794e2e
--- /dev/null
+++ b/app/views/devise/two_factor_authentication/edit.html.erb
@@ -0,0 +1,12 @@
+Disable two-factor authentication
+
+<%= flash[:notice] %>
+
+<%= form_tag([resource_name, :two_factor_authentication], method: 'PUT') do %>
+ <%= text_field_tag :code, nil, placeholder: 'Enter code', autocomplete: 'off' %>
+
+ <%= submit_tag 'Confirm and deactivate' %>
+<% end %>
+
+
+<%= link_to 'Send me a code instead', resend_code_user_two_factor_authentication_path, remote: true %>
diff --git a/app/views/devise/two_factor_authentication/new.html.erb b/app/views/devise/two_factor_authentication/new.html.erb
new file mode 100644
index 00000000..fedf0ed8
--- /dev/null
+++ b/app/views/devise/two_factor_authentication/new.html.erb
@@ -0,0 +1,42 @@
+Enable two-factor authentication
+
+<%= flash[:notice] %>
+
+Authentication with an app
+
+Get the app
+
+ Download and install one of the following apps for your phone or table:
+ - Google Authenticator
+ - Duo Mobile
+ - Authy
+ - Windows Phone Authenticator
+
+
+Scan this barcode
+<%= image_tag @qr %>
+
+ Open the authentication app and:
+ - Tap the "+" icon in the top-right of the app
+ - Scan the image to the left, using your phone's camera
+
+ Can't scan this barcode?
+ Instead of scanning, use your authentication app's "Manual entry" or equivalent option and provide the following time-based key.
+
+ <%= @totp_secret %>
+
+ Your app will then generate a 6-digit verification code, which you use below.
+
+
+Authentication via code
+
+<%= link_to 'Send me a code instead', resend_code_user_two_factor_authentication_path, remote: true %>
+
+
+<%= form_tag([resource_name, :two_factor_authentication]) do %>
+ <%= text_field_tag :code, nil, placeholder: 'Enter code', autocomplete: 'off' %>
+ <%= hidden_field_tag :totp_secret, @totp_secret %>
+
+ <%= submit_tag 'Confirm and activate' %>
+<% end %>
+
diff --git a/app/views/devise/two_factor_authentication/show.html.erb b/app/views/devise/two_factor_authentication/show.html.erb
index 552fd7d8..5bca76f3 100644
--- a/app/views/devise/two_factor_authentication/show.html.erb
+++ b/app/views/devise/two_factor_authentication/show.html.erb
@@ -6,7 +6,7 @@
<%= flash[:notice] %>
-<%= form_tag([resource_name, :two_factor_authentication], :method => :put) do %>
+<%= form_tag(verify_user_two_factor_authentication_path, method: :put) do %>
<%= text_field_tag :code %>
<%= submit_tag "Submit" %>
<% end %>
@@ -16,4 +16,5 @@
<% else %>
<%= link_to "Send me a code instead", resend_code_user_two_factor_authentication_path, action: :get %>
<% end %>
+
<%= link_to "Sign out", destroy_user_session_path, :method => :delete %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 37ff1dfc..efa25ecd 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -3,6 +3,11 @@ en:
two_factor_authentication:
success: "Two factor authentication successful."
attempt_failed: "Attempt failed."
+ confirm_failed: "Your code did not match, or expired after scanning. Remove the old barcode from your app, and try again. Since this process is time-sensitive, make sure your device's date and time is set to 'automatic'."
+ remove_success: "Two factor authentication successful disabled"
+ remove_failed: "Your code did not match, please try again."
max_login_attempts_reached: "Access completely denied as you have reached your attempts limit"
contact_administrator: "Please contact your system administrator."
code_has_been_sent: "Your authentication code has been sent."
+ totp_already_enabled: "Two factor authentication is already enabled."
+ totp_not_enabled: "Two factor authentication is not enabled. Activate first."
\ No newline at end of file
diff --git a/lib/generators/active_record/templates/migration.rb b/lib/generators/active_record/templates/migration.rb
index 251ef402..0b154efe 100644
--- a/lib/generators/active_record/templates/migration.rb
+++ b/lib/generators/active_record/templates/migration.rb
@@ -1,5 +1,6 @@
class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration
def change
+ add_column :<%= table_name %>, :otp_enabled, :boolean, default: false
add_column :<%= table_name %>, :second_factor_attempts_count, :integer, default: 0
add_column :<%= table_name %>, :encrypted_otp_secret_key, :string
add_column :<%= table_name %>, :encrypted_otp_secret_key_iv, :string
diff --git a/lib/generators/two_factor_authentication/views_generator.rb b/lib/generators/two_factor_authentication/views_generator.rb
new file mode 100644
index 00000000..ebe0c18b
--- /dev/null
+++ b/lib/generators/two_factor_authentication/views_generator.rb
@@ -0,0 +1,20 @@
+require 'generators/devise/views_generator'
+
+module TwoFactorAuthenticatable
+ module Generators
+ class ViewsGenerator < Rails::Generators::Base
+ namespace 'two_factor_authentication:views'
+
+ desc 'Copies all Devise Two Factor Authenticatable views to your application.'
+
+ argument :scope, :required => false, :default => nil,
+ :desc => "The scope to copy views to"
+
+ include ::Devise::Generators::ViewPathTemplates
+ source_root File.expand_path("../../../../app/views/devise", __FILE__)
+ def copy_views
+ view_directory :two_factor_authentication
+ end
+ end
+ end
+end
diff --git a/lib/two_factor_authentication.rb b/lib/two_factor_authentication.rb
index 59ffa039..1ecac79e 100644
--- a/lib/two_factor_authentication.rb
+++ b/lib/two_factor_authentication.rb
@@ -1,10 +1,10 @@
require 'two_factor_authentication/version'
require 'devise'
require 'active_support/concern'
-require "active_model"
-require "active_record"
-require "active_support/core_ext/class/attribute_accessors"
-require "cgi"
+require 'active_model'
+require 'active_record'
+require 'active_support/core_ext/class/attribute_accessors'
+require 'cgi'
module Devise
mattr_accessor :max_login_attempts
@@ -33,8 +33,8 @@ module Devise
end
module TwoFactorAuthentication
- NEED_AUTHENTICATION = 'need_two_factor_authentication'
- REMEMBER_TFA_COOKIE_NAME = "remember_tfa"
+ NEED_AUTHENTICATION = 'need_two_factor_authentication'.freeze
+ REMEMBER_TFA_COOKIE_NAME = 'remember_tfa'.freeze
autoload :Schema, 'two_factor_authentication/schema'
module Controllers
@@ -42,7 +42,10 @@ module Controllers
end
end
-Devise.add_module :two_factor_authenticatable, :model => 'two_factor_authentication/models/two_factor_authenticatable', :controller => :two_factor_authentication, :route => :two_factor_authentication
+Devise.add_module :two_factor_authenticatable,
+ model: 'two_factor_authentication/models/two_factor_authenticatable',
+ controller: :two_factor_authentication,
+ route: :two_factor_authentication
require 'two_factor_authentication/orm/active_record'
require 'two_factor_authentication/routes'
diff --git a/lib/two_factor_authentication/controllers/helpers.rb b/lib/two_factor_authentication/controllers/helpers.rb
index f8a084d4..79fe0e3c 100644
--- a/lib/two_factor_authentication/controllers/helpers.rb
+++ b/lib/two_factor_authentication/controllers/helpers.rb
@@ -30,7 +30,12 @@ def handle_failed_second_factor(scope)
def two_factor_authentication_path_for(resource_or_scope = nil)
scope = Devise::Mapping.find_scope!(resource_or_scope)
- change_path = "#{scope}_two_factor_authentication_path"
+ user = warden.user(scope: scope, run_callbacks: false)
+ if user.otp_enabled?
+ change_path = "#{scope}_two_factor_authentication_path"
+ else
+ change_path = "new_#{scope}_two_factor_authentication_path"
+ end
send(change_path)
end
diff --git a/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb b/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb
index 159ae144..8a333af1 100644
--- a/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb
+++ b/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb
@@ -7,7 +7,7 @@
if user.respond_to?(:need_two_factor_authentication?) && !bypass_by_cookie
if auth.session(options[:scope])[TwoFactorAuthentication::NEED_AUTHENTICATION] = user.need_two_factor_authentication?(auth.request)
- user.send_new_otp unless user.totp_enabled?
+ user.send_new_otp if user.otp_enabled && !user.totp_enabled?
end
end
end
diff --git a/lib/two_factor_authentication/models/two_factor_authenticatable.rb b/lib/two_factor_authentication/models/two_factor_authenticatable.rb
index 7d4a3306..67c84d48 100644
--- a/lib/two_factor_authentication/models/two_factor_authenticatable.rb
+++ b/lib/two_factor_authentication/models/two_factor_authenticatable.rb
@@ -16,7 +16,8 @@ def has_one_time_password(options = {})
::Devise::Models.config(
self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length,
:remember_otp_session_for_seconds, :otp_secret_encryption_key,
- :direct_otp_length, :direct_otp_valid_for, :totp_timestamp)
+ :direct_otp_length, :direct_otp_valid_for, :totp_timestamp
+ )
end
module InstanceMethodsOnActivation
@@ -36,7 +37,7 @@ def authenticate_totp(code, options = {})
totp_secret = options[:otp_secret_key] || otp_secret_key
digits = options[:otp_length] || self.class.otp_length
drift = options[:drift] || self.class.allowed_otp_drift_seconds
- raise "authenticate_totp called with no otp_secret_key set" if totp_secret.nil?
+ fail 'authenticate_totp called with no otp_secret_key set' if totp_secret.nil?
totp = ROTP::TOTP.new(totp_secret, digits: digits)
new_timestamp = totp.verify_with_drift_and_prior(code, drift, totp_timestamp)
return false unless new_timestamp
@@ -47,7 +48,7 @@ def authenticate_totp(code, options = {})
def provisioning_uri(account = nil, options = {})
totp_secret = options[:otp_secret_key] || otp_secret_key
options[:digits] ||= options[:otp_length] || self.class.otp_length
- raise "provisioning_uri called with no otp_secret_key set" if totp_secret.nil?
+ fail 'provisioning_uri called with no otp_secret_key set' if totp_secret.nil?
account ||= email if respond_to?(:email)
ROTP::TOTP.new(totp_secret, options).provisioning_uri(account)
end
@@ -62,7 +63,7 @@ def send_new_otp(options = {})
end
def send_two_factor_authentication_code(code)
- raise NotImplementedError.new("No default implementation - please define in your class.")
+ fail NotImplementedError.new('No default implementation - please define in your class.')
end
def max_login_attempts?
@@ -77,10 +78,28 @@ def totp_enabled?
respond_to?(:otp_secret_key) && !otp_secret_key.nil?
end
- def confirm_totp_secret(secret, code, options = {})
- return false unless authenticate_totp(code, {otp_secret_key: secret})
+ def confirm_otp(secret, code)
+ if direct_otp && authenticate_direct_otp(code)
+ return enable_otp
+ end
+ confirm_totp_secret(secret, code)
+ end
+
+ def confirm_totp_secret(secret, code, _options = {})
+ return false unless authenticate_totp(code, otp_secret_key: secret)
self.otp_secret_key = secret
- true
+ self.otp_enabled = true
+ end
+
+ def enable_otp
+ self.otp_enabled = true
+ save
+ end
+
+ def disable_otp
+ self.otp_secret_key = nil
+ self.otp_enabled = false
+ save
end
def generate_totp_secret
diff --git a/lib/two_factor_authentication/routes.rb b/lib/two_factor_authentication/routes.rb
index 543059a2..b6b200ce 100644
--- a/lib/two_factor_authentication/routes.rb
+++ b/lib/two_factor_authentication/routes.rb
@@ -3,8 +3,11 @@ class Mapper
protected
def devise_two_factor_authentication(mapping, controllers)
- resource :two_factor_authentication, :only => [:show, :update, :resend_code], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do
- collection { get "resend_code" }
+ resource :two_factor_authentication, except: [:index, :destroy], path: mapping.path_names[:two_factor_authentication], controller: controllers[:two_factor_authentication] do
+ collection do
+ put 'verify'
+ get 'resend_code'
+ end
end
end
end
diff --git a/spec/controllers/two_factor_authentication_controller_spec.rb b/spec/controllers/two_factor_authentication_controller_spec.rb
index 100876ad..d8bf4874 100644
--- a/spec/controllers/two_factor_authentication_controller_spec.rb
+++ b/spec/controllers/two_factor_authentication_controller_spec.rb
@@ -1,15 +1,139 @@
require 'spec_helper'
describe Devise::TwoFactorAuthenticationController, type: :controller do
+ describe 'Enabling otp' do
+ before do
+ sign_in create_user('not_encrypted', otp_enabled: false)
+ end
+
+ describe 'with direct code' do
+ context 'when user has not entered any OTP yet' do
+ it 'returns false' do
+ get :show
+
+ expect(subject.current_user.otp_enabled).to eq false
+ end
+ end
+
+ context 'when users enters valid OTP code' do
+ it 'returns true' do
+ controller.current_user.send_new_otp
+ post :create, code: controller.current_user.direct_otp, totp_secret: 'secret'
+
+ expect(subject.current_user.otp_enabled).to eq true
+ end
+ end
+
+ context 'when user enters an invalid OTP' do
+ it 'return false' do
+ post :create, code: '12345', totp_secret: 'secret'
+
+ expect(subject.current_user.otp_enabled).to eq false
+ end
+ end
+ end
+
+ describe 'with totp app' do
+ context 'when user has not entered any OTP yet' do
+ it 'returns false' do
+ get :show
+
+ expect(subject.current_user.otp_enabled).to eq false
+ end
+ end
+
+ context 'when users enters valid TOTP code' do
+ it 'returns true' do
+ secret = controller.current_user.generate_totp_secret
+ totp = ROTP::TOTP.new(secret)
+ post :create, code: totp.now, totp_secret: secret
+
+ expect(subject.current_user.otp_enabled).to eq true
+ end
+ end
+
+ context 'when user enters an invalid OTP' do
+ it 'return false' do
+ post :create, code: '12345', totp_secret: 'secret'
+
+ expect(subject.current_user.otp_enabled).to eq false
+ end
+ end
+ end
+ end
+
+ describe 'Disabling otp' do
+ before do
+ sign_in create_user('not_encrypted', otp_enabled: true)
+ secret = controller.current_user.generate_totp_secret
+ controller.current_user.update(otp_secret_key: secret)
+ end
+
+ describe 'with direct code' do
+ context 'when user has not entered any OTP yet' do
+ it 'returns true' do
+ get :edit
+
+ expect(subject.current_user.otp_enabled).to eq true
+ end
+ end
+
+ context 'when users enters valid OTP code' do
+ it 'returns false' do
+ controller.current_user.send_new_otp
+ post :update, code: controller.current_user.direct_otp
+
+ expect(subject.current_user.otp_enabled).to eq false
+ end
+ end
+
+ context 'when user enters an invalid OTP' do
+ it 'return true' do
+ post :update, code: '12345'
+
+ expect(subject.current_user.otp_enabled).to eq true
+ end
+ end
+ end
+
+ describe 'with totp app' do
+ context 'when user has not entered any OTP yet' do
+ it 'returns true' do
+ get :edit
+
+ expect(subject.current_user.otp_enabled).to eq true
+ end
+ end
+
+ context 'when users enters valid TOTP code' do
+ it 'returns true' do
+ secret = controller.current_user.otp_secret_key
+ totp = ROTP::TOTP.new(secret)
+ post :update, code: totp.now
+
+ expect(subject.current_user.otp_enabled).to eq false
+ end
+ end
+
+ context 'when user enters an invalid OTP' do
+ it 'return false' do
+ post :update, code: '12345'
+
+ expect(subject.current_user.otp_enabled).to eq true
+ end
+ end
+ end
+ end
+
describe 'is_fully_authenticated? helper' do
before do
- sign_in
+ sign_in create_user('not_encrypted', otp_enabled: true)
end
context 'after user enters valid OTP code' do
it 'returns true' do
controller.current_user.send_new_otp
- post :update, code: controller.current_user.direct_otp
+ post :verify, code: controller.current_user.direct_otp
expect(subject.is_fully_authenticated?).to eq true
end
end
@@ -24,7 +148,7 @@
context 'when user enters an invalid OTP' do
it 'returns false' do
- post :update, code: '12345'
+ post :verify, code: '12345'
expect(subject.is_fully_authenticated?).to eq false
end
diff --git a/spec/features/two_factor_authenticatable_spec.rb b/spec/features/two_factor_authenticatable_spec.rb
index ebde2d80..9d86c41e 100644
--- a/spec/features/two_factor_authenticatable_spec.rb
+++ b/spec/features/two_factor_authenticatable_spec.rb
@@ -18,7 +18,6 @@
it 'sends code via SMS after sign in' do
visit new_user_session_path
complete_sign_in_form_for(user)
-
expect(page).to have_content 'Enter the code that was sent to you'
expect(SMSProvider.messages.size).to eq(1)
@@ -44,8 +43,8 @@
end
end
- it_behaves_like 'sends and authenticates code', create_user('not_encrypted')
- it_behaves_like 'sends and authenticates code', create_user, 'encrypted'
+ it_behaves_like 'sends and authenticates code', create_user('not_encrypted', otp_enabled: true)
+ it_behaves_like 'sends and authenticates code', create_user('encrypted', otp_enabled: true), 'encrypted'
end
scenario "must be logged in" do
@@ -55,8 +54,48 @@
expect(page).to have_content("You are signed out")
end
+ context "when logged in without otp enabled" do
+ let(:user) { create_user('encrypted', otp_enabled: false) }
+
+ background do
+ login_as user
+ end
+
+ scenario "is redirected to TFA activation when path requires authentication" do
+ visit dashboard_path + "?A=param%20a&B=param%20b"
+
+ expect(page).to_not have_content("Your Personal Dashboard")
+ expect(page).to have_xpath('//img')
+ end
+
+ scenario "can enable TFA with a TOTP code" do
+ visit new_user_two_factor_authentication_path
+
+ secret = find(:css, 'i#totp_secret').text
+ totp = ROTP::TOTP.new(secret)
+ fill_in "code", with: totp.now
+ click_button "Confirm and activate"
+
+ expect(page).to have_content("You are signed in as Marissa")
+ expect(page).to have_content("Welcome Home")
+ end
+
+ scenario "can enable TFA with a direct code" do
+ visit new_user_two_factor_authentication_path
+
+ click_link "Send me a code instead"
+
+ visit new_user_two_factor_authentication_path
+ fill_in 'code', with: SMSProvider.last_message.body
+ click_button "Confirm and activate"
+
+ expect(page).to have_content("You are signed in as Marissa")
+ expect(page).to have_content("Welcome Home")
+ end
+ end
+
context "when logged in" do
- let(:user) { create_user }
+ let(:user) { create_user('encrypted', otp_enabled: true) }
background do
login_as user
@@ -144,7 +183,7 @@
logout
reset_session!
- user2 = create_user()
+ user2 = create_user('encrypted', otp_enabled: true)
login_as(user2)
sms_sign_in
@@ -168,7 +207,7 @@ def sms_sign_in
logout
reset_session!
- user2 = create_user()
+ user2 = create_user('encrypted', otp_enabled: true)
set_tfa_cookie(tfa_cookie1)
login_as(user2)
visit dashboard_path
diff --git a/spec/generators/active_record/two_factor_authentication_generator_spec.rb b/spec/generators/active_record/two_factor_authentication_generator_spec.rb
index 5a8989d0..648e13d2 100644
--- a/spec/generators/active_record/two_factor_authentication_generator_spec.rb
+++ b/spec/generators/active_record/two_factor_authentication_generator_spec.rb
@@ -26,6 +26,7 @@
it { is_expected.to exist }
it { is_expected.to be_a_migration }
it { is_expected.to contain /def change/ }
+ it { is_expected.to contain /add_column :users, :otp_enabled, :boolean, default: false/ }
it { is_expected.to contain /add_column :users, :second_factor_attempts_count, :integer, default: 0/ }
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key, :string/ }
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_iv, :string/ }
diff --git a/spec/rails_app/app/models/encrypted_user.rb b/spec/rails_app/app/models/encrypted_user.rb
index 292004a3..4e9033d6 100644
--- a/spec/rails_app/app/models/encrypted_user.rb
+++ b/spec/rails_app/app/models/encrypted_user.rb
@@ -9,7 +9,8 @@ class EncryptedUser
:encrypted_otp_secret_key_salt,
:email,
:second_factor_attempts_count,
- :totp_timestamp
+ :totp_timestamp,
+ :otp_enabled
has_one_time_password(encrypted: true)
end
diff --git a/spec/rails_app/app/models/guest_user.rb b/spec/rails_app/app/models/guest_user.rb
index 8003624c..e6365758 100644
--- a/spec/rails_app/app/models/guest_user.rb
+++ b/spec/rails_app/app/models/guest_user.rb
@@ -5,7 +5,7 @@ class GuestUser
define_model_callbacks :create
attr_accessor :direct_otp, :direct_otp_sent_at, :otp_secret_key, :email,
- :second_factor_attempts_count, :totp_timestamp
+ :second_factor_attempts_count, :totp_timestamp, :otp_enabled
def update_attributes(attrs)
attrs.each do |key, value|
diff --git a/spec/rails_app/db/migrate/20161110120108_add_enable_otp_to_user.rb b/spec/rails_app/db/migrate/20161110120108_add_enable_otp_to_user.rb
new file mode 100644
index 00000000..17dcc594
--- /dev/null
+++ b/spec/rails_app/db/migrate/20161110120108_add_enable_otp_to_user.rb
@@ -0,0 +1,5 @@
+class AddEnableOtpToUser < ActiveRecord::Migration
+ def change
+ add_column :users, :otp_enabled, :boolean, default: false
+ end
+end
diff --git a/spec/rails_app/db/schema.rb b/spec/rails_app/db/schema.rb
index 5f011618..23d5d32f 100644
--- a/spec/rails_app/db/schema.rb
+++ b/spec/rails_app/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20160209032439) do
+ActiveRecord::Schema.define(version: 20161110120108) do
create_table "admins", force: :cascade do |t|
t.string "email", default: "", null: false
@@ -32,23 +32,24 @@
add_index "admins", ["reset_password_token"], name: "index_admins_on_reset_password_token", unique: true
create_table "users", force: :cascade do |t|
- t.string "email", default: "", null: false
- t.string "encrypted_password", default: "", null: false
+ t.string "email", default: "", null: false
+ t.string "encrypted_password", default: "", null: false
t.string "reset_password_token"
t.datetime "reset_password_sent_at"
t.datetime "remember_created_at"
- t.integer "sign_in_count", default: 0, null: false
+ t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at"
t.datetime "last_sign_in_at"
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
t.integer "second_factor_attempts_count", default: 0
t.string "nickname", limit: 64
t.string "encrypted_otp_secret_key"
t.string "encrypted_otp_secret_key_iv"
t.string "encrypted_otp_secret_key_salt"
+ t.boolean "otp_enabled", default: false
end
add_index "users", ["email"], name: "index_users_on_email", unique: true
diff --git a/spec/support/authenticated_model_helper.rb b/spec/support/authenticated_model_helper.rb
index 42696e68..ba0c10f5 100644
--- a/spec/support/authenticated_model_helper.rb
+++ b/spec/support/authenticated_model_helper.rb
@@ -50,6 +50,7 @@ def create_table_for_nonencrypted_user
t.string 'direct_otp'
t.datetime 'direct_otp_sent_at'
t.timestamp 'totp_timestamp'
+ t.boolean 'otp_enabled', default: true
end
end
end
diff --git a/two_factor_authentication.gemspec b/two_factor_authentication.gemspec
index d606d6ed..8dae3f3b 100644
--- a/two_factor_authentication.gemspec
+++ b/two_factor_authentication.gemspec
@@ -29,6 +29,7 @@ Gem::Specification.new do |s|
s.add_runtime_dependency 'randexp'
s.add_runtime_dependency 'rotp', '>= 3.2.0'
s.add_runtime_dependency 'encryptor'
+ s.add_runtime_dependency 'rqrcode', '>= 0.10.1'
s.add_development_dependency 'bundler'
s.add_development_dependency 'rake'