Implement Two Factor Authentication

This commit is contained in:
Dominik Kwiatek 2020-10-18 10:39:46 +02:00
parent d9cc9daf4b
commit 141ff59f63
14 changed files with 183 additions and 40 deletions

View File

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

View File

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

View File

@ -0,0 +1,42 @@
class User::SessionsController < Devise::SessionsController
def create
if session.has_key?(:user_sign_in_uid)
self.resource = User.find(session[:user_sign_in_uid])
session.delete(:user_sign_in_uid)
else
self.resource = warden.authenticate!(auth_options)
end
if resource.active_for_authentication? && !resource.otp_secret_key.nil?
if params[:user][:otp_attempt].blank?
session[:user_sign_in_uid] = resource.id
sign_out(resource)
redirect_to user_two_factor_entry_url
else
if resource.authenticate_otp(params[:user][:otp_attempt])
continue_sign_in(resource, resource_name)
else
sign_out(resource)
flash[:error] = t('devise.failure.invalid')
redirect_to root_url
end
end
else
continue_sign_in(resource, resource_name)
end
end
def two_factor_entry
self.resource = User.find(session[:user_sign_in_uid])
render 'auth/two_factor_authentication'
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

View File

@ -172,4 +172,33 @@ class UserController < ApplicationController
redirect_to user_export_path redirect_to user_export_path
end end
def edit_security
current_user.otp_secret_key = User.otp_random_secret
@provisioning_uri = current_user.provisioning_uri(nil, issuer: APP_CONFIG[:hostname])
qr_code = RQRCode::QRCode.new(@provisioning_uri, :size => 12, :level => :h)
@qr_svg = qr_code.as_svg(offset: 0, color: '000',
shape_rendering: 'crispEdges',
module_size: 4)
end
def update_2fa
req_params = params.require(:user).permit(:otp_secret_key, :otp_validation)
current_user.otp_secret_key = req_params[:otp_secret_key]
if current_user.authenticate_otp(req_params[:otp_validation])
flash[:success] = 'yay'
current_user.save!
else
flash[:error] = current_user.otp_code
end
redirect_to edit_user_security_path
end
def destroy_2fa
end
end end

View File

@ -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,9 @@ class User < ApplicationRecord
:recoverable, :rememberable, :trackable, :recoverable, :rememberable, :trackable,
:validatable, :confirmable, :authentication_keys => [:login] :validatable, :confirmable, :authentication_keys => [:login]
has_one_time_password
attr_accessor :otp_attempt, :otp_validation
rolify rolify
# attr_accessor :login # attr_accessor :login

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

View File

@ -0,0 +1,15 @@
.card
.card-body
%h2= t('views.settings.security.2fa.title')
- if current_user.otp_secret_key.nil?
= bootstrap_form_for(current_user, url: { action: :update_2fa, method: :post }) do |f|
%a{:href => "https://play.google.com/store/apps/details?id=com.beemdevelopment.aegis"} Aegis Authenticator for Android
%a{:href => "https://apps.apple.com/gb/app/strongbox-authenticator/id1023839880"} Strongbox Authenticator for iOS
= RQRCode::QRCode.new(current_user.provisioning_uri("Retrospring:#{current_user.screen_name}", issuer: "Retrospring")).as_svg.html_safe
%pre= current_user.otp_secret_key
= f.text_field :otp_validation
= f.hidden_field :otp_secret_key, value: current_user.otp_secret_key
= f.submit t('views.actions.save'), class: 'btn btn-primary'
- else
%p= t('views.settings.security.2fa.enabled_hint')
= link_to t('views.actions.remove'), destroy_user_2fa_path, :class => 'btn btn-primary', :method => 'delete'

View File

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

View File

@ -0,0 +1,4 @@
= render 'settings/security'
- provide(:title, generate_title('Security Settings'))
- parent_layout 'user/settings'

View File

View File

@ -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,9 @@ 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"
modal: modal:
ask: ask:
title: "Ask your followers" title: "Ask your followers"
@ -443,3 +447,7 @@ en:
admin: "Admin" admin: "Admin"
moderator: "Moderator" moderator: "Moderator"
banned: "Banned" banned: "Banned"
auth:
2fa:
title: "Two-factor authentication"
otp_field: "One-time password"

View File

@ -48,7 +48,8 @@ Rails.application.routes.draw do
as :user do as :user do
# :sessions # :sessions
get 'sign_in' => 'devise/sessions#new', as: :new_user_session get 'sign_in' => 'devise/sessions#new', as: :new_user_session
post 'sign_in' => 'devise/sessions#create', as: :user_session post 'sign_in' => 'user/sessions#create', as: :user_session
get 'otp_auth' => 'user/sessions#two_factor_entry', as: :user_two_factor_entry
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 +68,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

View File

@ -0,0 +1,5 @@
class AddOtpSecretKeyToUsers < ActiveRecord::Migration[5.2]
def change
add_column :users, :otp_secret_key, :string
end
end

View File

@ -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_01_172537) 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,7 @@ 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.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