Merge branch 'features/2fa' into develop

This commit is contained in:
aignatov-bio 2020-07-22 11:56:36 +02:00
commit ddfe8231d8
36 changed files with 947 additions and 195 deletions

View file

@ -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

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -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('<br>'));
$('#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');
});
}());

View file

@ -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;
}
}
}

View file

@ -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%;
}
}
}

View file

@ -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

View file

@ -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'

View file

@ -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";

View file

@ -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?

View file

@ -0,0 +1,38 @@
<div class="modal two-factor-modal" id="twoFactorAuthenticationModal" tabindex="-1" role="dialog" data-backdrop="static">
<div class="modal-dialog" role="document">
<div class="modal-content">
<% if current_user.two_factor_auth_enabled? %>
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span></button>
<h2 class="modal-title"><%= t("users.registrations.edit.2fa_modal.disable.title") %></h2>
</div>
<%= form_with(url: users_2fa_disable_path, method: "post", class: "2fa-disable-form") do %>
<div class="modal-body">
<p><%= t("users.registrations.edit.2fa_modal.disable.description") %></p>
<div class="sci-input-container password-field">
<%= label_tag :password, t("users.registrations.edit.2fa_modal.disable.password_label") %>
<%= password_field_tag :password, '', class: "sci-input-field" %>
</div>
</div>
<div class="modal-footer">
<%= button_tag t("users.registrations.edit.2fa_modal.disable.disable_2fa"), type: 'submit', class: "btn btn-danger" %>
</div>
<% end %>
<% else %>
<ul class="nav nav-tabs hidden">
<li role="presentation"><a href="#2fa-step-1" data-toggle="tab" data-no-turbolink="true"></a></li>
<li role="presentation"><a href="#2fa-step-2" data-toggle="tab" data-no-turbolink="true"></a></li>
<li role="presentation"><a href="#2fa-step-3" data-toggle="tab" data-no-turbolink="true"></a></li>
<li role="presentation"><a href="#2fa-step-4" data-toggle="tab" data-no-turbolink="true"></a></li>
</ul>
<div class="tab-content">
<%= render partial: '2fa_modal_apps_tab' %>
<%= render partial: '2fa_modal_qr_code_tab' %>
<%= render partial: '2fa_modal_verify_code_tab' %>
<%= render partial: '2fa_modal_recovery_codes_tab' %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,71 @@
<div role="tabpanel" class="tab-pane active" id="2fa-step-1">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span></button>
<h2 class="modal-title"><%= t("users.registrations.edit.2fa_modal.step_1.title") %></h2>
</div>
<div class="modal-body">
<p><%= t("users.registrations.edit.2fa_modal.step_1.description") %></p>
<div class="two-factor-apps">
<div class="apps-list">
<div class="app">
<div class="app-logo">
<%= image_tag('2fa/google_authenticator.png', class: 'logo-image') %>
</div>
<div class="app-information">
<p class="app-name"><%= t("users.registrations.edit.2fa_modal.step_1.google_auth") %></p>
<div class="app-store">
<a href="<%= Constants::TWO_FACTOR_URL[:google][:android] %>" class="store" target="_blank">
<i class="fab fa-android"></i>
<%= t("users.registrations.edit.2fa_modal.step_1.android") %>
</a>
<a href="<%= Constants::TWO_FACTOR_URL[:google][:ios] %>" class="store" target="_blank">
<i class="fab fa-apple"></i>
<%= t("users.registrations.edit.2fa_modal.step_1.ios") %>
</a>
</div>
</div>
</div>
<div class="app">
<div class="app-logo">
<%= image_tag('2fa/ms_authenticator.png', class: 'logo-image') %>
</div>
<div class="app-information">
<p class="app-name"><%= t("users.registrations.edit.2fa_modal.step_1.microsoft_auth") %></p>
<div class="app-store">
<a href="<%= Constants::TWO_FACTOR_URL[:microsoft][:android] %>" class="store" target="_blank">
<i class="fab fa-android"></i>
<%= t("users.registrations.edit.2fa_modal.step_1.android") %>
</a>
<a href="<%= Constants::TWO_FACTOR_URL[:microsoft][:ios] %>" class="store" target="_blank">
<i class="fab fa-apple"></i>
<%= t("users.registrations.edit.2fa_modal.step_1.ios") %>
</a>
</div>
</div>
</div>
<div class="app">
<div class="app-logo">
<%= image_tag('2fa/2fa_authenticator.png', class: 'logo-image') %>
</div>
<div class="app-information">
<p class="app-name"><%= t("users.registrations.edit.2fa_modal.step_1.2fa_auth") %></p>
<div class="app-store">
<a href="<%= Constants::TWO_FACTOR_URL[:two_fa][:android] %>" class="store" target="_blank">
<i class="fab fa-android"></i>
<%= t("users.registrations.edit.2fa_modal.step_1.android") %>
</a>
<a href="<%= Constants::TWO_FACTOR_URL[:two_fa][:ios] %>" class="store" target="_blank">
<i class="fab fa-apple"></i>
<%= t("users.registrations.edit.2fa_modal.step_1.ios") %>
</a>
</div>
</div>
</div>
</div>
<%= image_tag('2fa/install_mobile.png', class: 'install-mobile') %>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary btn-next-step" data-step="#2fa-step-2"><%= t("users.registrations.edit.2fa_modal.step_1.start") %></button>
</div>
</div>

