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
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
......
......@@ -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)
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)
......
.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
# 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
......
- 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,
......
.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"
.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"
.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"
.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"
.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"
- 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"
- 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"
- 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: "&laquo; previous"
next_label: "next &raquo;"
......
......@@ -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
......
# 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
# 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
......
# 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
......
# 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