diff --git a/Gemfile b/Gemfile index e42fb4390..d3a99ea73 100644 --- a/Gemfile +++ b/Gemfile @@ -75,6 +75,8 @@ gem 'nokogiri', '~> 1.10.8' # HTML/XML parser gem 'rails_autolink', '~> 1.1', '>= 1.1.6' gem 'rgl' # Graph framework for project diagram calculations gem 'roo', '~> 2.8.2' # Spreadsheet parser +gem 'rotp' +gem 'rqrcode' # QR code generator gem 'rubyzip' gem 'scenic', '~> 1.4' gem 'sdoc', '~> 1.0', group: :doc diff --git a/Gemfile.lock b/Gemfile.lock index 4b0b8fa77..faf79f603 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -186,6 +186,7 @@ GEM activesupport childprocess (1.0.1) rake (< 13.0) + chunky_png (1.3.11) coderay (1.1.2) coffee-rails (5.0.0) coffee-script (>= 2.2.0) @@ -469,6 +470,12 @@ GEM roo (2.8.2) nokogiri (~> 1) rubyzip (>= 1.2.1, < 2.0.0) + rotp (6.0.0) + addressable (~> 2.7) + rqrcode (1.1.2) + chunky_png (~> 1.0) + rqrcode_core (~> 0.1) + rqrcode_core (0.1.2) rspec-core (3.8.2) rspec-support (~> 3.8.0) rspec-expectations (3.8.4) @@ -668,6 +675,8 @@ DEPENDENCIES recaptcha rgl roo (~> 2.8.2) + rotp + rqrcode rspec-rails (>= 4.0.0.beta2) rubocop (>= 0.75.0) rubocop-performance diff --git a/app/assets/images/2fa/2fa_authenticator.png b/app/assets/images/2fa/2fa_authenticator.png new file mode 100644 index 000000000..41a1e5d4f Binary files /dev/null and b/app/assets/images/2fa/2fa_authenticator.png differ diff --git a/app/assets/images/2fa/google_authenticator.png b/app/assets/images/2fa/google_authenticator.png new file mode 100644 index 000000000..c9405bd60 Binary files /dev/null and b/app/assets/images/2fa/google_authenticator.png differ diff --git a/app/assets/images/2fa/install_mobile.png b/app/assets/images/2fa/install_mobile.png new file mode 100644 index 000000000..ab9ee6cd1 Binary files /dev/null and b/app/assets/images/2fa/install_mobile.png differ diff --git a/app/assets/images/2fa/ms_authenticator.png b/app/assets/images/2fa/ms_authenticator.png new file mode 100644 index 000000000..00f36b7fa Binary files /dev/null and b/app/assets/images/2fa/ms_authenticator.png differ diff --git a/app/assets/javascripts/users/registrations/edit.js b/app/assets/javascripts/users/registrations/edit.js index cd2a5d551..465aa97a6 100644 --- a/app/assets/javascripts/users/registrations/edit.js +++ b/app/assets/javascripts/users/registrations/edit.js @@ -79,4 +79,36 @@ } $fileInput[0].value = ''; }); + + $('#twoFactorAuthenticationDisable').click(function() { + $('#twoFactorAuthenticationModal').modal('show'); + }); + + $('#twoFactorAuthenticationEnable').click(function() { + $.get(this.dataset.qrCodeUrl, function(result) { + $('#twoFactorAuthenticationModal .qr-code').html(result.qr_code); + $('#twoFactorAuthenticationModal').find('[href="#2fa-step-1"]').tab('show'); + $('#twoFactorAuthenticationModal').modal('show'); + }); + }); + + $('#twoFactorAuthenticationModal .2fa-enable-form').on('ajax:error', function(e, data) { + $(this).find('.submit-code-field').addClass('error').attr('data-error-text', data.responseJSON.error); + }).on('ajax:success', function(e, data) { + var blob = new Blob([data.recovery_codes.join('\r\n')], { type: 'text/plain;charset=utf-8' }); + $('#twoFactorAuthenticationModal').find('.recovery-codes').html(data.recovery_codes.join('
')); + $('#twoFactorAuthenticationModal').find('[href="#2fa-step-4"]').tab('show'); + $('.download-recovery-codes').attr('href', window.URL.createObjectURL(blob)); + $('#twoFactorAuthenticationModal').one('hide.bs.modal', function() { + location.reload(); + }); + }); + + $('#twoFactorAuthenticationModal .2fa-disable-form').on('ajax:error', function(e, data) { + $(this).find('.password-field').addClass('error').attr('data-error-text', data.responseJSON.error); + }); + + $('#twoFactorAuthenticationModal').on('click', '.btn-next-step', function() { + $('#twoFactorAuthenticationModal').find(`[href="${$(this).data('step')}"]`).tab('show'); + }); }()); diff --git a/app/assets/stylesheets/settings/users.scss b/app/assets/stylesheets/settings/users.scss index 90840a469..58bf693da 100644 --- a/app/assets/stylesheets/settings/users.scss +++ b/app/assets/stylesheets/settings/users.scss @@ -1,7 +1,128 @@ @import "constants"; @import "mixins"; -.user-settings-block { - display: block; - margin-bottom: 20px; +.user-settings { + .settings-row { + margin-top: 2em; + + .user-attribute { + align-items: center; + display: flex; + + .btn { + margin-left: auto; + } + } + } + + .user-settings-block { + display: block; + margin-bottom: 20px; + } + + + .two-factor-container { + border: $border-default; + column-gap: 1em; + display: grid; + grid-template-columns: auto fit-content; + margin: 1em 0; + padding: 1em; + row-gap: .5em; + + + .title { + @include font-main; + flex-basis: 100%; + grid-column: 1 / span 2; + + .enabled { + @include font-button; + color: $brand-success; + } + } + + .btn { + align-self: end; + } + } +} + + +.two-factor-modal { + .two-factor-apps { + align-items: center; + display: flex; + margin: 2em 0; + + .app { + align-items: center; + display: flex; + margin: 2em 0; + + .app-information { + margin-left: 1.5em; + + .app-name { + @include font-h3; + } + } + + .store { + margin-right: 1em; + } + } + + .apps-list { + flex-shrink: 0; + z-index: 2; + } + + .install-mobile { + margin-left: -150px; + } + } + + .modal-footer { + text-align: center; + } + + .tab-footer { + text-align: center; + } + + .qr-code { + display: flex; + justify-content: center; + padding: 4em; + } + + .verified-label { + color: $brand-success; + margin-top: 0; + } + + .recovery-codes { + @include font-h3; + line-height: 2em; + text-align: center; + } +} + +@media (max-width: 700px) { + .user-settings { + .two-factor-container { + grid-template-columns: auto; + + .title { + grid-column: 1; + } + } + } + + .two-factor-modal { + .install-mobile { + display: none; + } + } } diff --git a/app/assets/stylesheets/themes/scinote.scss b/app/assets/stylesheets/themes/scinote.scss index 56f9abf8e..e504f8d67 100644 --- a/app/assets/stylesheets/themes/scinote.scss +++ b/app/assets/stylesheets/themes/scinote.scss @@ -469,8 +469,10 @@ a[data-toggle="tooltip"] { } .user-statistics { + margin-top: 1em; + .list-inline { - margin-left: 15px; + margin-left: 0; } .label { @@ -479,10 +481,8 @@ a[data-toggle="tooltip"] { li { height: 100px; - margin: 15px; - padding-bottom: 15px; - padding-left: 10px; - padding-right: 10px; + margin-bottom: 1em; + margin-right: 2em; width: 100px; } } @@ -1318,10 +1318,9 @@ ul.content-activities { .avatar-container { background-color: lighten($color-concrete, 2%); border-radius: 50%; - height: 100px; - margin-top: 5px; + height: 5em; position: relative; - width: 100px; + width: 5em; .avatar-edit { background-color: $color-silver-chalice; @@ -1356,6 +1355,8 @@ ul.content-activities { img { border-radius: 50%; + height: 100%; + width: 100%; } } } diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index ea2fac89e..e1ab95937 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -182,6 +182,31 @@ class Users::RegistrationsController < Devise::RegistrationsController end end + def two_factor_enable + if current_user.valid_otp?(params[:submit_code]) + recovery_codes = current_user.enable_2fa! + render json: { recovery_codes: recovery_codes } + else + render json: { error: t('users.registrations.edit.2fa_errors.wrong_submit_code') }, status: :unprocessable_entity + end + end + + def two_factor_disable + if current_user.valid_password?(params[:password]) + current_user.disable_2fa! + redirect_to edit_user_registration_path + else + render json: { error: t('users.registrations.edit.2fa_errors.wrong_password') }, status: :forbidden + end + end + + def two_factor_qr_code + current_user.assign_2fa_token! + qr_code_url = ROTP::TOTP.new(current_user.otp_secret, issuer: 'SciNote').provisioning_uri(current_user.email) + qr_code = RQRCode::QRCode.new(qr_code_url) + render json: { qr_code: qr_code.as_svg(module_size: 4) } + end + protected # Called upon creating User (before .save). Permits parameters and extracts diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 386df0ac1..b50aacc43 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -3,8 +3,9 @@ class Users::SessionsController < Devise::SessionsController layout :session_layout - after_action :after_sign_in, only: :create + after_action :after_sign_in, only: %i(create authenticate_with_two_factor) before_action :remove_authenticate_mesasge_if_root_path, only: :new + prepend_before_action :redirect_2fa, only: :create rescue_from ActionController::InvalidAuthenticityToken do redirect_to new_user_session_path @@ -24,21 +25,13 @@ class Users::SessionsController < Devise::SessionsController def create super - # Schedule templates creation for user - TemplatesService.new.schedule_creation_for_user(current_user) + generate_demo_project + end - # Schedule demo project creation for user - current_user.created_teams.each do |team| - FirstTimeDataGenerator.delay( - queue: :new_demo_project, - priority: 10 - ).seed_demo_data_with_id(current_user.id, team.id) + def two_factor_recovery + unless session[:otp_user_id] + redirect_to new_user_session_path end - rescue StandardError => e - Rails.logger.fatal( - "User ID #{current_user.id}: Error creating inital projects on sign_in: "\ - "#{e.message}" - ) end # DELETE /resource/sign_out @@ -75,7 +68,7 @@ class Users::SessionsController < Devise::SessionsController flash[:system_notification_modal] = true end - private + protected def remove_authenticate_mesasge_if_root_path if session[:user_return_to] == root_path && flash[:alert] == I18n.t('devise.failure.unauthenticated') @@ -83,6 +76,76 @@ class Users::SessionsController < Devise::SessionsController end end + private + + def authenticate_with_two_factor + user = User.find_by(id: session[:otp_user_id]) + + unless user + flash[:alert] = t('devise.sessions.2fa.no_user_error') + redirect_to root_path && return + end + + if user.valid_otp?(params[:otp]) + session.delete(:otp_user_id) + + sign_in(user) + generate_demo_project + flash[:notice] = t('devise.sessions.signed_in') + redirect_to root_path + else + flash.now[:alert] = t('devise.sessions.2fa.error_message') + render :two_factor_auth + end + end + + def authenticate_with_recovery_code + user = User.find_by(id: session[:otp_user_id]) + + unless user + flash[:alert] = t('devise.sessions.2fa.no_user_error') + redirect_to root_path && return + end + + session.delete(:otp_user_id) + if user.recover_2fa!(params[:recovery_code]) + sign_in(user) + generate_demo_project + flash[:notice] = t('devise.sessions.signed_in') + redirect_to root_path + else + flash[:alert] = t("devise.sessions.2fa_recovery.not_correct_code") + redirect_to new_user_session_path + end + + end + + def redirect_2fa + user = User.find_by(email: params[:user][:email]) + + return unless user&.valid_password?(params[:user][:password]) + + if user&.two_factor_auth_enabled? + session[:otp_user_id] = user.id + render :two_factor_auth + end + end + + def generate_demo_project + # Schedule templates creation for user + TemplatesService.new.schedule_creation_for_user(current_user) + + # Schedule demo project creation for user + current_user.created_teams.each do |team| + FirstTimeDataGenerator.delay( + queue: :new_demo_project, + priority: 10 + ).seed_demo_data_with_id(current_user.id, team.id) + end + rescue StandardError => e + Rails.logger.fatal("User ID #{current_user.id}: Error creating inital projects on sign_in: #{e.message}") + end + def session_layout if @simple_sign_in 'sign_in_halt' diff --git a/app/javascript/packs/fontawesome.scss b/app/javascript/packs/fontawesome.scss index fb98b131a..3d61ff3cc 100644 --- a/app/javascript/packs/fontawesome.scss +++ b/app/javascript/packs/fontawesome.scss @@ -3,3 +3,4 @@ $fa-font-path: "~@fortawesome/fontawesome-free/webfonts/"; @import "~@fortawesome/fontawesome-free/scss/fontawesome"; @import "~@fortawesome/fontawesome-free/scss/solid"; @import "~@fortawesome/fontawesome-free/scss/regular"; +@import "~@fortawesome/fontawesome-free/scss/brands"; diff --git a/app/models/user.rb b/app/models/user.rb index 0edbd4150..34e37233d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -641,6 +641,48 @@ class User < ApplicationRecord avatar.blob&.filename&.sanitized end + def valid_otp?(otp) + raise StandardError, 'Missing otp_secret' unless otp_secret + + totp = ROTP::TOTP.new(otp_secret, issuer: 'sciNote') + totp.verify(otp, drift_behind: 10) + end + + def assign_2fa_token! + self.otp_secret = ROTP::Base32.random + save! + end + + def enable_2fa! + recovery_codes = [] + Constants::TWO_FACTOR_RECOVERY_CODE_COUNT.times do + recovery_codes.push(SecureRandom.hex(Constants::TWO_FACTOR_RECOVERY_CODE_LENGTH / 2)) + end + + update!( + two_factor_auth_enabled: true, + otp_recovery_codes: recovery_codes.map { |c| Devise::Encryptor.digest(self.class, c) } + ) + + recovery_codes + end + + def disable_2fa! + update!(two_factor_auth_enabled: false, otp_secret: nil, otp_recovery_codes: nil) + end + + def recover_2fa!(code) + return unless otp_recovery_codes + + otp_recovery_codes.each do |recovery_code| + if Devise::Encryptor.compare(self.class, recovery_code, code) + update!(otp_recovery_codes: otp_recovery_codes.reject { |i| i == recovery_code }) + return true + end + end + false + end + protected def confirmation_required? diff --git a/app/views/users/registrations/_2fa_modal.html.erb b/app/views/users/registrations/_2fa_modal.html.erb new file mode 100644 index 000000000..89567c8dd --- /dev/null +++ b/app/views/users/registrations/_2fa_modal.html.erb @@ -0,0 +1,38 @@ + + diff --git a/app/views/users/registrations/_2fa_modal_apps_tab.html.erb b/app/views/users/registrations/_2fa_modal_apps_tab.html.erb new file mode 100644 index 000000000..08feb40da --- /dev/null +++ b/app/views/users/registrations/_2fa_modal_apps_tab.html.erb @@ -0,0 +1,71 @@ +
+ + + +
diff --git a/app/views/users/registrations/_2fa_modal_qr_code_tab.html.erb b/app/views/users/registrations/_2fa_modal_qr_code_tab.html.erb new file mode 100644 index 000000000..1dfad1611 --- /dev/null +++ b/app/views/users/registrations/_2fa_modal_qr_code_tab.html.erb @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/app/views/users/registrations/_2fa_modal_recovery_codes_tab.html.erb b/app/views/users/registrations/_2fa_modal_recovery_codes_tab.html.erb new file mode 100644 index 000000000..8f325641d --- /dev/null +++ b/app/views/users/registrations/_2fa_modal_recovery_codes_tab.html.erb @@ -0,0 +1,16 @@ +
+ + + +
diff --git a/app/views/users/registrations/_2fa_modal_verify_code_tab.html.erb b/app/views/users/registrations/_2fa_modal_verify_code_tab.html.erb new file mode 100644 index 000000000..75fea64b8 --- /dev/null +++ b/app/views/users/registrations/_2fa_modal_verify_code_tab.html.erb @@ -0,0 +1,18 @@ +
+ + <%= form_with(url: users_2fa_enable_path, method: "post", class: "2fa-enable-form") do %> + + + <% end %> +
diff --git a/app/views/users/registrations/edit.html.erb b/app/views/users/registrations/edit.html.erb index 172507263..1b9b1d48c 100644 --- a/app/views/users/registrations/edit.html.erb +++ b/app/views/users/registrations/edit.html.erb @@ -2,180 +2,71 @@ <% provide(:container_class, "no-second-nav-container") %> <%= render partial: "users/settings/sidebar" %> -
-
-
-
-
-
-

