diff --git a/.gitignore b/.gitignore index 65d3c2649..48f5537ad 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Gemfile b/Gemfile index 0e444ee31..f20886f8c 100644 --- a/Gemfile +++ b/Gemfile @@ -28,7 +28,9 @@ gem "yajl-ruby", "1.4.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 diff --git a/Gemfile.lock b/Gemfile.lock index 12da75e2c..0780e7b3f 100644 --- a/Gemfile.lock +++ b/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 @@ -173,6 +175,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) @@ -195,6 +203,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) @@ -603,6 +612,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) @@ -798,6 +810,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) @@ -891,6 +904,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) diff --git a/app/assets/stylesheets/login.scss b/app/assets/stylesheets/login.scss index b0336a8e0..bdb62a343 100644 --- a/app/assets/stylesheets/login.scss +++ b/app/assets/stylesheets/login.scss @@ -1,4 +1,5 @@ .page-sessions.action-new, +.page-sessions.action-create, .page-passwords.action-new, .page-passwords.action-edit { padding-top: 25px; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 89411fb4e..86d763887 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 0f3bd1c46..9d483a0cc 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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 diff --git a/app/controllers/two_factor_authentications_controller.rb b/app/controllers/two_factor_authentications_controller.rb new file mode 100644 index 000000000..e5350b38e --- /dev/null +++ b/app/controllers/two_factor_authentications_controller.rb @@ -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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 1f10ebfc2..726f46f2e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 213d034b6..36cce9bce 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 069b0ac77..0f9336eca 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/sessions/two_factor.html.haml b/app/views/sessions/two_factor.html.haml new file mode 100644 index 000000000..8e40dc411 --- /dev/null +++ b/app/views/sessions/two_factor.html.haml @@ -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) diff --git a/app/views/shared/_settings_nav.haml b/app/views/shared/_settings_nav.haml index e54f3e424..c5e5a3d6c 100644 --- a/app/views/shared/_settings_nav.haml +++ b/app/views/shared/_settings_nav.haml @@ -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, diff --git a/app/views/two_factor_authentications/_activate.haml b/app/views/two_factor_authentications/_activate.haml new file mode 100644 index 000000000..ef6b0a7ff --- /dev/null +++ b/app/views/two_factor_authentications/_activate.haml @@ -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" diff --git a/app/views/two_factor_authentications/_confirm.haml b/app/views/two_factor_authentications/_confirm.haml new file mode 100644 index 000000000..b2ec2af90 --- /dev/null +++ b/app/views/two_factor_authentications/_confirm.haml @@ -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" diff --git a/app/views/two_factor_authentications/_deactivate.haml b/app/views/two_factor_authentications/_deactivate.haml new file mode 100644 index 000000000..c982265de --- /dev/null +++ b/app/views/two_factor_authentications/_deactivate.haml @@ -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" diff --git a/app/views/two_factor_authentications/_recovery.haml b/app/views/two_factor_authentications/_recovery.haml new file mode 100644 index 000000000..3f192e7d3 --- /dev/null +++ b/app/views/two_factor_authentications/_recovery.haml @@ -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" diff --git a/app/views/two_factor_authentications/_token_form.haml b/app/views/two_factor_authentications/_token_form.haml new file mode 100644 index 000000000..616cd7a67 --- /dev/null +++ b/app/views/two_factor_authentications/_token_form.haml @@ -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" diff --git a/app/views/two_factor_authentications/confirm_2fa.html.haml b/app/views/two_factor_authentications/confirm_2fa.html.haml new file mode 100644 index 000000000..36a8dcb08 --- /dev/null +++ b/app/views/two_factor_authentications/confirm_2fa.html.haml @@ -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" diff --git a/app/views/two_factor_authentications/recovery_codes.html.haml b/app/views/two_factor_authentications/recovery_codes.html.haml new file mode 100644 index 000000000..98b4490d5 --- /dev/null +++ b/app/views/two_factor_authentications/recovery_codes.html.haml @@ -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" diff --git a/app/views/two_factor_authentications/show.html.haml b/app/views/two_factor_authentications/show.html.haml new file mode 100644 index 000000000..a68902ac9 --- /dev/null +++ b/app/views/two_factor_authentications/show.html.haml @@ -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" diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index bdd334b26..38a1eafad 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -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 diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 2a915d470..791647716 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -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] diff --git a/config/locales/diaspora/en.yml b/config/locales/diaspora/en.yml index 88ee05b20..007c05efc 100644 --- a/config/locales/diaspora/en.yml +++ b/config/locales/diaspora/en.yml @@ -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.
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 »" diff --git a/config/routes.rb b/config/routes.rb index 7f1e36102..4115bf5ba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20171229175654_add_devise_two_factor_to_users.rb b/db/migrate/20171229175654_add_devise_two_factor_to_users.rb new file mode 100644 index 000000000..f66a4e77e --- /dev/null +++ b/db/migrate/20171229175654_add_devise_two_factor_to_users.rb @@ -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 diff --git a/db/migrate/20180302105225_add_two_factor_backupable_to_user.rb b/db/migrate/20180302105225_add_two_factor_backupable_to_user.rb new file mode 100644 index 000000000..f9b6c0b5e --- /dev/null +++ b/db/migrate/20180302105225_add_two_factor_backupable_to_user.rb @@ -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 diff --git a/features/desktop/change_password.feature b/features/desktop/change_password.feature index f37ebe045..7b30014ec 100644 --- a/features/desktop/change_password.feature +++ b/features/desktop/change_password.feature @@ -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 diff --git a/features/desktop/two_factor_authentication.feature b/features/desktop/two_factor_authentication.feature new file mode 100644 index 000000000..ae8e2d2ef --- /dev/null +++ b/features/desktop/two_factor_authentication.feature @@ -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" diff --git a/features/mobile/change_password.feature b/features/mobile/change_password.feature index f30edb215..f3bcdb445 100644 --- a/features/mobile/change_password.feature +++ b/features/mobile/change_password.feature @@ -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 diff --git a/features/step_definitions/two_factor_steps.rb b/features/step_definitions/two_factor_steps.rb new file mode 100644 index 000000000..7b5ab2319 --- /dev/null +++ b/features/step_definitions/two_factor_steps.rb @@ -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 diff --git a/features/support/paths.rb b/features/support/paths.rb index a486ef245..7962fe17d 100644 --- a/features/support/paths.rb +++ b/features/support/paths.rb @@ -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 diff --git a/lib/configuration_methods.rb b/lib/configuration_methods.rb index 511e8e2d2..487b70672 100644 --- a/lib/configuration_methods.rb +++ b/lib/configuration_methods.rb @@ -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 diff --git a/lib/tasks/generate_2fa_encription_key.rake b/lib/tasks/generate_2fa_encription_key.rake new file mode 100644 index 000000000..53572f51f --- /dev/null +++ b/lib/tasks/generate_2fa_encription_key.rake @@ -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 diff --git a/spec/controllers/two_factor_authentications_controller_spec.rb b/spec/controllers/two_factor_authentications_controller_spec.rb new file mode 100644 index 000000000..585061385 --- /dev/null +++ b/spec/controllers/two_factor_authentications_controller_spec.rb @@ -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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d264493a8..13aa6fae8 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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