View file

@ -0,0 +1,13 @@
<div role="tabpanel" class="tab-pane" id="2fa-step-2">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span></button>
<h2 class="modal-title"><%= t("users.registrations.edit.2fa_modal.step_2.title") %></h2>
</div>
<div class="modal-body">
<p><%= t("users.registrations.edit.2fa_modal.step_2.description") %></p>
<div class="qr-code"></div>
</div>
<div class="modal-footer">
<button class="btn btn-primary btn-next-step" data-step="#2fa-step-3"><%= t("users.registrations.edit.2fa_modal.step_2.next") %></button>
</div>
</div>

View file

@ -0,0 +1,16 @@
<div role="tabpanel" class="tab-pane" id="2fa-step-4">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span></button>
<h2 class="modal-title"><%= t("users.registrations.edit.2fa_modal.step_4.title") %></h2>
</div>
<div class="modal-body">
<h1 class="verified-label"><i class="fas fa-check-circle">&nbsp;</i><%= t("users.registrations.edit.2fa_modal.step_4.verified") %></h1>
<p><%= t("users.registrations.edit.2fa_modal.step_4.description") %></p>
<div class="recovery-codes"></div>
</div>
<div class="modal-footer">
<a href download="scinote_recovery_codes.txt" class="btn btn-secondary download-recovery-codes">
<%= t("users.registrations.edit.2fa_modal.step_4.download_codes") %>
</a>
</div>
</div>

View file

@ -0,0 +1,18 @@
<div role="tabpanel" class="tab-pane" id="2fa-step-3">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span></button>
<h2 class="modal-title"><%= t("users.registrations.edit.2fa_modal.step_3.title") %></h2>
</div>
<%= form_with(url: users_2fa_enable_path, method: "post", class: "2fa-enable-form") do %>
<div class="modal-body">
<p><%= t("users.registrations.edit.2fa_modal.step_3.description") %></p>
<p class="sci-input-container submit-code-field">
<%= label_tag :submit_code, t("users.registrations.edit.2fa_modal.step_3.enter_code") %>
<%= text_field_tag :submit_code, '', class: "sci-input-field" %>
</p>
</div>
<div class="modal-footer">
<%= button_tag t("users.registrations.edit.2fa_modal.step_3.verify"), class: 'btn btn-primary', type: :submit %>
</div>
<% end %>
</div>

View file