<%=t "users.registrations.edit.title" %>

- - <% if not resource.errors.empty? %> -
- <%= devise_error_messages! %> -
- <% end %> -
- -
- - - - - - <%= form_for(resource, - as: resource_name, - url: registration_path(resource_name, format: :json), - remote: true, - html: { method: :put, "data-for" => "email", class: 'settings-page-email', id: 'user-email-field' }) do |f| %> -
-
- <%= f.label t("users.registrations.edit.email_label") %> - - <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %> - - <% end %> -
-
-
-
-

<%=t "users.registrations.edit.email_title" %>

-
- <%= f.label :email, t("users.registrations.edit.new_email_label") %> - <%= f.email_field :email, class: "form-control", "data-role" => "edit" %> -
-
- <%= f.label :current_password, t("users.registrations.edit.current_password_label") %> <%=t "users.registrations.edit.password_explanation" %> - <%= f.password_field :current_password, autocomplete: "off", class: "form-control", "data-role" => "clear", id: 'edit-email-current-password' %> -
-
- <%=t "general.cancel" %> - <%= f.button t("general.save"), class: "btn btn-success" %> -
-
-
- <% end %> - - <%= form_for(resource, - as: resource_name, - url: registration_path(resource_name, format: :json), - remote: true, - html: { method: :put, "data-for" => "password", class: 'settings-page-change-password', id: 'user-password-field' }) do |f| %> - <%= hidden_field_tag "user[change_password]", "true" %> -
-
- <%= f.label t("users.registrations.edit.password_label") %> - -
-
-
-
-

