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'