diff --git a/Gemfile b/Gemfile index 5266c57d..73efed4b 100644 --- a/Gemfile +++ b/Gemfile @@ -24,6 +24,8 @@ gem 'sweetalert-rails' gem 'devise', '~> 4.0' gem 'devise-i18n' gem 'devise-async' +gem 'active_model_otp' +gem 'rqrcode' gem 'bootstrap_form' gem 'font-kit-rails' gem 'nprogress-rails' diff --git a/Gemfile.lock b/Gemfile.lock index a71e1561..91034a28 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -59,6 +59,9 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) + active_model_otp (2.0.1) + activemodel + rotp (~> 5.0.0) activejob (5.2.4.3) activesupport (= 5.2.4.3) globalid (>= 0.3.6) @@ -84,8 +87,8 @@ GEM addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) arel (9.0.0) - ast (2.4.0) - autoprefixer-rails (9.7.6) + ast (2.4.1) + autoprefixer-rails (9.8.5) execjs bcrypt (3.1.13) better_errors (2.7.1) @@ -110,7 +113,7 @@ GEM buftok (0.2.0) builder (3.2.4) byebug (11.1.3) - capybara (3.32.2) + capybara (3.33.0) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) @@ -125,8 +128,9 @@ GEM image_processing (~> 1.1) mimemagic (>= 0.3.0) mini_mime (>= 0.1.3) + chunky_png (1.3.12) cliver (0.3.2) - coderay (1.1.2) + coderay (1.1.3) coffee-rails (4.2.2) coffee-script (>= 2.2.0) railties (>= 4.0.0) @@ -140,7 +144,7 @@ GEM crass (1.0.6) database_cleaner (1.8.5) debug_inspector (0.0.3) - devise (4.7.1) + devise (4.7.2) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) @@ -151,19 +155,19 @@ GEM devise (>= 4.0) devise-i18n (1.9.1) devise (>= 4.7.1) - diff-lcs (1.3) + diff-lcs (1.4.4) docile (1.3.2) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) equalizer (0.0.11) erubi (1.9.0) - excon (0.73.0) + excon (0.75.0) execjs (2.7.0) - factory_bot (5.2.0) - activesupport (>= 4.2.0) - factory_bot_rails (5.2.0) - factory_bot (~> 5.2.0) - railties (>= 4.2.0) + factory_bot (6.1.0) + activesupport (>= 5.0.0) + factory_bot_rails (6.1.0) + factory_bot (~> 6.1.0) + railties (>= 5.0.0) fake_email_validator (1.0.11) activemodel mail @@ -173,11 +177,11 @@ GEM multipart-post (>= 1.2, < 3) faraday_middleware (1.0.0) faraday (~> 1.0) - ffi (1.12.2) + ffi (1.13.1) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake - fog-aws (3.6.5) + fog-aws (3.6.6) fog-core (~> 2.1) fog-json (~> 1.1) fog-xml (~> 0.1) @@ -236,7 +240,7 @@ GEM http-parser (1.2.1) ffi-compiler (>= 1.0, < 2.0) http_parser.rb (0.6.0) - httparty (0.18.0) + httparty (0.18.1) mime-types (~> 3.0) multi_xml (>= 0.5.2) i18n (0.9.5) @@ -261,7 +265,7 @@ GEM turbolinks jquery-ui-rails (6.0.1) railties (>= 3.2.16) - json (2.3.0) + json (2.3.1) kaminari (1.2.1) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.1) @@ -281,10 +285,10 @@ GEM listen (3.2.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.5.0) + loofah (2.6.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) - lumberjack (1.2.4) + lumberjack (1.2.6) mail (2.7.1) mini_mime (>= 0.1.1) marcel (0.3.3) @@ -304,15 +308,15 @@ GEM momentjs-rails (>= 2.10.5, <= 3.0.0) momentjs-rails (2.20.1) railties (>= 3.1) - multi_json (1.14.1) + multi_json (1.15.0) multi_xml (0.6.0) multipart-post (2.1.1) naught (1.1.0) nenv (0.3.0) nested_form (0.3.2) - newrelic_rpm (6.10.0.364) + newrelic_rpm (6.11.0.365) nio4r (2.5.2) - nokogiri (1.10.9) + nokogiri (1.10.10) mini_portile2 (~> 2.4.0) nokogumbo (2.0.2) nokogiri (~> 1.8, >= 1.8.4) @@ -334,9 +338,9 @@ GEM omniauth-oauth (~> 1.1) rack orm_adapter (0.5.0) - parallel (1.19.1) - parser (2.7.1.2) - ast (~> 2.4.0) + parallel (1.19.2) + parser (2.7.1.4) + ast (~> 2.4.1) pg (1.2.3) pghero (2.7.0) activerecord (>= 5) @@ -375,10 +379,10 @@ GEM rails-assets-growl (1.3.5) rails-assets-jquery rails-assets-jquery (2.2.4) - rails-controller-testing (1.0.4) - actionpack (>= 5.0.1.x) - actionview (>= 5.0.1.x) - activesupport (>= 5.0.1.x) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) @@ -412,13 +416,19 @@ GEM ffi (~> 1.0) redcarpet (3.5.0) redis (4.1.4) - regexp_parser (1.7.0) + regexp_parser (1.7.1) remotipart (1.4.4) - responders (3.0.0) + responders (3.0.1) actionpack (>= 5.0) railties (>= 5.0) rexml (3.2.4) - rolify (5.2.0) + rolify (5.3.0) + rotp (5.0.0) + addressable (~> 2.5) + rqrcode (1.1.2) + chunky_png (~> 1.0) + rqrcode_core (~> 0.1) + rqrcode_core (0.1.2) rspec-core (3.9.2) rspec-support (~> 3.9.3) rspec-expectations (3.9.2) @@ -438,19 +448,20 @@ GEM rspec-expectations (~> 3.9.0) rspec-mocks (~> 3.9.0) rspec-support (~> 3.9.0) - rspec-sidekiq (3.0.3) + rspec-sidekiq (3.1.0) rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) rspec-support (3.9.3) - rubocop (0.84.0) + rubocop (0.88.0) parallel (~> 1.10) - parser (>= 2.7.0.1) + parser (>= 2.7.1.1) rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.7) rexml - rubocop-ast (>= 0.0.3) + rubocop-ast (>= 0.1.0, < 1.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 2.0) - rubocop-ast (0.0.3) + rubocop-ast (0.1.0) parser (>= 2.7.0.1) ruby-progressbar (1.10.1) ruby-vips (2.0.17) @@ -470,7 +481,7 @@ GEM sprockets (>= 2.8, < 4.0) sprockets-rails (>= 2.0, < 4.0) tilt (>= 1.1, < 3) - sassc (2.3.0) + sassc (2.4.0) ffi (~> 1.9) sassc-rails (2.1.2) railties (>= 4.0.0) @@ -540,7 +551,7 @@ GEM activemodel (>= 5.0) bindex (>= 0.4.0) railties (>= 5.0) - websocket-driver (0.7.2) + websocket-driver (0.7.3) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) @@ -550,6 +561,7 @@ PLATFORMS ruby DEPENDENCIES + active_model_otp bcrypt (~> 3.1.7) better_errors binding_of_caller @@ -609,6 +621,7 @@ DEPENDENCIES redcarpet redis rolify (~> 5.2) + rqrcode rspec-its (~> 1.3) rspec-mocks rspec-rails (~> 3.9) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 768706ef..5b033152 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -91,6 +91,7 @@ "components/profile", "components/question", "components/smiles", +"components/totp-setup", "components/userbox"; /** diff --git a/app/assets/stylesheets/components/_totp-setup.scss b/app/assets/stylesheets/components/_totp-setup.scss new file mode 100644 index 00000000..67d0c932 --- /dev/null +++ b/app/assets/stylesheets/components/_totp-setup.scss @@ -0,0 +1,50 @@ +%totp-input { + font-family: "Monaco", "Inconsolata", "Cascadia Code", "Consolas", monospace; + width: 86px; +} + +.totp-setup { + &__card { + background: var(--primary); + padding: 10px; + border-radius: 5px; + + min-width: 256px; + max-width: 256px; + width: 100%; + margin: 0 auto; + } + + &__card-container { + min-width: 276px; + max-width: 276px; + width: 100%; + padding: 0; + } + + &__qr { + background: white; + border-radius: 5px; + } + + &__text { + background: #000; + color: #fff; + margin: 10px 0 0 0; + padding: 5px; + border-radius: 5px; + + code { + display: block; + color: var(--warning); + } + } + + &__code-field { + @extend %totp-input; + } +} + +#user_otp_attempt { + @extend %totp-input; +} \ No newline at end of file diff --git a/app/controllers/user/sessions_controller.rb b/app/controllers/user/sessions_controller.rb new file mode 100644 index 00000000..cd8d96bf --- /dev/null +++ b/app/controllers/user/sessions_controller.rb @@ -0,0 +1,42 @@ +class User::SessionsController < Devise::SessionsController + def new + session.delete(:user_sign_in_uid) + super + end + + def create + if session.has_key?(:user_sign_in_uid) + self.resource = User.find(session.delete(:user_sign_in_uid)) + else + self.resource = warden.authenticate!(auth_options) + end + + if resource.active_for_authentication? && resource.otp_module_enabled? + if params[:user][:otp_attempt].blank? + session[:user_sign_in_uid] = resource.id + sign_out(resource) + warden.lock! + render 'auth/two_factor_authentication' + else + if resource.authenticate_otp(params[:user][:otp_attempt], drift: APP_CONFIG.fetch(:otp_drift_period, 30).to_i) + continue_sign_in(resource, resource_name) + else + sign_out(resource) + flash[:error] = t('views.auth.2fa.errors.invalid_code') + redirect_to new_user_session_url + end + end + else + continue_sign_in(resource, resource_name) + end + end + + private + + def continue_sign_in(resource, resource_name) + set_flash_message!(:notice, :signed_in) + sign_in(resource_name, resource) + yield resource if block_given? + respond_with resource, location: after_sign_in_path_for(resource) + end +end \ No newline at end of file diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb index 204044cf..b2fc4b7c 100644 --- a/app/controllers/user_controller.rb +++ b/app/controllers/user_controller.rb @@ -172,4 +172,37 @@ class UserController < ApplicationController redirect_to user_export_path end + + def edit_security + if current_user.otp_module_disabled? + current_user.otp_secret_key = User.otp_random_secret(26) + current_user.save + + @provisioning_uri = current_user.provisioning_uri(nil, issuer: APP_CONFIG[:hostname]) + qr_code = RQRCode::QRCode.new(current_user.provisioning_uri("Retrospring:#{current_user.screen_name}", issuer: "Retrospring")) + + @qr_svg = qr_code.as_svg({offset: 4, module_size: 4, color: '000;fill:var(--primary)'}).html_safe + end + end + + def update_2fa + req_params = params.require(:user).permit(:otp_validation) + current_user.otp_module = :enabled + + if current_user.authenticate_otp(req_params[:otp_validation], drift: APP_CONFIG.fetch(:otp_drift_period, 30).to_i) + flash[:success] = t('views.auth.2fa.setup.success') + current_user.save! + else + flash[:error] = t('views.auth.2fa.errors.invalid_code') + end + + redirect_to edit_user_security_path + end + + def destroy_2fa + current_user.otp_module = :disabled + current_user.save! + flash[:success] = 'Two factor authentication has been disabled for your account.' + redirect_to edit_user_security_path + end end diff --git a/app/models/user.rb b/app/models/user.rb index 8bec2c60..b1e636d7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,6 +4,7 @@ class User < ApplicationRecord include User::QuestionMethods include User::RelationshipMethods include User::TimelineMethods + include ActiveModel::OneTimePassword # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable @@ -11,6 +12,10 @@ class User < ApplicationRecord :recoverable, :rememberable, :trackable, :validatable, :confirmable, :authentication_keys => [:login] + has_one_time_password + enum otp_module: { disabled: 0, enabled: 1 }, _prefix: true + attr_accessor :otp_attempt, :otp_validation + rolify # attr_accessor :login diff --git a/app/views/auth/two_factor_authentication.haml b/app/views/auth/two_factor_authentication.haml new file mode 100644 index 00000000..75583213 --- /dev/null +++ b/app/views/auth/two_factor_authentication.haml @@ -0,0 +1,14 @@ +.container + .row + .col-sm-4.offset-sm-4 + = render 'layouts/messages' + .card.mt-3 + .card-body + %h1.mb-3.mt-0= t('views.auth.2fa.title') + = bootstrap_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| + + = f.text_field :otp_attempt, autofocus: true, label: t('views.auth.2fa.otp_field') + + = f.submit t('views.sessions.create'), class: 'btn btn-primary mt-3 mb-3' + += render 'shared/links' diff --git a/app/views/discover/_userbox.haml b/app/views/discover/_userbox.haml index b087ca2f..8ae7567f 100644 --- a/app/views/discover/_userbox.haml +++ b/app/views/discover/_userbox.haml @@ -14,9 +14,10 @@ = u.display_name %span.text-muted= u.screen_name %p.answerbox__question-text - - if type == 'new' + - case type + - when 'new' = t('views.discover.userbox.new', time: time_ago_in_words(u.created_at)) - - elsif type == 'most' + - when 'most' = t('views.discover.userbox.answers', questions: pluralize(a, t('views.general.question'))) - else = t('views.discover.userbox.questions', questions: pluralize(q, t('views.general.question'))) diff --git a/app/views/settings/_security.haml b/app/views/settings/_security.haml new file mode 100644 index 00000000..bc673ba7 --- /dev/null +++ b/app/views/settings/_security.haml @@ -0,0 +1,7 @@ +.card + .card-body + %h2= t('views.settings.security.2fa.title') + - if current_user.otp_module_disabled? + = render partial: 'settings/security/totp_setup', locals: { qr_svg: qr_svg } + - else + = render partial: 'settings/security/totp_enabled' diff --git a/app/views/settings/security/_totp_enabled.haml b/app/views/settings/security/_totp_enabled.haml new file mode 100644 index 00000000..4c383227 --- /dev/null +++ b/app/views/settings/security/_totp_enabled.haml @@ -0,0 +1,3 @@ +%p Your account is set up to require the use of a one-time password in order to log in += link_to t('views.actions.remove'), destroy_user_2fa_path, class: 'btn btn-primary', method: 'delete', + data: { confirm: t('views.settings.security.2fa.detach_confirm') } diff --git a/app/views/settings/security/_totp_setup.haml b/app/views/settings/security/_totp_setup.haml new file mode 100644 index 00000000..a32d0661 --- /dev/null +++ b/app/views/settings/security/_totp_setup.haml @@ -0,0 +1,42 @@ +.totp-setup.container + .row + .totp-setup__card-container.col + .totp-setup__card + .totp-setup__qr + = qr_svg + %p.totp-setup__text + If you cannot scan the QR code, use the following key instead: + %code= current_user.otp_secret_key.scan(/.{4}/).flatten.join(' ') + .totp-setup__content.col + = bootstrap_form_for(current_user, url: { action: :update_2fa, method: :post }) do |f| + %p + If you do not have an authenticator app already installed on your device, we suggest one of the following: + %ul.list-unstyled.pl-3 + %li + %i.fa.fa-android + Aegis Authenticator for Android + %ul.list-inline + %li.list-inline-item + %a{ href: 'https://play.google.com/store/apps/details?id=com.beemdevelopment.aegis' } Google Play + %li.list-inline-item + %a{ href: 'https://f-droid.org/app/com.beemdevelopment.aegis' } F-Droid + %li.list-inline-item + %a{ href: 'https://github.com/beemdevelopment/Aegis' } Source Code + %li + %i.fa.fa-apple + Strongbox Authenticator for iOS + %ul.list-inline + %li.list-inline-item + %a{ href: 'https://apps.apple.com/gb/app/strongbox-authenticator/id1023839880' } App Store + %li + %i.fa.fa-apple + %i.fa.fa-android + Microsoft Authenticator + %ul.list-inline + %li.list-inline-item + %a{ href: 'https://apps.apple.com/gb/app/microsoft-authenticator/id983156458' } App Store + %li.list-inline-item + %a{ href: 'https://play.google.com/store/apps/details?id=com.azure.authenticator' } Google Play + %p Once you have downloaded an authenticator app, add your Retrospring account by scanning the QR code displayed on the left. + = f.text_field :otp_validation, class: 'totp-setup__code-field', label: 'Enter the code displayed in the app here:', autofocus: true + = f.submit t('views.actions.save'), class: 'btn btn-primary' diff --git a/app/views/tabs/_settings.haml b/app/views/tabs/_settings.haml index 13d4a420..d6210214 100644 --- a/app/views/tabs/_settings.haml +++ b/app/views/tabs/_settings.haml @@ -3,6 +3,7 @@ = list_group_item t('views.settings.tabs.account'), edit_user_registration_path = list_group_item t('views.settings.tabs.profile'), edit_user_profile_path = list_group_item t('views.settings.tabs.privacy'), edit_user_privacy_path + = list_group_item t('views.settings.tabs.security'), edit_user_security_path = list_group_item t('views.settings.tabs.sharing'), services_path = list_group_item 'Theme', edit_user_theme_path = list_group_item 'Your Data', user_data_path diff --git a/app/views/user/edit_security.haml b/app/views/user/edit_security.haml new file mode 100644 index 00000000..bcdebbea --- /dev/null +++ b/app/views/user/edit_security.haml @@ -0,0 +1,4 @@ += render 'settings/security', qr_svg: @qr_svg + +- provide(:title, generate_title('Security Settings')) +- parent_layout 'user/settings' diff --git a/config/justask.yml.example b/config/justask.yml.example index 57ce7c8a..280b871d 100644 --- a/config/justask.yml.example +++ b/config/justask.yml.example @@ -68,3 +68,6 @@ hcaptcha: enabled: false site_key: '' secret_key: '' + +# TOTP Drift period in seconds +otp_drift_period: 30 diff --git a/config/locales/en.yml b/config/locales/en.yml index f663e63c..0c2e5325 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -377,6 +377,7 @@ en: profile: "Profile" privacy: "Privacy" sharing: "Sharing" + security: "Security" account: modal: title: "Save account changes" @@ -415,6 +416,10 @@ en: connect: "Connect to %{service}" disconnect: "Disconnect" confirm: "Really disconnect service %{service}?" + security: + 2fa: + title: "Two-factor authentication" + detach_confirm: "Are you sure you want to disable two-factor authentication?" modal: ask: title: "Ask your followers" @@ -443,3 +448,11 @@ en: admin: "Admin" moderator: "Moderator" banned: "Banned" + auth: + 2fa: + title: "Two-factor authentication" + otp_field: "One-time password" + errors: + invalid_code: "The code you entered was invalid." + setup: + success: "Two factor authentication has been enabled for your account." diff --git a/config/routes.rb b/config/routes.rb index 92d57d42..757b520c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -47,8 +47,8 @@ Rails.application.routes.draw do devise_for :users, path: 'user', skip: [:sessions, :registrations] as :user do # :sessions - get 'sign_in' => 'devise/sessions#new', as: :new_user_session - post 'sign_in' => 'devise/sessions#create', as: :user_session + get 'sign_in' => 'user/sessions#new', as: :new_user_session + post 'sign_in' => 'user/sessions#create', as: :user_session delete 'sign_out' => 'devise/sessions#destroy', as: :destroy_user_session # :registrations get 'settings/delete_account' => 'devise/registrations#cancel', as: :cancel_user_registration @@ -67,6 +67,10 @@ Rails.application.routes.draw do match '/settings/theme', to: 'user#update_theme', via: 'patch', as: :update_user_theme match '/settings/theme/delete', to: 'user#delete_theme', via: 'delete', as: :delete_user_theme + match '/settings/security', to: 'user#edit_security', via: :get, as: :edit_user_security + match '/settings/security/2fa', to: 'user#update_2fa', via: :patch, as: :update_user_2fa + match '/settings/security/2fa', to: 'user#destroy_2fa', via: :delete, as: :destroy_user_2fa + # resources :services, only: [:index, :destroy] match '/settings/services', to: 'services#index', via: 'get', as: :services match '/settings/services/:id', to: 'services#destroy', via: 'delete', as: :service diff --git a/db/migrate/20201001172537_add_otp_secret_key_to_users.rb b/db/migrate/20201001172537_add_otp_secret_key_to_users.rb new file mode 100644 index 00000000..59a33b48 --- /dev/null +++ b/db/migrate/20201001172537_add_otp_secret_key_to_users.rb @@ -0,0 +1,6 @@ +class AddOtpSecretKeyToUsers < ActiveRecord::Migration[5.2] + def change + add_column :users, :otp_secret_key, :string + add_column :users, :otp_module, :integer, default: 0, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index f399bfce..4ad56134 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_07_04_163504) do +ActiveRecord::Schema.define(version: 2020_10_18_090453) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -273,6 +273,8 @@ ActiveRecord::Schema.define(version: 2020_07_04_163504) do t.string "export_url" t.boolean "export_processing", default: false, null: false t.datetime "export_created_at" + t.string "otp_secret_key" + t.integer "otp_module" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true diff --git a/spec/controllers/user/registration_controller_spec.rb b/spec/controllers/user/registration_controller_spec.rb index 3e7670fc..a2b699b1 100644 --- a/spec/controllers/user/registration_controller_spec.rb +++ b/spec/controllers/user/registration_controller_spec.rb @@ -4,6 +4,7 @@ require "rails_helper" describe User::RegistrationsController, type: :controller do before do + # Required for devise to register routes @request.env["devise.mapping"] = Devise.mappings[:user] end diff --git a/spec/controllers/user/sessions_controller_spec.rb b/spec/controllers/user/sessions_controller_spec.rb new file mode 100644 index 00000000..ced9528c --- /dev/null +++ b/spec/controllers/user/sessions_controller_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +describe User::SessionsController do + before do + # Required for devise to register routes + @request.env["devise.mapping"] = Devise.mappings[:user] + end + + describe "#create" do + let(:user) { FactoryBot.create(:user, password: '/bin/animals64') } + + subject { post :create, params: { user: { login: user.email, password: user.password } } } + + it "logs in users without 2FA enabled without any further input" do + expect(subject).to redirect_to :root + end + + it "prompts users with 2FA enabled to enter a code" do + user.otp_module = :enabled + user.save + + expect(subject).to have_rendered('auth/two_factor_authentication') + end + end +end \ No newline at end of file diff --git a/spec/controllers/user_controller_spec.rb b/spec/controllers/user_controller_spec.rb index 08d2f53c..9a18b75d 100644 --- a/spec/controllers/user_controller_spec.rb +++ b/spec/controllers/user_controller_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" describe UserController, type: :controller do - let(:user) { FactoryBot.create :user } + let(:user) { FactoryBot.create :user, otp_module: :disabled } describe "#edit" do subject { get :edit } @@ -63,4 +63,93 @@ describe UserController, type: :controller do end end end + + describe "#edit_security" do + subject { get :edit_security } + + context "user signed in" do + before(:each) { sign_in user } + render_views + + it "shows a setup form for users who don't have 2FA enabled" do + subject + expect(response).to have_rendered(:edit_security) + expect(response).to have_rendered(partial: 'settings/security/_totp_setup') + end + + it "shows the option to disable 2FA for users who have 2FA already enabled" do + user.otp_module = :enabled + user.save + + subject + expect(response).to have_rendered(:edit_security) + expect(response).to have_rendered(partial: 'settings/security/_totp_enabled') + end + end + end + + describe "#update_2fa" do + subject { post :update_2fa, params: update_params } + + context "user signed in" do + before(:each) { sign_in user } + + context "user enters the incorrect code" do + let(:update_params) do + { + user: { otp_secret_key: 'EJFNIJPYXXTCQSRTQY6AG7XQLAT2IDG5H7NGLJE3', + otp_validation: 123456 } + } + end + + it "shows an error if the user enters the incorrect code" do + Timecop.freeze(Time.at(1603290888)) do + subject + expect(response).to redirect_to :edit_user_security + end + end + end + + context "user enters the correct code" do + let(:update_params) do + { + user: { otp_secret_key: 'EJFNIJPYXXTCQSRTQY6AG7XQLAT2IDG5H7NGLJE3', + otp_validation: 187894 } + } + end + + it "enables 2FA for the logged in user" do + Timecop.freeze(Time.at(1603290888)) do + subject + expect(response).to redirect_to :edit_user_security + end + end + + it "shows an error if the user attempts to use the code once it has expired" do + Timecop.freeze(Time.at(1603290910)) do + subject + expect(flash[:error]).to eq('The code you entered was invalid.') + end + end + end + end + end + + describe "#destroy_2fa" do + subject { delete :destroy_2fa } + + context "user signed in" do + before(:each) do + user.otp_module = :enabled + user.save + sign_in user + end + + it "disables 2FA for the logged in user" do + subject + user.reload + expect(user.otp_module_enabled?).to be_falsey + end + end + end end