<%=t "users.registrations.edit.password_title" %>

-
- <%= f.label :current_password, t("users.registrations.edit.current_password_label") %> <%=t "users.registrations.edit.password_explanation" %> - <%= f.password_field :current_password, autocomplete: "off", class: "form-control", "data-role" => "clear", id: 'edit-password-current-password' %> -
- -
- <%= f.label :password, t("users.registrations.edit.new_password_label") %> - <%= f.password_field :password, autocomplete: "off", class: "form-control", "data-role" => "clear" %> -
- -
- <%= f.label :password_confirmation, t("users.registrations.edit.new_password_2_label") %> - <%= f.password_field :password_confirmation, autocomplete: "off", class: "form-control", "data-role" => "clear" %> -
- -
- <%=t "general.cancel" %> - <%= f.button t("general.save"), class: "btn btn-success" %> -
-
-
- <% end %> - -
-
-
-
-

<%=t "users.statistics.title" %>

-
    -
  • -

    <%= @user.statistics[:number_of_teams]%>

    - <%= t("users.statistics.team").pluralize(@user.statistics[:number_of_teams]) %> -
  • -
  • -

    <%= @user.statistics[:number_of_projects] %>

    - <%= t("users.statistics.project").pluralize(@user.statistics[:number_of_projects]) %> -
  • -
  • -

    <%= @user.statistics[:number_of_experiments] %>

    - <%= t("users.statistics.experiment").pluralize(@user.statistics[:number_of_experiments]) %> -
  • -
  • -

    <%= @user.statistics[:number_of_protocols] %>

    - <%= t("users.statistics.protocol").pluralize(@user.statistics[:number_of_protocols]) %> -
  • -
-
+ +<%= render partial: '2fa_modal' %> <%= render partial: 'users/shared/user_avatars_modal' %> <%= javascript_pack_tag 'custom/croppie' %> diff --git a/app/views/users/registrations/edit_partials/_2fa.html.erb b/app/views/users/registrations/edit_partials/_2fa.html.erb new file mode 100644 index 000000000..b9d9480e5 --- /dev/null +++ b/app/views/users/registrations/edit_partials/_2fa.html.erb @@ -0,0 +1,19 @@ +
+
+ <%= t("users.registrations.edit.2fa_title") %> + <% if current_user.two_factor_auth_enabled? %> + + + <%= t("users.registrations.edit.2fa_enabled") %> + + <% end %> +
+
+ <%= t("users.registrations.edit.2fa_description") %> +
+ <% if current_user.two_factor_auth_enabled? %> + + <% else %> + + <% end %> +
diff --git a/app/views/users/registrations/edit_partials/_avatar.html.erb b/app/views/users/registrations/edit_partials/_avatar.html.erb new file mode 100644 index 000000000..3f521adf3 --- /dev/null +++ b/app/views/users/registrations/edit_partials/_avatar.html.erb @@ -0,0 +1,15 @@ +
+ +
diff --git a/app/views/users/registrations/edit_partials/_email.html.erb b/app/views/users/registrations/edit_partials/_email.html.erb new file mode 100644 index 000000000..50be8c942 --- /dev/null +++ b/app/views/users/registrations/edit_partials/_email.html.erb @@ -0,0 +1,38 @@ +<%= form_for(resource, + as: resource_name, + url: registration_path(resource_name, format: :json), + remote: true, + html: { method: :put, "data-for" => "email", class: 'settings-page-email', id: 'user-email-field' }) do |f| %> +
+
+ <%= f.label t("users.registrations.edit.email_label") %> +
+ <%= @user.email %> + <%=t "general.edit" %> +
+ <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %> + + <% end %> +
+
+
+
+

