Commit 9d5b9818 authored by lislis's avatar lislis Committed by Jonne Haß

Two factor authentication (#7751)

parent 3f74a759
......@@ -11,6 +11,7 @@ app/assets/images/custom/
# Configuration files
......@@ -5,8 +5,9 @@
## Bug fixes
## Features
* Add a manifest.json file as a first step to make diaspora* a Progressive Web App [#7998](
* Add a manifest.json file as a first step to make diaspora\* a Progressive Web App [#7998](
* Allow `web+diaspora://` links to link to a profile with only the diaspora ID [#8000](
* Support TOTP two factor authentication [#7751](
......@@ -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
......@@ -60,6 +60,8 @@ GEM
mime-types (>= 2.99)
ast (2.4.0)
attr_encrypted (3.1.0)
encryptor (~> 3.0.0)
attr_required (1.0.1)
autoprefixer-rails (8.6.5)
......@@ -169,6 +171,12 @@ GEM
railties (>= 4.1.0, < 6.0)
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)
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)
.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 = {}
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
......@@ -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]
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]
elsif u&.valid_password?(params[:user][:password])
def valid_otp_attempt?(user)
user.validate_and_consume_otp!(params[:user][:otp_attempt]) ||
rescue OpenSSL::Cipher::CipherError => _error
def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user)
else[:alert] = "Invalid token"
def prompt_for_two_factor(user)
session[:otp_user_id] =
render :two_factor
def reset_authentication_token
current_user.reset_authentication_token! unless current_user.nil?
# frozen_string_literal: true
class TwoFactorAuthenticationsController < ApplicationController
before_action :authenticate_user!
before_action :verify_otp_required, only: [:create]
def show
@user = current_user
def create
current_user.otp_secret = User.generate_otp_secret(32)!
redirect_to confirm_two_factor_authentication_path
def confirm_2fa
redirect_to two_factor_authentication_path if current_user.otp_required_for_login?
def confirm_and_activate_2fa
if current_user.validate_and_consume_otp!(params[:user][:code])
current_user.otp_required_for_login = true!
flash[:notice] = t("two_factor_auth.flash.success_activation")
redirect_to recovery_codes_two_factor_authentication_path
flash[:alert] = t("two_factor_auth.flash.error_token")
redirect_to confirm_two_factor_authentication_path
def recovery_codes
@recovery_codes = current_user.generate_otp_backup_codes!!
def destroy
if acceptable_code?
current_user.otp_required_for_login = false!
flash[:notice] = t("two_factor_auth.flash.success_deactivation")
else[:alert] = t("two_factor_auth.flash.error_token")
redirect_to two_factor_authentication_path
def verify_otp_required
redirect_to two_factor_authentication_path if current_user.otp_required_for_login?
def acceptable_code?
current_user.validate_and_consume_otp!(params[:two_factor_authentication][:code]) ||
......@@ -152,6 +152,8 @@ class UsersController < ApplicationController
......@@ -72,4 +72,9 @@ module ApplicationHelper
buf << [nonced_javascript_tag("$ = true;")] if Rails.env.test?
def qrcode_uri
label = current_user.username
current_user.otp_provisioning_uri(label, issuer: AppConfig.environment.url)
......@@ -19,7 +19,15 @@ class User < ApplicationRecord
scope :halfyear_actives, ->(time = { 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,
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
- content_for :page_title do
= AppConfig.settings.pod_name + " - " + t("two_factor_auth.title")
= 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{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"
= f.button t(""),
type: :submit,
class: "btn btn-large btn-block btn-primary"
- if display_password_reset_link?
= link_to t("devise.shared.links.forgot_your_password"),
new_password_path(resource_name), id: "forgot_password_link"
- 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,
%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"
%h3= t("two_factor_auth.confirm.title")
%p= t("two_factor_auth.confirm.status")
%h4= t("two_factor_auth.confirm.scan_title")
%p= t("two_factor_auth.confirm.scan_explanation")
!= 10, fill: "ffffff", module_size: 5)
%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(" ")
%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|
= f.label :code, t("two_factor_auth.input_token.label"), class: "control-label col-sm-6"
= f.text_field :code, placeholder: t("two_factor_auth.input_token.placeholder"), class: "form-control"
= 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"
%h3= t("two_factor_auth.title")
%p= t("two_factor_auth.explanation")
.well= t("two_factor_auth.activated.status")
%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|
= f.label :code, t("two_factor_auth.input_token.label"), class: "control-label 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"
%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"
%h3= t("two_factor_auth.title")
.well= t("two_factor_auth.activated.status")
%h3= t("two_factor_auth.recovery.title")
%p= t("two_factor_auth.recovery.explanation")
- @recovery_codes.each do |code|
%samp= code
= link_to t("ok"), two_factor_authentication_path, class: "btn btn-primary pull-right"
= f.label :code, t("two_factor_auth.input_token.label"), class: "control-label col-sm-6"
= f.text_field :code, placeholder: t("two_factor_auth.input_token.placeholder"), class: "form-control"
- content_for :page_title do
= t("two_factor_auth.confirm.title")
= render "shared/settings_nav"
= render "confirm"
- content_for :page_title do
= t("two_factor_auth.title")
= t("two_factor_auth.recovery.title")
= render "shared/settings_nav"
= render "recovery"
- content_for :page_title do
= t("two_factor_auth.title")
= render "shared/settings_nav"
- 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
# 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
......@@ -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?"
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."
status: "Two-factor authentication activated"
change_label: "Deactivate two-factor authentication by entering a TOTP token."
change_button: "Deactivate"
status: "Two-factor authentication not activated"
change_label: "Activate two-factor authentication"
change_button: "Activate"
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"
label: "Two-factor token"
placeholder: "six-digit two-factor token"
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"
success_activation: "Successfully activated two-factor authentication"
success_deactivation: "Successfully deactivated two-factor authentication"
error_token: "Token was incorrect or invalid"
previous_label: "&laquo; previous"
next_label: "next &raquo;"
......@@ -119,6 +119,12 @@ Rails.application.routes.draw do
get "getting_started_completed" => :getting_started_completed
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
devise_for :users, controllers: {sessions: :sessions}, skip: :registration
devise_scope :user do
get "/users/sign_up" => "registrations#new", :as => :new_user_registration
# 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
# frozen_string_literal: true
class AddTwoFactorBackupableToUser < ActiveRecord::Migration[5.1]
def change
add_column :users, :otp_backup_codes, :text
......@@ -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
# frozen_string_literal: true
Feature: Two-factor autentication
Scenario: Activate 2fa
Given a user with email ""
When I sign in as ""
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 ""
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"