mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2025-02-26 16:53:12 +08:00
Merge branch 'features/2fa' into develop
This commit is contained in:
commit
ddfe8231d8
36 changed files with 947 additions and 195 deletions
2
Gemfile
2
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
|
||||
|
|
|
@ -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
|
||||
|
|
BIN
app/assets/images/2fa/2fa_authenticator.png
Normal file
BIN
app/assets/images/2fa/2fa_authenticator.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
app/assets/images/2fa/google_authenticator.png
Normal file
BIN
app/assets/images/2fa/google_authenticator.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
BIN
app/assets/images/2fa/install_mobile.png
Normal file
BIN
app/assets/images/2fa/install_mobile.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.6 KiB |
BIN
app/assets/images/2fa/ms_authenticator.png
Normal file
BIN
app/assets/images/2fa/ms_authenticator.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
|
@ -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');
|
||||
});
|
||||
}());
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
1
app/javascript/packs/fontawesome.scss
vendored
1
app/javascript/packs/fontawesome.scss
vendored
|
@ -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";
|
||||
|
|
|
@ -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?
|
||||
|
|
38
app/views/users/registrations/_2fa_modal.html.erb
Normal file
38
app/views/users/registrations/_2fa_modal.html.erb
Normal 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">×</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>
|
||||
|
71
app/views/users/registrations/_2fa_modal_apps_tab.html.erb
Normal file
71
app/views/users/registrations/_2fa_modal_apps_tab.html.erb
Normal 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">×</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>
|
|
@ -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">×</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>
|
|
@ -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">×</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"> </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>
|
|
@ -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">×</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>
|
|
@ -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' %>
|
||||
|
|
19
app/views/users/registrations/edit_partials/_2fa.html.erb
Normal file
19
app/views/users/registrations/edit_partials/_2fa.html.erb
Normal 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>
|
15
app/views/users/registrations/edit_partials/_avatar.html.erb
Normal file
15
app/views/users/registrations/edit_partials/_avatar.html.erb
Normal 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>
|
38
app/views/users/registrations/edit_partials/_email.html.erb
Normal file
38
app/views/users/registrations/edit_partials/_email.html.erb
Normal 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 %>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 %>
|
22
app/views/users/sessions/two_factor_auth.html.erb
Normal file
22
app/views/users/sessions/two_factor_auth.html.erb
Normal 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>
|
20
app/views/users/sessions/two_factor_recovery.html.erb
Normal file
20
app/views/users/sessions/two_factor_recovery.html.erb
Normal 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>
|
|
@ -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
|
||||
#=============================================================================
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
10
db/migrate/20200622140843_add2fa_to_users.rb
Normal file
10
db/migrate/20200622140843_add2fa_to_users.rb
Normal 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
|
|
@ -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
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
107
spec/controllers/users/sessions_controller_spec.rb
Normal file
107
spec/controllers/users/sessions_controller_spec.rb
Normal 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
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue