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