<%=t "users.registrations.edit.email_title" %>

+
+ <%= f.label :email, t("users.registrations.edit.new_email_label") %> + <%= f.email_field :email, class: "form-control sci-input-field", "data-role" => "edit" %> +
+
+ <%= f.label :current_password, t("users.registrations.edit.current_password_label") %> <%=t "users.registrations.edit.password_explanation" %> + <%= f.password_field :current_password, autocomplete: "off", class: "form-control sci-input-field", "data-role" => "clear", id: 'edit-email-current-password' %> +
+
+ <%=t "general.cancel" %> + <%= f.submit t("general.save"), class: "btn btn-success" %> +
+
+
+<% end %> diff --git a/app/views/users/registrations/edit_partials/_full_name.html.erb b/app/views/users/registrations/edit_partials/_full_name.html.erb new file mode 100644 index 000000000..e69f58e54 --- /dev/null +++ b/app/views/users/registrations/edit_partials/_full_name.html.erb @@ -0,0 +1,12 @@ + diff --git a/app/views/users/registrations/edit_partials/_initials.html.erb b/app/views/users/registrations/edit_partials/_initials.html.erb new file mode 100644 index 000000000..d938d8d00 --- /dev/null +++ b/app/views/users/registrations/edit_partials/_initials.html.erb @@ -0,0 +1,12 @@ + diff --git a/app/views/users/registrations/edit_partials/_password.html.erb b/app/views/users/registrations/edit_partials/_password.html.erb new file mode 100644 index 000000000..512ad3a34 --- /dev/null +++ b/app/views/users/registrations/edit_partials/_password.html.erb @@ -0,0 +1,40 @@ +<%= form_for(resource, + as: resource_name, + url: registration_path(resource_name, format: :json), + remote: true, + html: { method: :put, "data-for" => "password", class: 'settings-page-change-password', id: 'user-password-field' }) do |f| %> + <%= hidden_field_tag "user[change_password]", "true" %> +
+
+ <%= f.label t("users.registrations.edit.password_label") %> +
+ •••••••••••• + <%=t "general.edit" %> +
+
+
+
+
+

