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 @@
+
<%= t("users.registrations.edit.2fa_modal.step_1.description") %>
+<%= t("users.registrations.edit.2fa_modal.step_1.google_auth") %>
+ +<%= t("users.registrations.edit.2fa_modal.step_1.microsoft_auth") %>
+ +<%= t("users.registrations.edit.2fa_modal.step_1.2fa_auth") %>
+ +<%= t("users.registrations.edit.2fa_modal.step_2.description") %>
+ +<%= t("users.registrations.edit.2fa_modal.step_4.description") %>
+ +<%= t("users.registrations.edit.2fa_modal.step_3.description") %>
++ <%= label_tag :submit_code, t("users.registrations.edit.2fa_modal.step_3.enter_code") %> + <%= text_field_tag :submit_code, '', class: "sci-input-field" %> +
+<%= t "devise.sessions.2fa.description" %>
++ <%= label :otp, t("devise.sessions.2fa.field") %> + <%= text_field_tag(:otp, '', { class: "form-control sci-input-field" })%> +
+ ++ <%= button_tag t("devise.sessions.2fa.enter"), type: :submit, class: "btn btn-primary" %> +
+ + <%= link_to t("devise.sessions.2fa.bypass_code_link"), users_two_factor_recovery_path %> + <% end %> +<%= t "devise.sessions.2fa_recovery.description" %>
++ <%= label :recovery_code, t("devise.sessions.2fa_recovery.bypass_code") %> + <%= text_field_tag(:recovery_code, '', { class: "form-control sci-input-field" })%> +
+ +