Two factor authentication (#7751)
This commit is contained in:
parent
3f74a759b3
commit
9d5b981809
36 changed files with 731 additions and 16 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,6 +11,7 @@ app/assets/images/custom/
|
|||
# Configuration files
|
||||
config/diaspora.yml
|
||||
config/initializers/secret_token.rb
|
||||
config/initializers/twofa_encryption_key.rb
|
||||
.bundle
|
||||
vendor/bundle/
|
||||
vendor/cache/
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@
|
|||
## Bug fixes
|
||||
|
||||
## Features
|
||||
* Add a manifest.json file as a first step to make diaspora* a Progressive Web App [#7998](https://github.com/diaspora/diaspora/pull/7998)
|
||||
* Add a manifest.json file as a first step to make diaspora\* a Progressive Web App [#7998](https://github.com/diaspora/diaspora/pull/7998)
|
||||
* Allow `web+diaspora://` links to link to a profile with only the diaspora ID [#8000](https://github.com/diaspora/diaspora/pull/8000)
|
||||
* Support TOTP two factor authentication [#7751](https://github.com/diaspora/diaspora/pull/7751)
|
||||
|
||||
# 0.7.10.0
|
||||
|
||||
|
|
|
|||
2
Gemfile
2
Gemfile
|
|
@ -27,7 +27,9 @@ gem "json-schema", "2.8.1"
|
|||
# Authentication
|
||||
|
||||
gem "devise", "4.6.1"
|
||||
gem "devise-two-factor", "3.0.3"
|
||||
gem "devise_lastseenable", "0.0.6"
|
||||
gem "rqrcode", "0.10.1"
|
||||
|
||||
# Captcha
|
||||
|
||||
|
|
|
|||
14
Gemfile.lock
14
Gemfile.lock
|
|
@ -60,6 +60,8 @@ GEM
|
|||
mime-types (>= 2.99)
|
||||
unf
|
||||
ast (2.4.0)
|
||||
attr_encrypted (3.1.0)
|
||||
encryptor (~> 3.0.0)
|
||||
attr_required (1.0.1)
|
||||
autoprefixer-rails (8.6.5)
|
||||
execjs
|
||||
|
|
@ -169,6 +171,12 @@ GEM
|
|||
railties (>= 4.1.0, < 6.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (3.0.3)
|
||||
activesupport (< 5.3)
|
||||
attr_encrypted (>= 1.3, < 4, != 2)
|
||||
devise (~> 4.0)
|
||||
railties (< 5.3)
|
||||
rotp (~> 2.0)
|
||||
devise_lastseenable (0.0.6)
|
||||
devise
|
||||
rails (>= 3.0.4)
|
||||
|
|
@ -191,6 +199,7 @@ GEM
|
|||
docile (1.3.1)
|
||||
domain_name (0.5.20180417)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
encryptor (3.0.0)
|
||||
entypo-rails (3.0.0)
|
||||
railties (>= 4.1, < 6)
|
||||
equalizer (0.0.11)
|
||||
|
|
@ -596,6 +605,9 @@ GEM
|
|||
responders (2.4.1)
|
||||
actionpack (>= 4.2.0, < 6.0)
|
||||
railties (>= 4.2.0, < 6.0)
|
||||
rotp (2.1.2)
|
||||
rqrcode (0.10.1)
|
||||
chunky_png (~> 1.0)
|
||||
rspec (3.8.0)
|
||||
rspec-core (~> 3.8.0)
|
||||
rspec-expectations (~> 3.8.0)
|
||||
|
|
@ -785,6 +797,7 @@ DEPENDENCIES
|
|||
cucumber-rails (= 1.6.0)
|
||||
database_cleaner (= 1.7.0)
|
||||
devise (= 4.6.1)
|
||||
devise-two-factor (= 3.0.3)
|
||||
devise_lastseenable (= 0.0.6)
|
||||
diaspora-prosody-config (= 0.0.7)
|
||||
diaspora_federation-json_schema (= 0.2.6)
|
||||
|
|
@ -876,6 +889,7 @@ DEPENDENCIES
|
|||
redcarpet (= 3.4.0)
|
||||
redis (= 3.3.5)
|
||||
responders (= 2.4.1)
|
||||
rqrcode (= 0.10.1)
|
||||
rspec-json_expectations (~> 2.1)
|
||||
rspec-rails (= 3.8.2)
|
||||
rubocop (= 0.66.0)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
.page-sessions.action-new,
|
||||
.page-sessions.action-create,
|
||||
.page-passwords.action-new,
|
||||
.page-passwords.action-edit {
|
||||
padding-top: 25px;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class ApplicationController < ActionController::Base
|
|||
before_action :gon_set_current_user
|
||||
before_action :gon_set_appconfig
|
||||
before_action :gon_set_preloads
|
||||
before_action :configure_permitted_parameters, if: :devise_controller?
|
||||
|
||||
inflection_method grammatical_gender: :gender
|
||||
|
||||
|
|
@ -182,4 +183,10 @@ class ApplicationController < ActionController::Base
|
|||
return unless gon.preloads.nil?
|
||||
gon.preloads = {}
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def configure_permitted_parameters
|
||||
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,10 +5,54 @@
|
|||
# the COPYRIGHT file.
|
||||
|
||||
class SessionsController < Devise::SessionsController
|
||||
after_action :reset_authentication_token, only: [:create]
|
||||
before_action :reset_authentication_token, only: [:destroy]
|
||||
# rubocop:disable Rails/LexicallyScopedActionFilter
|
||||
before_action :authenticate_with_2fa, only: :create
|
||||
after_action :reset_authentication_token, only: :create
|
||||
before_action :reset_authentication_token, only: :destroy
|
||||
# rubocop:enable Rails/LexicallyScopedActionFilter
|
||||
|
||||
def find_user
|
||||
return User.find(session[:otp_user_id]) if session[:otp_user_id]
|
||||
|
||||
User.find_for_authentication(username: params[:user][:username]) if params[:user][:username]
|
||||
end
|
||||
|
||||
def authenticate_with_2fa
|
||||
self.resource = find_user
|
||||
u = find_user
|
||||
|
||||
return true unless u&.otp_required_for_login?
|
||||
|
||||
if params[:user][:otp_attempt].present? && session[:otp_user_id]
|
||||
authenticate_with_two_factor_via_otp(u)
|
||||
elsif u&.valid_password?(params[:user][:password])
|
||||
prompt_for_two_factor(u)
|
||||
end
|
||||
end
|
||||
|
||||
def valid_otp_attempt?(user)
|
||||
user.validate_and_consume_otp!(params[:user][:otp_attempt]) ||
|
||||
user.invalidate_otp_backup_code!(params[:user][:otp_attempt])
|
||||
rescue OpenSSL::Cipher::CipherError => _error
|
||||
false
|
||||
end
|
||||
|
||||
def authenticate_with_two_factor_via_otp(user)
|
||||
if valid_otp_attempt?(user)
|
||||
session.delete(:otp_user_id)
|
||||
sign_in(user)
|
||||
else
|
||||
flash.now[:alert] = "Invalid token"
|
||||
prompt_for_two_factor(user)
|
||||
end
|
||||
end
|
||||
|
||||
def prompt_for_two_factor(user)
|
||||
session[:otp_user_id] = user.id
|
||||
render :two_factor
|
||||
end
|
||||
|
||||
def reset_authentication_token
|
||||
current_user.reset_authentication_token! unless current_user.nil?
|
||||
current_user&.reset_authentication_token!
|
||||
end
|
||||
end
|
||||
|
|
|
|||
60
app/controllers/two_factor_authentications_controller.rb
Normal file
60
app/controllers/two_factor_authentications_controller.rb
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TwoFactorAuthenticationsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :verify_otp_required, only: [:create]
|
||||
|
||||
def show
|
||||
@user = current_user
|
||||
end
|
||||
|
||||
def create
|
||||
current_user.otp_secret = User.generate_otp_secret(32)
|
||||
current_user.save!
|
||||
redirect_to confirm_two_factor_authentication_path
|
||||
end
|
||||
|
||||
def confirm_2fa
|
||||
redirect_to two_factor_authentication_path if current_user.otp_required_for_login?
|
||||
end
|
||||
|
||||
def confirm_and_activate_2fa
|
||||
if current_user.validate_and_consume_otp!(params[:user][:code])
|
||||
current_user.otp_required_for_login = true
|
||||
current_user.save!
|
||||
|
||||
flash[:notice] = t("two_factor_auth.flash.success_activation")
|
||||
redirect_to recovery_codes_two_factor_authentication_path
|
||||
else
|
||||
flash[:alert] = t("two_factor_auth.flash.error_token")
|
||||
redirect_to confirm_two_factor_authentication_path
|
||||
end
|
||||
end
|
||||
|
||||
def recovery_codes
|
||||
@recovery_codes = current_user.generate_otp_backup_codes!
|
||||
current_user.save!
|
||||
end
|
||||
|
||||
def destroy
|
||||
if acceptable_code?
|
||||
current_user.otp_required_for_login = false
|
||||
current_user.save!
|
||||
flash[:notice] = t("two_factor_auth.flash.success_deactivation")
|
||||
else
|
||||
flash.now[:alert] = t("two_factor_auth.flash.error_token")
|
||||
end
|
||||
redirect_to two_factor_authentication_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify_otp_required
|
||||
redirect_to two_factor_authentication_path if current_user.otp_required_for_login?
|
||||
end
|
||||
|
||||
def acceptable_code?
|
||||
current_user.validate_and_consume_otp!(params[:two_factor_authentication][:code]) ||
|
||||
current_user.invalidate_otp_backup_code!(params[:two_factor_authentication][:code])
|
||||
end
|
||||
end
|
||||
|
|
@ -152,6 +152,8 @@ class UsersController < ApplicationController
|
|||
:auto_follow_back_aspect_id,
|
||||
:getting_started,
|
||||
:post_default_public,
|
||||
:otp_required_for_login,
|
||||
:otp_secret,
|
||||
email_preferences: UserPreference::VALID_EMAIL_TYPES.map(&:to_sym)
|
||||
)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -72,4 +72,9 @@ module ApplicationHelper
|
|||
buf << [nonced_javascript_tag("$.fx.off = true;")] if Rails.env.test?
|
||||
buf.join("\n").html_safe
|
||||
end
|
||||
|
||||
def qrcode_uri
|
||||
label = current_user.username
|
||||
current_user.otp_provisioning_uri(label, issuer: AppConfig.environment.url)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,7 +19,15 @@ class User < ApplicationRecord
|
|||
scope :halfyear_actives, ->(time = Time.now) { logged_in_since(time - 6.month) }
|
||||
scope :active, -> { joins(:person).where(people: {closed_account: false}) }
|
||||
|
||||
devise :database_authenticatable, :registerable,
|
||||
attribute :otp_secret
|
||||
|
||||
devise :two_factor_authenticatable,
|
||||
:two_factor_backupable,
|
||||
otp_secret_encryption_key: AppConfig.twofa_encryption_key,
|
||||
otp_backup_code_length: 16,
|
||||
otp_number_of_backup_codes: 10
|
||||
|
||||
devise :registerable,
|
||||
:recoverable, :rememberable, :trackable, :validatable,
|
||||
:lockable, :lastseenable, :lock_strategy => :none, :unlock_strategy => :none
|
||||
|
||||
|
|
@ -42,6 +50,7 @@ class User < ApplicationRecord
|
|||
validate :no_person_with_same_username
|
||||
|
||||
serialize :hidden_shareables, Hash
|
||||
serialize :otp_backup_codes, Array
|
||||
|
||||
has_one :person, inverse_of: :owner, foreign_key: :owner_id
|
||||
has_one :profile, through: :person
|
||||
|
|
|
|||
39
app/views/sessions/two_factor.html.haml
Normal file
39
app/views/sessions/two_factor.html.haml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
- content_for :page_title do
|
||||
= AppConfig.settings.pod_name + " - " + t("two_factor_auth.title")
|
||||
|
||||
.container#twofa
|
||||
.text-center
|
||||
.logos-asterisk
|
||||
%h1
|
||||
= t("two_factor_auth.title")
|
||||
|
||||
|
||||
= form_for resource, as: resource_name,
|
||||
url: session_path(resource_name),
|
||||
html: {class: "block-form"},
|
||||
method: :post do |f|
|
||||
%fieldset
|
||||
%label.sr-only#otp-label{for: "otp_attempt"}
|
||||
= t("two_factor_auth.input_token.label")
|
||||
|
||||
= f.text_field :otp_attempt,
|
||||
type: :text,
|
||||
placeholder: t("two_factor_auth.input_token.placeholder"),
|
||||
required: true,
|
||||
autofocus: true,
|
||||
class: "input-block-level form-control"
|
||||
%p= t "two_factor_auth.recovery.reminder"
|
||||
|
||||
|
||||
.actions
|
||||
= f.button t("devise.sessions.new.sign_in"),
|
||||
type: :submit,
|
||||
class: "btn btn-large btn-block btn-primary"
|
||||
|
||||
.text-center
|
||||
- if display_password_reset_link?
|
||||
= link_to t("devise.shared.links.forgot_your_password"),
|
||||
new_password_path(resource_name), id: "forgot_password_link"
|
||||
%br
|
||||
- if display_registration_link?
|
||||
= link_to t("devise.shared.links.sign_up"), new_registration_path(resource_name)
|
||||
|
|
@ -9,6 +9,8 @@
|
|||
class: request.path == edit_user_path ? "list-group-item active" : "list-group-item"
|
||||
= link_to t("privacy"), privacy_settings_path,
|
||||
class: current_page?(privacy_settings_path) ? "list-group-item active" : "list-group-item"
|
||||
= link_to t("two_factor_auth.title"), two_factor_authentication_path,
|
||||
class: current_page?(two_factor_authentication_path) ? "list-group-item active" : "list-group-item"
|
||||
= link_to t("_services"), services_path,
|
||||
class: current_page?(services_path) ? "list-group-item active" : "list-group-item"
|
||||
= link_to t("_applications"), api_openid_connect_user_applications_path,
|
||||
|
|
|
|||
11
app/views/two_factor_authentications/_activate.haml
Normal file
11
app/views/two_factor_authentications/_activate.haml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.col-md-12
|
||||
.row
|
||||
.col-md-12
|
||||
%h3= t("two_factor_auth.title")
|
||||
%p= t("two_factor_auth.explanation")
|
||||
|
||||
.well= t("two_factor_auth.deactivated.status")
|
||||
= form_for "user", url: two_factor_authentication_path, html: {method: :post} do |f|
|
||||
= f.hidden_field :otp_required_for_login, value: true
|
||||
.clearfix.form-group= f.submit t("two_factor_auth.deactivated.change_button"),
|
||||
class: "btn btn-primary pull-right"
|
||||
33
app/views/two_factor_authentications/_confirm.haml
Normal file
33
app/views/two_factor_authentications/_confirm.haml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
.col-md-12
|
||||
.row
|
||||
.col-md-12
|
||||
%h3= t("two_factor_auth.confirm.title")
|
||||
%p= t("two_factor_auth.confirm.status")
|
||||
.small-horizontal-spacer
|
||||
%h4= t("two_factor_auth.confirm.scan_title")
|
||||
.row
|
||||
.col-md-6
|
||||
%p= t("two_factor_auth.confirm.scan_explanation")
|
||||
.two-factor-qr
|
||||
!= RQRCode::QRCode.new(qrcode_uri).as_svg(offset: 10, fill: "ffffff", module_size: 5)
|
||||
|
||||
.col-md-6
|
||||
%p= t("two_factor_auth.confirm.manual_explanation")
|
||||
%p!= t("two_factor_auth.confirm.manual_explanation_cont")
|
||||
%pre.well= current_user.otp_secret.scan(/.{4}/).join(" ")
|
||||
|
||||
.row
|
||||
.col-md-12
|
||||
.small-horizontal-spacer
|
||||
%h4= t("two_factor_auth.confirm.input_title")
|
||||
= t("two_factor_auth.confirm.input_explanation")
|
||||
= form_for "user", url: confirm_two_factor_authentication_path,
|
||||
html: {method: :post, class: "form-horizontal"} do |f|
|
||||
.form-group
|
||||
= f.label :code, t("two_factor_auth.input_token.label"), class: "control-label col-sm-6"
|
||||
.col-sm-6
|
||||
= f.text_field :code, placeholder: t("two_factor_auth.input_token.placeholder"), class: "form-control"
|
||||
.form-group
|
||||
.col-sm-12
|
||||
= link_to t("cancel"), two_factor_authentication_path, class: "btn btn-default"
|
||||
= f.submit t("two_factor_auth.confirm.activate_button"), class: "btn btn-primary pull-right"
|
||||
28
app/views/two_factor_authentications/_deactivate.haml
Normal file
28
app/views/two_factor_authentications/_deactivate.haml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
.col-md-12
|
||||
.row
|
||||
.col-md-12
|
||||
%h3= t("two_factor_auth.title")
|
||||
%p= t("two_factor_auth.explanation")
|
||||
.well= t("two_factor_auth.activated.status")
|
||||
|
||||
%hr
|
||||
|
||||
%h4= t("two_factor_auth.activated.change_button")
|
||||
%p= t("two_factor_auth.activated.change_label")
|
||||
|
||||
= form_for "two_factor_authentication", url: two_factor_authentication_path,
|
||||
html: {method: :delete, class: "form-horizontal"} do |f|
|
||||
.form-group
|
||||
= f.label :code, t("two_factor_auth.input_token.label"), class: "control-label col-sm-6"
|
||||
.col-sm-6
|
||||
= f.text_field :code, placeholder: t("two_factor_auth.input_token.placeholder"), class: "form-control"
|
||||
= t("two_factor_auth.recovery.reminder")
|
||||
.clearfix= f.submit t("two_factor_auth.activated.change_button"), class: "btn btn-primary pull-right"
|
||||
|
||||
%hr
|
||||
|
||||
%h4= t("two_factor_auth.recovery.title")
|
||||
%p= t("two_factor_auth.recovery.explanation_short")
|
||||
%p= t("two_factor_auth.recovery.invalidation_notice")
|
||||
%p= link_to t("two_factor_auth.recovery.button"),
|
||||
recovery_codes_two_factor_authentication_path, class: "btn btn-default"
|
||||
18
app/views/two_factor_authentications/_recovery.haml
Normal file
18
app/views/two_factor_authentications/_recovery.haml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
.col-md-12
|
||||
.row
|
||||
.col-md-12
|
||||
%h3= t("two_factor_auth.title")
|
||||
.well= t("two_factor_auth.activated.status")
|
||||
|
||||
%hr
|
||||
|
||||
%h3= t("two_factor_auth.recovery.title")
|
||||
%p= t("two_factor_auth.recovery.explanation")
|
||||
|
||||
%ol.recovery-codes
|
||||
- @recovery_codes.each do |code|
|
||||
%li<
|
||||
%samp= code
|
||||
|
||||
.form-group.submit_block.clearfix
|
||||
= link_to t("ok"), two_factor_authentication_path, class: "btn btn-primary pull-right"
|
||||
4
app/views/two_factor_authentications/_token_form.haml
Normal file
4
app/views/two_factor_authentications/_token_form.haml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.form-group
|
||||
= f.label :code, t("two_factor_auth.input_token.label"), class: "control-label col-sm-6"
|
||||
.col-sm-6
|
||||
= f.text_field :code, placeholder: t("two_factor_auth.input_token.placeholder"), class: "form-control"
|
||||
11
app/views/two_factor_authentications/confirm_2fa.html.haml
Normal file
11
app/views/two_factor_authentications/confirm_2fa.html.haml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
- content_for :page_title do
|
||||
= t("two_factor_auth.confirm.title")
|
||||
|
||||
.container-fluid
|
||||
.row
|
||||
.col-md-3
|
||||
.sidebar
|
||||
= render "shared/settings_nav"
|
||||
.col-md-9
|
||||
.framed-content.clearfix
|
||||
= render "confirm"
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
- content_for :page_title do
|
||||
= t("two_factor_auth.title")
|
||||
= t("two_factor_auth.recovery.title")
|
||||
|
||||
.container-fluid
|
||||
.row
|
||||
.col-md-3
|
||||
.sidebar
|
||||
= render "shared/settings_nav"
|
||||
.col-md-9
|
||||
.framed-content.clearfix
|
||||
= render "recovery"
|
||||
15
app/views/two_factor_authentications/show.html.haml
Normal file
15
app/views/two_factor_authentications/show.html.haml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
- content_for :page_title do
|
||||
= t("two_factor_auth.title")
|
||||
|
||||
.container-fluid
|
||||
.row
|
||||
.col-md-3
|
||||
.sidebar
|
||||
= render "shared/settings_nav"
|
||||
.col-md-9
|
||||
.framed-content.clearfix
|
||||
|
||||
- if @user.otp_required_for_login
|
||||
= render "deactivate"
|
||||
- else
|
||||
= render "activate"
|
||||
|
|
@ -15,6 +15,11 @@ end
|
|||
# Use this hook to configure devise mailer, warden hooks and so forth.
|
||||
# Many of these configuration options can be set straight in your model.
|
||||
Devise.setup do |config|
|
||||
config.warden do |manager|
|
||||
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
|
||||
manager.default_strategies(scope: :user).unshift :two_factor_backupable
|
||||
end
|
||||
|
||||
# The secret key used by Devise. Devise uses this key to generate
|
||||
# random tokens. Changing this key will render invalid all existing
|
||||
# confirmation, reset password and unlock tokens in the database.
|
||||
|
|
@ -270,4 +275,8 @@ Devise.setup do |config|
|
|||
# When using omniauth, Devise cannot automatically set Omniauth path,
|
||||
# so you need to do it manually. For the users scope, it would be:
|
||||
# config.omniauth_path_prefix = '/my_engine/users/auth'
|
||||
|
||||
# if a user enables 2fa this would log them in without requiring them
|
||||
# to enter a token
|
||||
config.sign_in_after_reset_password = false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@
|
|||
|
||||
# Configure sensitive parameters which will be filtered from the log file.
|
||||
Rails.application.config.filter_parameters += %i[password message text bio]
|
||||
Rails.application.config.filter_parameters += [:otp_attempt]
|
||||
|
|
|
|||
|
|
@ -1311,6 +1311,42 @@ en:
|
|||
email_confirmed: "Email %{email} activated"
|
||||
email_not_confirmed: "Email could not be activated. Wrong link?"
|
||||
|
||||
two_factor_auth:
|
||||
title: "Two-factor authentication"
|
||||
explanation: "Two-factor authentication is a powerful way to ensure you are the only one able to sign in to your account. When signing in, you will enter a 6-digit code along with your password to prove your identity. Be careful though: if you lose your phone and the recovery codes created when you activate this feature, access to your diaspora* account will be blocked forever."
|
||||
activated:
|
||||
status: "Two-factor authentication activated"
|
||||
change_label: "Deactivate two-factor authentication by entering a TOTP token."
|
||||
change_button: "Deactivate"
|
||||
deactivated:
|
||||
status: "Two-factor authentication not activated"
|
||||
change_label: "Activate two-factor authentication"
|
||||
change_button: "Activate"
|
||||
confirm:
|
||||
title: "Confirm activation"
|
||||
status: "Two-factor authentication is not fully activated yet, you need to confirm activation with a TOTP token"
|
||||
scan_title: "Scan the QR code"
|
||||
scan_explanation: "Please scan the QR code with a TOTP capable app, such as andOTP (Android), FreeOTP (iOS), SailOTP (SailfishOS)."
|
||||
manual_explanation: "In case you can’t scan the QR code automatically you can manually enter the secret in your app."
|
||||
manual_explanation_cont: "We are using time-based one-time passwords (TOTP) with six-digit tokens. In case your app prompts you for a time interval and algorithm enter 30 seconds and sha1 respectively. <br /> The spaces are just for readability, please enter the code without them."
|
||||
input_title: "Confim with TOTP token"
|
||||
input_explanation: "After scanning or entering the secret, enter the six-digit code you see and confirm the setup."
|
||||
activate_button: "Confirm and activate"
|
||||
input_token:
|
||||
label: "Two-factor token"
|
||||
placeholder: "six-digit two-factor token"
|
||||
recovery:
|
||||
title: "Recovery codes"
|
||||
reminder: "Alternatively, you can use one of the recovery codes."
|
||||
explanation: "If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. Keep the recovery codes safe. For example, you may print them and store them with other important documents."
|
||||
explanation_short: "Recovery codes allow you to regain access to your account if you lose your phone. Note that you can use each recovery code only once."
|
||||
invalidation_notice: "If you've lost your recovery codes, you can regenerate them here. Your old recovery codes will be invalidated."
|
||||
button: "Generate new recovery codes"
|
||||
flash:
|
||||
success_activation: "Successfully activated two-factor authentication"
|
||||
success_deactivation: "Successfully deactivated two-factor authentication"
|
||||
error_token: "Token was incorrect or invalid"
|
||||
|
||||
will_paginate:
|
||||
previous_label: "« previous"
|
||||
next_label: "next »"
|
||||
|
|
|
|||
|
|
@ -119,6 +119,12 @@ Rails.application.routes.draw do
|
|||
get "getting_started_completed" => :getting_started_completed
|
||||
end
|
||||
|
||||
resource :two_factor_authentication, only: %i[show create destroy] do
|
||||
get :confirm, action: :confirm_2fa
|
||||
post :confirm, action: :confirm_and_activate_2fa
|
||||
get :recovery_codes
|
||||
end
|
||||
|
||||
devise_for :users, controllers: {sessions: :sessions}, skip: :registration
|
||||
devise_scope :user do
|
||||
get "/users/sign_up" => "registrations#new", :as => :new_user_registration
|
||||
|
|
|
|||
11
db/migrate/20171229175654_add_devise_two_factor_to_users.rb
Normal file
11
db/migrate/20171229175654_add_devise_two_factor_to_users.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddDeviseTwoFactorToUsers < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_column :users, :encrypted_otp_secret, :string
|
||||
add_column :users, :encrypted_otp_secret_iv, :string
|
||||
add_column :users, :encrypted_otp_secret_salt, :string
|
||||
add_column :users, :consumed_timestep, :integer
|
||||
add_column :users, :otp_required_for_login, :boolean
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddTwoFactorBackupableToUser < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_column :users, :otp_backup_codes, :text
|
||||
end
|
||||
end
|
||||
|
|
@ -29,8 +29,7 @@ Feature: Change password
|
|||
When I follow the "Change my password" link from the last sent email
|
||||
When I fill out the password reset form with "supersecret" and "supersecret"
|
||||
And I submit the password reset form
|
||||
Then I should be on the stream page
|
||||
And I sign out manually
|
||||
Then I should be on the new user session page
|
||||
And I sign in manually as "georges_abitbol" with password "supersecret"
|
||||
Then I should be on the stream page
|
||||
|
||||
|
|
|
|||
90
features/desktop/two_factor_authentication.feature
Normal file
90
features/desktop/two_factor_authentication.feature
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# frozen_string_literal: true
|
||||
@javascript
|
||||
Feature: Two-factor autentication
|
||||
|
||||
Scenario: Activate 2fa
|
||||
Given a user with email "alice@test.com"
|
||||
When I sign in as "alice@test.com"
|
||||
When I go to the two-factor authentication page
|
||||
And I press "Activate"
|
||||
Then I should see "Confirm activation"
|
||||
When I scan the QR code and fill in a valid TOTP token for "alice@test.com"
|
||||
And I press "Confirm and activate"
|
||||
Then I should see "Two-factor authentication activated"
|
||||
And I should see "Recovery codes"
|
||||
When I confirm activation
|
||||
Then I should see "Two-factor authentication activated"
|
||||
And I should see "Deactivate"
|
||||
|
||||
Scenario: Signing in with 2fa activated and correct token
|
||||
Given a user with username "alice" and password "secret"
|
||||
And 2fa is activated for "alice"
|
||||
When I go to the login page
|
||||
And I fill in username "alice" and password "secret"
|
||||
And press "Sign in"
|
||||
Then I should see "Two-factor authentication"
|
||||
When I fill in a valid TOTP token for "alice"
|
||||
And I press "Sign in"
|
||||
Then I should be on the stream page
|
||||
|
||||
Scenario: Trying to sign in with 2fa activated and incorrect token
|
||||
Given a user with username "alice" and password "secret"
|
||||
And 2fa is activated for "alice"
|
||||
When I go to the login page
|
||||
And I fill in username "alice" and password "secret"
|
||||
And press "Sign in"
|
||||
Then I should see "Two-factor authentication"
|
||||
When I fill in an invalid TOTP token
|
||||
And I press "Sign in"
|
||||
Then I should see "Two-factor authentication"
|
||||
|
||||
Scenario: Signing in with 2fa activated and a recovery code
|
||||
Given a user with username "alice" and password "secret"
|
||||
And 2fa is activated for "alice"
|
||||
When I go to the login page
|
||||
And I fill in username "alice" and password "secret"
|
||||
And press "Sign in"
|
||||
Then I should see "Two-factor authentication"
|
||||
When I fill in a recovery code from "alice"
|
||||
And I press "Sign in"
|
||||
Then I should be on the stream page
|
||||
|
||||
Scenario: Regenerating recovery codes
|
||||
Given a user with email "alice@test.com"
|
||||
When I sign in as "alice@test.com"
|
||||
And 2fa is activated for "alice@test.com"
|
||||
When I go to the two-factor authentication page
|
||||
Then I should see "Generate new recovery codes"
|
||||
When I press the recovery code generate button
|
||||
Then I should see a list of recovery codes
|
||||
|
||||
Scenario: Deactivating 2fa with correct token
|
||||
Given a user with email "alice@test.com"
|
||||
When I sign in as "alice@test.com"
|
||||
And 2fa is activated for "alice@test.com"
|
||||
When I go to the two-factor authentication page
|
||||
Then I should see "Deactivate"
|
||||
When I fill in a valid TOTP token to deactivate for "alice@test.com"
|
||||
And I press "Deactivate"
|
||||
Then I should see "Two-factor authentication not activated"
|
||||
|
||||
Scenario: Deactivating 2fa with recovery token
|
||||
Given a user with email "alice@test.com"
|
||||
When I sign in as "alice@test.com"
|
||||
And 2fa is activated for "alice@test.com"
|
||||
When I go to the two-factor authentication page
|
||||
Then I should see "Deactivate"
|
||||
When I fill in a recovery code to deactivate from "alice@test.com"
|
||||
And I press "Deactivate"
|
||||
Then I should see "Two-factor authentication not activated"
|
||||
|
||||
Scenario: Trying to deactivate with incorrect token
|
||||
Given a user with email "alice@test.com"
|
||||
When I sign in as "alice@test.com"
|
||||
And 2fa is activated for "alice@test.com"
|
||||
When I go to the two-factor authentication page
|
||||
Then I should see "Deactivate"
|
||||
When I fill in an invalid TOTP token to deactivate
|
||||
And I press "Deactivate"
|
||||
Then I should see "Two-factor authentication activated"
|
||||
And I should see "Deactivate"
|
||||
|
|
@ -31,9 +31,8 @@ Feature: Change password
|
|||
When I follow the "Change my password" link from the last sent email
|
||||
And I fill out the password reset form with "supersecret" and "supersecret"
|
||||
And I submit the password reset form
|
||||
Then I should be on the stream page
|
||||
When I sign out
|
||||
And I go to the login page
|
||||
Then I should be on the new user session page
|
||||
When I go to the login page
|
||||
And I sign in manually as "georges_abitbol" with password "supersecret" on the mobile website
|
||||
Then I should be on the stream page
|
||||
|
||||
|
|
|
|||
67
features/step_definitions/two_factor_steps.rb
Normal file
67
features/step_definitions/two_factor_steps.rb
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
When /^I scan the QR code and fill in a valid TOTP token for "([^"]*)"$/ do |email|
|
||||
@me = find_user email
|
||||
fill_in "user_code", with: @me.current_otp
|
||||
end
|
||||
|
||||
When /^I fill in a valid TOTP token for "([^"]*)"$/ do |username|
|
||||
@me = find_user username
|
||||
fill_in "user_otp_attempt", with: @me.current_otp
|
||||
end
|
||||
|
||||
When /^I fill in an invalid TOTP token$/ do
|
||||
fill_in "user_otp_attempt", with: "c0ffee"
|
||||
end
|
||||
|
||||
When /^I fill in a valid TOTP token to deactivate for "([^"]*)"$/ do |username|
|
||||
@me = find_user username
|
||||
fill_in "two_factor_authentication_code", with: @me.current_otp
|
||||
end
|
||||
|
||||
When /^I fill in an invalid TOTP token to deactivate$/ do
|
||||
fill_in "two_factor_authentication_code", with: "c0ffee"
|
||||
end
|
||||
|
||||
When /^I fill in a recovery code from "([^"]*)"$/ do |username|
|
||||
@me = find_user username
|
||||
@codes = @me.generate_otp_backup_codes!
|
||||
@me.save!
|
||||
fill_in "user_otp_attempt", with: @codes.first
|
||||
end
|
||||
|
||||
When /^I fill in a recovery code to deactivate from "([^"]*)"$/ do |username|
|
||||
@me = find_user username
|
||||
@codes = @me.generate_otp_backup_codes!
|
||||
@me.save!
|
||||
fill_in "two_factor_authentication_code", with: @codes.first
|
||||
end
|
||||
|
||||
When /^I confirm activation$/ do
|
||||
find(".btn-primary", match: :first).click
|
||||
end
|
||||
|
||||
When /^2fa is activated for "([^"]*)"$/ do |username|
|
||||
@me = find_user username
|
||||
@me.otp_secret = User.generate_otp_secret(32)
|
||||
@me.otp_required_for_login = true
|
||||
@me.save!
|
||||
end
|
||||
|
||||
When /^I fill in username "([^"]*)" and password "([^"]*)"$/ do |username, password|
|
||||
fill_in "user_username", with: username
|
||||
fill_in "user_password", with: password
|
||||
end
|
||||
|
||||
Then /^I should see a list of recovery codes$/ do
|
||||
find(".recovery-codes", match: :first)
|
||||
find(".recovery-codes li samp", match: :first)
|
||||
end
|
||||
|
||||
When /^I press the recovery code generate button$/ do
|
||||
find(".btn-default", match: :first).click
|
||||
end
|
||||
|
||||
def find_user(username)
|
||||
User.find_by(username: username) || User.find_by(email: username)
|
||||
end
|
||||
|
|
@ -40,6 +40,8 @@ module NavigationHelpers
|
|||
edit_user_path
|
||||
when /^forgot password page$/
|
||||
new_user_password_path
|
||||
when /^the two-factor authentication page$/
|
||||
two_factor_authentication_path
|
||||
when %r{^"(/.*)"}
|
||||
Regexp.last_match(1)
|
||||
else
|
||||
|
|
|
|||
|
|
@ -52,25 +52,43 @@ module Configuration
|
|||
|
||||
def secret_token
|
||||
if heroku?
|
||||
return ENV['SECRET_TOKEN'] if ENV['SECRET_TOKEN']
|
||||
return ENV["SECRET_TOKEN"] if ENV["SECRET_TOKEN"]
|
||||
|
||||
warn "FATAL: Running on Heroku with SECRET_TOKEN unset"
|
||||
warn " Run heroku config:add SECRET_TOKEN=#{SecureRandom.hex(40)}"
|
||||
Process.exit(1)
|
||||
abort
|
||||
else
|
||||
token_file = File.expand_path(
|
||||
'../config/initializers/secret_token.rb',
|
||||
"../config/initializers/secret_token.rb",
|
||||
File.dirname(__FILE__)
|
||||
)
|
||||
unless File.exist? token_file
|
||||
`DISABLE_SPRING=1 bin/rake generate:secret_token`
|
||||
end
|
||||
system "DISABLE_SPRING=1 bin/rake generate:secret_token" unless File.exist? token_file
|
||||
require token_file
|
||||
Diaspora::Application.config.secret_key_base
|
||||
end
|
||||
end
|
||||
|
||||
def twofa_encryption_key
|
||||
if heroku?
|
||||
return ENV["TWOFA_ENCRYPTION_KEY"] if ENV["TWOFA_ENCRYPTION_KEY"]
|
||||
|
||||
warn "FATAL: Running on Heroku with TWOFA_ENCRYPTION_KEY unset"
|
||||
warn " Run heroku config:add TWOFA_ENCRYPTION_KEY=#{SecureRandom.hex(32)}"
|
||||
abort
|
||||
else
|
||||
key_file = File.expand_path(
|
||||
"../config/initializers/twofa_encryption_key.rb",
|
||||
File.dirname(__FILE__)
|
||||
)
|
||||
system "DISABLE_SPRING=1 bin/rake generate:twofa_key" unless File.exist? key_file
|
||||
require key_file
|
||||
Diaspora::Application.config.twofa_encryption_key
|
||||
end
|
||||
end
|
||||
|
||||
def version_string
|
||||
return @version_string unless @version_string.nil?
|
||||
|
||||
@version_string = version.number.to_s
|
||||
@version_string = "#{@version_string}-p#{git_revision[0..7]}" if git_available?
|
||||
@version_string
|
||||
|
|
|
|||
24
lib/tasks/generate_2fa_encription_key.rake
Normal file
24
lib/tasks/generate_2fa_encription_key.rake
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
namespace :generate do
|
||||
desc "Generates a key for encrypting 2fa tokens"
|
||||
task :twofa_key do
|
||||
path = Rails.root.join("config", "initializers", "twofa_encryption_key.rb")
|
||||
key = SecureRandom.hex(32)
|
||||
File.open(path, "w") do |f|
|
||||
f.write <<~CONF
|
||||
# frozen_string_literal: true
|
||||
|
||||
# The 2fa encryption key is used to encrypt users' OTP tokens in the database.
|
||||
|
||||
# You can regenerate this key by running `rake generate:twofa_key`
|
||||
|
||||
# If you change this key after a user has set up 2fa
|
||||
# the users' tokens cannot be recovered
|
||||
# and they will not be able to log in again!
|
||||
|
||||
Diaspora::Application.config.twofa_encryption_key = "#{key}"
|
||||
CONF
|
||||
end
|
||||
end
|
||||
end
|
||||
120
spec/controllers/two_factor_authentications_controller_spec.rb
Normal file
120
spec/controllers/two_factor_authentications_controller_spec.rb
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
describe TwoFactorAuthenticationsController, type: :controller do
|
||||
before do
|
||||
@user = FactoryGirl.create :user
|
||||
sign_in @user
|
||||
end
|
||||
|
||||
describe "#show" do
|
||||
it "shows the deactivated state of 2fa" do
|
||||
get :show
|
||||
expect(response.body).to match I18n.t("two_factor_auth.title")
|
||||
expect(response.body).to match I18n.t("two_factor_auth.deactivated.status")
|
||||
expect(@user).to have_attributes(otp_required_for_login: nil)
|
||||
end
|
||||
|
||||
it "shows the activated state of 2fa" do
|
||||
activate_2fa
|
||||
get :show
|
||||
expect(response.body).to match I18n.t("two_factor_auth.title")
|
||||
expect(response.body).to match I18n.t("two_factor_auth.activated.status")
|
||||
expect(response.body).to match I18n.t("two_factor_auth.input_token.label")
|
||||
expect(response.body).to match I18n.t("two_factor_auth.recovery.button")
|
||||
expect(@user).to have_attributes(otp_required_for_login: true)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#create" do
|
||||
it "sets the otp_secret flag" do
|
||||
post :create, params: {user: {otp_required_for_login: "true"}}
|
||||
expect(response).to be_redirect
|
||||
expect(response.location).to match confirm_two_factor_authentication_path
|
||||
end
|
||||
end
|
||||
|
||||
describe "#confirm_2fa" do
|
||||
context "2fa is not yet activated" do
|
||||
before do
|
||||
create_otp_token
|
||||
end
|
||||
it "shows the QR verification code" do
|
||||
get :confirm_2fa
|
||||
expect(response.body).to match I18n.t("two_factor_auth.confirm.title")
|
||||
expect(response.body).to include("svg")
|
||||
expect(response.body).to match(/#{@user.otp_secret.scan(/.{4}/).join(" ")}/)
|
||||
expect(response.body).to match I18n.t("two_factor_auth.input_token.label")
|
||||
end
|
||||
end
|
||||
context "2fa is already activated" do
|
||||
before do
|
||||
activate_2fa
|
||||
end
|
||||
|
||||
it "redirects back" do
|
||||
get :confirm_2fa
|
||||
expect(response).to be_redirect
|
||||
expect(response.location).to match two_factor_authentication_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#confirm_and_activate_2fa" do
|
||||
before do
|
||||
create_otp_token
|
||||
end
|
||||
it "redirects back to confirm when token was wrong" do
|
||||
post :confirm_and_activate_2fa, params: {user: {code: "not valid token"}}
|
||||
expect(response.location).to match confirm_two_factor_authentication_path
|
||||
expect(flash[:alert]).to match I18n.t("two_factor_auth.flash.error_token")
|
||||
end
|
||||
it "redirects to #recovery_codes when token was correct" do
|
||||
post :confirm_and_activate_2fa, params: {user: {code: @user.current_otp}}
|
||||
expect(response.location).to match recovery_codes_two_factor_authentication_path
|
||||
expect(flash[:notice]).to match I18n.t("two_factor_auth.flash.success_activation")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#recovery_codes" do
|
||||
before do
|
||||
activate_2fa
|
||||
end
|
||||
it "shows recovery codes page" do
|
||||
get :recovery_codes
|
||||
expect(response.body).to match I18n.t("two_factor_auth.recovery.title")
|
||||
expect(@user).to have_attributes(otp_required_for_login: true)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#destroy" do
|
||||
before do
|
||||
activate_2fa
|
||||
end
|
||||
it "deactivates 2fa if token is correct" do
|
||||
delete :destroy, params: {two_factor_authentication: {code: @user.current_otp}}
|
||||
expect(response).to be_redirect
|
||||
expect(flash[:notice]).to match I18n.t("two_factor_auth.flash.success_deactivation")
|
||||
end
|
||||
|
||||
it "does nothing if token is wrong" do
|
||||
delete :destroy, params: {two_factor_authentication: {code: "a wrong code"}}
|
||||
expect(response).to be_redirect
|
||||
expect(flash[:alert]).to match I18n.t("two_factor_auth.flash.error_token")
|
||||
end
|
||||
end
|
||||
|
||||
def create_otp_token
|
||||
@user.otp_secret = User.generate_otp_secret(32)
|
||||
@user.save!
|
||||
end
|
||||
|
||||
def confirm_activation
|
||||
@user.otp_required_for_login = true
|
||||
@user.save!
|
||||
end
|
||||
|
||||
def activate_2fa
|
||||
create_otp_token
|
||||
confirm_activation
|
||||
end
|
||||
end
|
||||
|
|
@ -983,6 +983,13 @@ describe User, :type => :model do
|
|||
post_default_public
|
||||
exported_at
|
||||
exported_photos_at
|
||||
consumed_timestep
|
||||
encrypted_otp_secret
|
||||
encrypted_otp_secret_iv
|
||||
encrypted_otp_secret_salt
|
||||
otp_backup_codes
|
||||
otp_required_for_login
|
||||
otp_secret
|
||||
)
|
||||
)
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in a new issue