<%=t "users.registrations.edit.password_title" %>

+
+ <%= f.label :current_password, t("users.registrations.edit.current_password_label") %> <%=t "users.registrations.edit.password_explanation" %> + <%= f.password_field :current_password, autocomplete: "off", class: "form-control sci-input-field", "data-role" => "clear", id: 'edit-password-current-password' %> +
+ +
+ <%= f.label :password, t("users.registrations.edit.new_password_label") %> + <%= f.password_field :password, autocomplete: "off", class: "form-control sci-input-field", "data-role" => "clear" %> +
+ +
+ <%= f.label :password_confirmation, t("users.registrations.edit.new_password_2_label") %> + <%= f.password_field :password_confirmation, autocomplete: "off", class: "form-control sci-input-field", "data-role" => "clear" %> +
+ +
+ <%=t "general.cancel" %> + <%= f.submit t("general.save"), class: "btn btn-success" %> +
+
+
+<% end %> diff --git a/app/views/users/sessions/two_factor_auth.html.erb b/app/views/users/sessions/two_factor_auth.html.erb new file mode 100644 index 000000000..c2db9afa5 --- /dev/null +++ b/app/views/users/sessions/two_factor_auth.html.erb @@ -0,0 +1,22 @@ +<% provide(:head_title, t("devise.sessions.new.head_title")) %> +<% content_for(:body_class, 'sign-in-layout') %> + diff --git a/app/views/users/sessions/two_factor_recovery.html.erb b/app/views/users/sessions/two_factor_recovery.html.erb new file mode 100644 index 000000000..3b7f6eaa4 --- /dev/null +++ b/app/views/users/sessions/two_factor_recovery.html.erb @@ -0,0 +1,20 @@ +<% provide(:head_title, '2FA Bypass') %> +<% content_for(:body_class, 'sign-in-layout') %> + diff --git a/config/initializers/constants.rb b/config/initializers/constants.rb index f2fa01f64..fd31a3dcf 100644 --- a/config/initializers/constants.rb +++ b/config/initializers/constants.rb @@ -205,6 +205,23 @@ class Constants ACADEMY_BL_LINK = 'https://scinote.net/academy/?utm_source=SciNote%20software%20BL&utm_medium=SciNote%20software%20BL'.freeze + TWO_FACTOR_URL = { + google: { + android: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', + ios: 'https://apps.apple.com/us/app/google-authenticator/id388497605' + }, + microsoft: { + android: 'https://play.google.com/store/apps/details?id=com.azure.authenticator', + ios: 'https://apps.apple.com/us/app/microsoft-authenticator/id983156458' + }, + two_fa: { + android: 'https://play.google.com/store/apps/details?id=com.twofasapp', + ios: 'https://apps.apple.com/us/app/2fa-authenticator-2fas/id1217793794' + }, + } + TWO_FACTOR_RECOVERY_CODE_COUNT = 6 + TWO_FACTOR_RECOVERY_CODE_LENGTH = 12 + #============================================================================= # Protocol importers #============================================================================= diff --git a/config/locales/en.yml b/config/locales/en.yml index 817e26dab..61dd502f2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -33,6 +33,20 @@ en: password_placeholder: "Enter password" remember_me: "Remember me" submit: "Log in" + 2fa: + title: "Two-factor authentication" + description: "Enter the one-time code found in your authenticator app to log in to SciNote." + field: "Authenticator code" + error_message: "One time code is not correct." + no_user_error: "Cannot find user!" + enter: "Enter" + bypass_code_link: "I have a bypass code" + 2fa_recovery: + title: "2FA Bypass" + description: "Enter one of the bypass codes provided when you creted 2FA authentication. The code will no longer be valid after use." + bypass_code: "Bypass code" + enter: "Enter" + not_correct_code: "Not correct recovery code" create: team_name: "%{user}'s projects" auth_token_create: @@ -1636,7 +1650,7 @@ en: head_title: "My profile" title: "My profile" avatar_label: "Profile photo" - avatar_btn: "Edit photo" + avatar_btn: "Edit" avatar_modal: title: 'Change your profile photo' upload_button: 'Upload a photo' @@ -1655,6 +1669,43 @@ en: password_title: "Change password" new_password_label: "New password" new_password_2_label: "New password confirmation" + 2fa_title: "Two-factor authentication" + 2fa_description: "Two-factor authentication (2FA) is a way of verifying a user’s identity by using a combination of two different verification methods. It adds an extra layer of security to your account and protects from potential remote attacs or other threats." + 2fa_enabled: "Enabled" + 2fa_button: "Enable authentication" + 2fa_button_disable: "Disable authentication" + 2fa_modal: + step_1: + title: "1. Install an Authenticator App on your mobile device" + description: "Use your phone to download an authenticator app. It is needed to enable the 2FA in SciNote. Bellow you can see the most commonly used ones." + google_auth: "Google authenticator" + microsoft_auth: "Microsoft authenticator" + 2fa_auth: "2FA authenticator" + android: "Android" + ios: "iOS" + start: "Start" + step_2: + title: "2. Scan the QR code with your app" + description: "Open your authenticator app and use it to scan this code to add 2FA." + next: "Next" + step_3: + title: "3. Enter the code given by your authenticator app" + description: "Enter the generated code into the fields bellow to finalize the setup of the two-factor authorisation." + enter_code: "Enter authenticator code" + verify: "Verify" + step_4: + title: "4. Save your bypass codes" + verified: "2FA Verified" + description: "Your 2FA is now verified. Save these one-time codes bellow to access your account in case you lose your device. This way you will be able to reset the authorization." + download_codes: "Download codes" + disable: + title: "Disable two-factor authentication" + description: "Enter your password bellow to confirm disableing 2FA. This will remove authentication, from your account and make you more vulnerable to potential attacks." + password_label: "Enter password" + disable_2fa: "Disable 2FA" + 2fa_errors: + wrong_submit_code: "Not correct code" + wrong_password: "Not correct password" new: head_title: "Sign up" team_name_label: "Team name" diff --git a/config/routes.rb b/config/routes.rb index 063c5da22..c1ca98d4c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -648,8 +648,14 @@ Rails.application.routes.draw do get 'avatar/:id/:style' => 'users/registrations#avatar', as: 'avatar' get 'users/auth_token_sign_in' => 'users/sessions#auth_token_create' get 'users/sign_up_provider' => 'users/registrations#new_with_provider' - post 'users/complete_sign_up_provider' => - 'users/registrations#create_with_provider' + get 'users/two_factor_recovery' => 'users/sessions#two_factor_recovery' + post 'users/authenticate_with_two_factor' => 'users/sessions#authenticate_with_two_factor' + post 'users/authenticate_with_recovery_code' => 'users/sessions#authenticate_with_recovery_code' + post 'users/complete_sign_up_provider' => 'users/registrations#create_with_provider' + + post 'users/2fa_enable' => 'users/registrations#two_factor_enable' + post 'users/2fa_disable' => 'users/registrations#two_factor_disable' + get 'users/2fa_qr_code' => 'users/registrations#two_factor_qr_code' end namespace :api, defaults: { format: 'json' } do diff --git a/db/migrate/20200622140843_add2fa_to_users.rb b/db/migrate/20200622140843_add2fa_to_users.rb new file mode 100644 index 000000000..88494cc52 --- /dev/null +++ b/db/migrate/20200622140843_add2fa_to_users.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Add2faToUsers < ActiveRecord::Migration[6.0] + def change + change_table :users, bulk: true do |t| + t.boolean :two_factor_auth_enabled, default: false, null: false + t.string :otp_secret + end + end +end diff --git a/db/migrate/20200709142830_add_otp_recovery_codes_to_users.rb b/db/migrate/20200709142830_add_otp_recovery_codes_to_users.rb new file mode 100644 index 000000000..62a58aa60 --- /dev/null +++ b/db/migrate/20200709142830_add_otp_recovery_codes_to_users.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOtpRecoveryCodesToUsers < ActiveRecord::Migration[6.0] + def change + change_table :users, bulk: true do |t| + t.jsonb :otp_recovery_codes + end + end +end diff --git a/db/structure.sql b/db/structure.sql index be09c7d37..9a5c8218b 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2658,9 +2658,15 @@ CREATE TABLE public.users ( authentication_token character varying(30), settings jsonb DEFAULT '{}'::jsonb NOT NULL, variables jsonb DEFAULT '{}'::jsonb NOT NULL, +<<<<<<< HEAD failed_attempts integer DEFAULT 0 NOT NULL, locked_at timestamp without time zone, unlock_token character varying +======= + two_factor_auth_enabled boolean DEFAULT false NOT NULL, + otp_secret character varying, + otp_recovery_codes jsonb +>>>>>>> features/2fa ); @@ -7277,5 +7283,8 @@ INSERT INTO "schema_migrations" (version) VALUES ('20200331183640'), ('20200603125407'), ('20200604210943'), +('20200622140843'), ('20200622155632'), +('20200709142830'), ('20200714082503'); + diff --git a/features/settings_page/profile.feature b/features/settings_page/profile.feature index cdba4735e..91428e553 100644 --- a/features/settings_page/profile.feature +++ b/features/settings_page/profile.feature @@ -58,7 +58,7 @@ Scenario: Successfully changes user email And I change "nonadmin@myorg.com" with "user@myorg.com" email And I fill in "mypassword1234" in "#edit-email-current-password" field of ".settings-page-email" form Then I click "Save" button - And I should see "user@myorg.com" in ".settings-page-email" input field + And I should see "user@myorg.com" @javascript Scenario: Unsuccessful Password Change, password is too short diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb new file mode 100644 index 000000000..f7fa1adf8 --- /dev/null +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::SessionsController, type: :controller do + describe 'POST #create' do + before do + @request.env['devise.mapping'] = Devise.mappings[:user] + end + + let(:user) { create :user } + let(:password) { 'asdf1243' } + let(:params) do + { user: { + email: user.email, + password: password + } } + end + + let(:action) do + post :create, params: params + end + + context 'when have invalid email or password' do + let(:password) { '123' } + + it 'returns error message' do + action + + expect(flash[:alert]).to eq('Invalid Email or password.') + end + + it 'does not set current user' do + expect { action }.not_to(change { subject.current_user }) + end + end + + context 'when have valid email and password' do + context 'when user has 2FA disabled' do + it 'returns successfully log in' do + action + + expect(flash[:notice]).to eq('Logged in successfully.') + end + + it 'sets current user' do + expect { action }.to(change { subject.current_user }.from(nil).to(User)) + end + end + + context 'when user has 2FA enabled' do + it 'renders 2FA page' do + user.two_factor_auth_enabled = true + user.save! + + expect(action).to render_template('users/sessions/two_factor_auth') + end + end + end + end + + describe 'POST #authenticate_with_two_factor' do + before do + @request.env['devise.mapping'] = Devise.mappings[:user] + end + + let(:user) { create :user } + let(:params) { { otp: '123123' } } + let(:otp_user_id) { user.id } + let(:action) do + post :authenticate_with_two_factor, params: params, session: { otp_user_id: otp_user_id } + end + + context 'when have valid otp' do + it 'sets current user' do + allow_any_instance_of(User).to receive(:valid_otp?).and_return(true) + + expect { action }.to(change { subject.current_user }.from(nil).to(User)) + end + end + + context 'when have invalid valid otp' do + it 'returns error message' do + allow_any_instance_of(User).to receive(:valid_otp?).and_return(nil) + action + + expect(flash[:alert]).to eq(I18n.t('devise.sessions.2fa.error_message')) + end + + it 'does not set current user' do + allow_any_instance_of(User).to receive(:valid_otp?).and_return(nil) + + expect { action }.not_to(change { subject.current_user }) + end + end + + context 'when user is not found' do + let(:otp_user_id) { -1 } + + it 'returns error message' do + action + + expect(flash[:alert]).to eq('Cannot find user!') + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 8c6ff65ff..376050d13 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -331,4 +331,26 @@ describe User, type: :model do describe 'Associations' do it { is_expected.to have_many(:system_notifications) } end + + describe 'valid_otp?' do + let(:user) { create :user } + before do + user.assign_2fa_token! + allow_any_instance_of(ROTP::TOTP).to receive(:verify).and_return(nil) + end + + context 'when user has set otp_secret' do + it 'returns nil' do + expect(user.valid_otp?('someString')).to be_nil + end + end + + context 'when user does not have otp_secret' do + it 'raises an error' do + user.update_column(:otp_secret, nil) + + expect { user.valid_otp?('someString') }.to raise_error(StandardError, 'Missing otp_secret') + end + end + end end