@ -2,180 +2,71 @@
<% provide(:container_class, "no-second-nav-container") %>
<%= render partial: "users/settings/sidebar" %>
<div class="tab-content">
<div class="tab-pane content-pane active" role="tabpanel">
<div class="row">
<div class="col-xs-12 col-sm-12">
<div class="row">
<div class="col-md-4">
<h1 style="margin-top: 10px;"><%=t "users.registrations.edit.title" %></h1>
<% if not resource.errors.empty? %>
<div class="alert alert-danger">
<%= devise_error_messages! %>
</div>
<% end %>
<div data-part="view">
<div class="form-group user-settings-edit-avatar">
<%= t("users.registrations.edit.avatar_label") %><br>
<% @user_avatar_url ||= avatar_path(current_user, :thumb) %>
<div class="avatar-container">
<div class="avatar-image">
<%= image_tag @user_avatar_url %>
</div>
<div class="avatar-edit"></div>
<div class="avatar-edit-text">
<span class="fas fa-pencil-alt"></span>
<span><%=t "users.registrations.edit.avatar_btn" %></span>
</div>
</div>
</div>
</div>
<span class="settings-page-full-name user-settings-block">
<label><%= t("users.registrations.edit.name_label") %></label>
<%= render partial: "shared/inline_editing",
locals: {
initial_value: @user.full_name,
config: {
params_group: 'user',
field_to_udpate: 'full_name',
path_to_update: registration_path(resource_name, format: :json)
}
} %>
</span>
<span class="settings-page-initials user-settings-block">
<label><%= t("users.registrations.edit.initials_label") %></label>
<%= render partial: "shared/inline_editing",
locals: {
initial_value: @user.initials,
config: {
params_group: 'user',
field_to_udpate: 'initials',
path_to_update: registration_path(resource_name, format: :json)
}
} %>
</span>
<%= 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| %>
<div data-part="view">
<div class="form-group">
<%= f.label t("users.registrations.edit.email_label") %>
<div class="input-group">
<input data-role="src" class="form-control" disabled="disabled" type="text" value="<%= @user.email %>" name="fake_user[email]" id="fake_user_email">
<span class="input-group-btn">
<a href="#" class="btn btn-default" data-action="edit"><%=t "general.edit" %></a>
</span>
</div>
<% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
<div class="alert alert-info" style="margin-top: 15px;" role="alert">
<span class="fas fa-info-circle" aria-hidden="true"></span>
<%=t "users.registrations.edit.waiting_for_confirm", email: resource.unconfirmed_email %>
</div>
<% end %>
</div>
</div>
<div data-part="edit" style="display: none;">
<div class="well">
<h4><%=t "users.registrations.edit.email_title" %></h4>
<div class="form-group">
<%= f.label :email, t("users.registrations.edit.new_email_label") %>
<%= f.email_field :email, class: "form-control", "data-role" => "edit" %>
</div>
<div class="form-group">
<%= f.label :current_password, t("users.registrations.edit.current_password_label") %> <i><%=t "users.registrations.edit.password_explanation" %></i>
<%= f.password_field :current_password, autocomplete: "off", class: "form-control", "data-role" => "clear", id: 'edit-email-current-password' %>
</div>
<div class="align-right">
<a href="#" class="btn btn-default" data-action="cancel"><%=t "general.cancel" %></a>
<%= f.button t("general.save"), class: "btn btn-success" %>
</div>
</div>
</div>
<% 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" %>
<div data-part="view">
<div class="form-group">
<%= f.label t("users.registrations.edit.password_label") %>
<div class="input-group">
<input class="form-control" disabled="disabled" autocomplete="off" type="password" value="aaaaaaaaaa" name="fake_user[current_password]" id="fake_user_current_password">
<span class="input-group-btn">
<a href="#" class="btn btn-default" data-action="edit"><%=t "general.edit" %></a>
</span>
</div>
</div>
</div>
<div data-part="edit" style="display: none;">
<div class="well">
<h4><%=t "users.registrations.edit.password_title" %></h4>
<div class="form-group">
<%= f.label :current_password, t("users.registrations.edit.current_password_label") %> <i><%=t "users.registrations.edit.password_explanation" %></i>
<%= f.password_field :current_password, autocomplete: "off", class: "form-control", "data-role" => "clear", id: 'edit-password-current-password' %>
</div>
<div class="form-group">
<%= f.label :password, t("users.registrations.edit.new_password_label") %>
<%= f.password_field :password, autocomplete: "off", class: "form-control", "data-role" => "clear" %>
</div>
<div class="form-group">
<%= 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" %>
</div>
<div class="align-right">
<a href="#" class="btn btn-default" data-action="cancel"><%=t "general.cancel" %></a>
<%= f.button t("general.save"), class: "btn btn-success" %>
</div>
</div>
</div>
<% end %>
</div>
<div class="col-md-7 col-md-offset-1">
<div class="row user-statistics">
<div class="col-md-12">
<h2 style="margin-top: 10px;"><%=t "users.statistics.title" %></h2>
<ul class="list-inline">
<li class="label label-primary">
<h2><%= @user.statistics[:number_of_teams]%></h2>
<%= t("users.statistics.team").pluralize(@user.statistics[:number_of_teams]) %>
</li>
<li class="label label-primary">
<h2><%= @user.statistics[:number_of_projects] %></h2>
<%= t("users.statistics.project").pluralize(@user.statistics[:number_of_projects]) %>
</li>
<li class="label label-primary">
<h2><%= @user.statistics[:number_of_experiments] %></h2>
<%= t("users.statistics.experiment").pluralize(@user.statistics[:number_of_experiments]) %>
</li>
<li class="label label-primary">
<h2><%= @user.statistics[:number_of_protocols] %></h2>
<%= t("users.statistics.protocol").pluralize(@user.statistics[:number_of_protocols]) %>
</li>
</ul>
</div>
<div class="content-pane active flexible user-settings">
<div class="content-header">
<h1><%=t "users.registrations.edit.title" %></h1>
</div>
<div class="row settings-row">
<div class="col-md-12">
<% if not resource.errors.empty? %>
<div class="alert alert-danger">
<%= devise_error_messages! %>
</div>
<% end %>
<div class="row">
<div class="col-md-2 col-lg-1">
<%= render partial: 'users/registrations/edit_partials/avatar' %>
</div>
<div class="col-md-10 col-lg-11">
<div class="row">
<div class="col-md-6">
<%= render partial: 'users/registrations/edit_partials/full_name' %>
<%= render partial: 'users/registrations/edit_partials/initials' %>
</div>
<div class="col-md-6">
<%= render partial: 'users/registrations/edit_partials/email' %>
<%= render partial: 'users/registrations/edit_partials/password' %>
</div>
<span style="display: none;" data-hook="profile-statistics"></span>
</div>
</div>
</div>
<%= render partial: 'users/registrations/edit_partials/2fa' %>
</div>
</div>
<!-- User statistics -->
<div class="row">
<div class="col-md-12">
<div class="row user-statistics">
<div class="col-md-12">
<h2><%=t "users.statistics.title" %></h2>
<ul class="list-inline">
<li class="label label-primary">
<h2><%= @user.statistics[:number_of_teams]%></h2>
<%= t("users.statistics.team").pluralize(@user.statistics[:number_of_teams]) %>
</li>
<li class="label label-primary">
<h2><%= @user.statistics[:number_of_projects] %></h2>
<%= t("users.statistics.project").pluralize(@user.statistics[:number_of_projects]) %>
</li>
<li class="label label-primary">
<h2><%= @user.statistics[:number_of_experiments] %></h2>
<%= t("users.statistics.experiment").pluralize(@user.statistics[:number_of_experiments]) %>
</li>
<li class="label label-primary">
<h2><%= @user.statistics[:number_of_protocols] %></h2>
<%= t("users.statistics.protocol").pluralize(@user.statistics[:number_of_protocols]) %>
</li>
</ul>
</div>
</div>
<span style="display: none;" data-hook="profile-statistics"></span>
</div>
</div>
</div>
<%= render partial: '2fa_modal' %>
<%= render partial: 'users/shared/user_avatars_modal' %>
<%= javascript_pack_tag 'custom/croppie' %>

View file

@ -0,0 +1,19 @@
<div class="two-factor-container">
<div class="title">
<b><%= t("users.registrations.edit.2fa_title") %></b>
<% if current_user.two_factor_auth_enabled? %>
<span class="enabled">
<i class="fas fa-shield-alt"></i>
<%= t("users.registrations.edit.2fa_enabled") %>
</span>
<% end %>
</div>
<div class="description">
<%= t("users.registrations.edit.2fa_description") %>
</div>
<% if current_user.two_factor_auth_enabled? %>
<button class="btn btn-secondary" id="twoFactorAuthenticationDisable"><%= t("users.registrations.edit.2fa_button_disable") %></button>
<% else %>
<button class="btn btn-primary" id="twoFactorAuthenticationEnable" data-qr-code-url="<%= users_2fa_qr_code_path %>"><%= t("users.registrations.edit.2fa_button") %></button>
<% end %>
</div>

View file

@ -0,0 +1,15 @@
<div data-part="view">
<div class="form-group user-settings-edit-avatar">
<% @user_avatar_url ||= avatar_path(current_user, :thumb) %>
<div class="avatar-container">
<div class="avatar-image">
<%= image_tag @user_avatar_url %>
</div>
<div class="avatar-edit"></div>
<div class="avatar-edit-text">
<span class="fas fa-pencil-alt"></span>
<span><%=t "users.registrations.edit.avatar_btn" %></span>
</div>
</div>
</div>
</div>

View file

@ -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| %>
<div data-part="view">
<div class="form-group">
<%= f.label t("users.registrations.edit.email_label") %>
<div class="user-attribute">
<%= @user.email %>
<a href="#" class="btn btn-default" data-action="edit"><%=t "general.edit" %></a>
</div>
<% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
<div class="alert alert-info" style="margin-top: 15px;" role="alert">
<span class="fas fa-info-circle" aria-hidden="true"></span>
<%=t "users.registrations.edit.waiting_for_confirm", email: resource.unconfirmed_email %>
</div>
<% end %>
</div>
</div>
<div data-part="edit" style="display: none;">
<div class="well">
<h4><%=t "users.registrations.edit.email_title" %></h4>
<div class="form-group sci-input-container">
<%= f.label :email, t("users.registrations.edit.new_email_label") %>
<%= f.email_field :email, class: "form-control sci-input-field", "data-role" => "edit" %>
</div>
<div class="form-group sci-input-container">
<%= f.label :current_password, t("users.registrations.edit.current_password_label") %> <i><%=t "users.registrations.edit.password_explanation" %></i>
<%= f.password_field :current_password, autocomplete: "off", class: "form-control sci-input-field", "data-role" => "clear", id: 'edit-email-current-password' %>
</div>
<div class="align-right">
<a href="#" class="btn btn-light" data-action="cancel"><%=t "general.cancel" %></a>
<%= f.submit t("general.save"), class: "btn btn-success" %>
</div>
</div>
</div>
<% end %>

View file

@ -0,0 +1,12 @@
<span class="settings-page-full-name user-settings-block">
<label><%= t("users.registrations.edit.name_label") %></label>
<%= render partial: "shared/inline_editing",
locals: {
initial_value: @user.full_name,
config: {
params_group: 'user',
field_to_udpate: 'full_name',
path_to_update: registration_path(resource_name, format: :json)
}
} %>
</span>

View file

@ -0,0 +1,12 @@
<span class="settings-page-initials user-settings-block">
<label><%= t("users.registrations.edit.initials_label") %></label>
<%= render partial: "shared/inline_editing",
locals: {
initial_value: @user.initials,
config: {
params_group: 'user',
field_to_udpate: 'initials',
path_to_update: registration_path(resource_name, format: :json)
}
} %>
</span>

View file

@ -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" %>
<div data-part="view">
<div class="form-group">
<%= f.label t("users.registrations.edit.password_label") %>
<div class="user-attribute">
••••••••••••
<a href="#" class="btn btn-default" data-action="edit"><%=t "general.edit" %></a>
</div>
</div>
</div>
<div data-part="edit" style="display: none;">
<div class="well">
<h4><%=t "users.registrations.edit.password_title" %></h4>
<div class="form-group sci-input-container">
<%= f.label :current_password, t("users.registrations.edit.current_password_label") %> <i><%=t "users.registrations.edit.password_explanation" %></i>
<%= f.password_field :current_password, autocomplete: "off", class: "form-control sci-input-field", "data-role" => "clear", id: 'edit-password-current-password' %>
</div>
<div class="form-group sci-input-container">
<%= 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" %>
</div>
<div class="form-group sci-input-container">
<%= 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" %>
</div>
<div class="align-right">
<a href="#" class="btn btn-light" data-action="cancel"><%=t "general.cancel" %></a>
<%= f.submit t("general.save"), class: "btn btn-success" %>
</div>
</div>
</div>
<% end %>

View file

@ -0,0 +1,22 @@
<% provide(:head_title, t("devise.sessions.new.head_title")) %>
<% content_for(:body_class, 'sign-in-layout') %>
<div class="sign-in-container">
<div class="sign-in-form-wrapper">
<div class="center-block center-block-narrow">
<h1 class="log-in-title"><%= t "devise.sessions.2fa.title" %></h1>
<%= form_with url: users_authenticate_with_two_factor_url, local: true do %>
<p><%= t "devise.sessions.2fa.description" %></p>
<p class="input-group sci-input-container">
<%= label :otp, t("devise.sessions.2fa.field") %>
<%= text_field_tag(:otp, '', { class: "form-control sci-input-field" })%>
</p>
<p class="actions">
<%= button_tag t("devise.sessions.2fa.enter"), type: :submit, class: "btn btn-primary" %>
</p>
<%= link_to t("devise.sessions.2fa.bypass_code_link"), users_two_factor_recovery_path %>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,20 @@
<% provide(:head_title, '2FA Bypass') %>
<% content_for(:body_class, 'sign-in-layout') %>
<div class="sign-in-container">
<div class="sign-in-form-wrapper">
<div class="center-block center-block-narrow">
<h1 class="log-in-title"><%= t "devise.sessions.2fa_recovery.title" %></h1>
<%= form_with url: users_authenticate_with_recovery_code_path, local: true do %>
<p><%= t "devise.sessions.2fa_recovery.description" %></p>
<p class="input-group sci-input-container">
<%= label :recovery_code, t("devise.sessions.2fa_recovery.bypass_code") %>
<%= text_field_tag(:recovery_code, '', { class: "form-control sci-input-field" })%>
</p>
<div class="actions">
<%= button_tag t("devise.sessions.2fa_recovery.enter"), type: :submit, class: "btn btn-primary" %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -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
#=============================================================================

View file

@ -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 users 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"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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');

View file

@ -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

View file

@ -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

View file

@ -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