From c2f9d48a5acd7a014779ffd443c556d53fb43263 Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Thu, 27 Aug 2020 00:45:01 +0900 Subject: [PATCH 3/4] Backup codes for 2fa auth (#1237). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch by Felix Schäfer. --- app/controllers/account_controller.rb | 3 + .../twofa_backup_codes_controller.rb | 79 +++++++++++++++++++ app/controllers/twofa_controller.rb | 8 +- app/helpers/twofa_helper.rb | 24 ++++++ app/models/token.rb | 1 + app/views/my/account.html.erb | 1 + app/views/twofa/_twofa_code_form.html.erb | 9 +++ app/views/twofa/deactivate_confirm.html.erb | 11 +-- app/views/twofa_backup_codes/confirm.html.erb | 15 ++++ app/views/twofa_backup_codes/show.html.erb | 17 ++++ config/locales/de.yml | 12 ++- config/locales/en.yml | 12 ++- config/routes.rb | 4 + lib/redmine/twofa/base.rb | 50 +++++++++++- public/stylesheets/application.css | 3 + 15 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 app/controllers/twofa_backup_codes_controller.rb create mode 100644 app/helpers/twofa_helper.rb create mode 100644 app/views/twofa/_twofa_code_form.html.erb create mode 100644 app/views/twofa_backup_codes/confirm.html.erb create mode 100644 app/views/twofa_backup_codes/show.html.erb diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb index 56f29e30c..cc7ff02bb 100644 --- a/app/controllers/account_controller.rb +++ b/app/controllers/account_controller.rb @@ -265,6 +265,9 @@ class AccountController < ApplicationController # set locale for the twofa user set_localization(@user) + # set the requesting IP of the twofa user (e.g. for security notifications) + @user.remote_ip = request.remote_ip + @twofa = Redmine::Twofa.for_user(@user) end diff --git a/app/controllers/twofa_backup_codes_controller.rb b/app/controllers/twofa_backup_codes_controller.rb new file mode 100644 index 000000000..364dafec4 --- /dev/null +++ b/app/controllers/twofa_backup_codes_controller.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006-2020 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +class TwofaBackupCodesController < ApplicationController + include TwofaHelper + + self.main_menu = false + + before_action :require_login, :require_active_twofa + + before_action :twofa_setup + + require_sudo_mode :init + + def init + if @twofa.send_code(controller: 'twofa_backup_codes', action: 'create') + flash[:notice] = l('twofa_code_sent') + end + redirect_to action: 'confirm' + end + + def confirm + @twofa_view = @twofa.otp_confirm_view_variables + end + + def create + if @twofa.verify!(params[:twofa_code].to_s) + if time = @twofa.backup_codes.map(&:created_on).max + flash[:warning] = t('twofa_warning_backup_codes_generated_invalidated', time: format_time(time)) + else + flash[:notice] = t('twofa_notice_backup_codes_generated') + end + tokens = @twofa.init_backup_codes! + flash[:twofa_backup_token_ids] = tokens.collect(&:id) + redirect_to action: 'show' + else + flash[:error] = l('twofa_invalid_code') + redirect_to action: 'confirm' + end + end + + def show + # make sure we get only the codes that we should show + tokens = @twofa.backup_codes.where(id: flash[:twofa_backup_token_ids]) + # Redmine will show all flash contents at the top of the rendered html + # page, so we need to explicitely delete this here + flash.delete(:twofa_backup_token_ids) + + if tokens.present? && (@created_at = tokens.collect(&:created_on).max) > 5.minutes.ago + @backup_codes = tokens.collect(&:value) + else + flash[:warning] = l('twofa_backup_codes_already_shown', bc_path: my_twofa_backup_codes_init_path) + redirect_to controller: 'my', action: 'account' + end + end + + private + + def twofa_setup + @user = User.current + @twofa = Redmine::Twofa.for_user(@user) + end +end diff --git a/app/controllers/twofa_controller.rb b/app/controllers/twofa_controller.rb index 093b61cc8..ccd9ca5d8 100644 --- a/app/controllers/twofa_controller.rb +++ b/app/controllers/twofa_controller.rb @@ -18,6 +18,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. class TwofaController < ApplicationController + include TwofaHelper + self.main_menu = false before_action :require_login @@ -45,7 +47,7 @@ class TwofaController < ApplicationController def activate if @twofa.confirm_pairing!(params[:twofa_code].to_s) - flash[:notice] = l('twofa_activated') + flash[:notice] = l('twofa_activated', bc_path: my_twofa_backup_codes_init_path) redirect_to my_account_path else flash[:error] = l('twofa_invalid_code') @@ -107,8 +109,4 @@ class TwofaController < ApplicationController redirect_to my_account_path end end - - def require_active_twofa - Setting.twofa? ? true : deny_access - end end diff --git a/app/helpers/twofa_helper.rb b/app/helpers/twofa_helper.rb new file mode 100644 index 000000000..c7ede1ceb --- /dev/null +++ b/app/helpers/twofa_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006-2020 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +module TwofaHelper + def require_active_twofa + Setting.twofa? ? true : deny_access + end +end diff --git a/app/models/token.rb b/app/models/token.rb index 6beed1014..e34d6b65e 100644 --- a/app/models/token.rb +++ b/app/models/token.rb @@ -42,6 +42,7 @@ class Token < ActiveRecord::Base add_action :recovery, max_instances: 1, validity_time: Proc.new { Token.validity_time } add_action :register, max_instances: 1, validity_time: Proc.new { Token.validity_time } add_action :session, max_instances: 10, validity_time: nil + add_action :twofa_backup_code, max_instances: 10, validity_time: nil def generate_new_token self.value = Token.generate_token_value diff --git a/app/views/my/account.html.erb b/app/views/my/account.html.erb index 996bead61..c54183a8c 100644 --- a/app/views/my/account.html.erb +++ b/app/views/my/account.html.erb @@ -34,6 +34,7 @@ <% if @user.twofa_active? %> <%=l 'twofa_currently_active', twofa_scheme_name: l("twofa__#{@user.twofa_scheme}__name") -%>
<%= link_to l('button_disable'), { controller: 'twofa', action: 'deactivate_init', scheme: @user.twofa_scheme }, method: :post -%>
+ <%= link_to l('twofa_generate_backup_codes'), { controller: 'twofa_backup_codes', action: 'init' }, method: :post, data: { confirm: Redmine::Twofa.for_user(User.current).backup_codes.any? ? t('twofa_text_generate_backup_codes_confirmation') : nil } -%> <% else %> <% Redmine::Twofa.available_schemes.each do |s| %> <%= link_to l("twofa__#{s}__label_activate"), { controller: 'twofa', action: 'activate_init', scheme: s }, method: :post -%>
diff --git a/app/views/twofa/_twofa_code_form.html.erb b/app/views/twofa/_twofa_code_form.html.erb new file mode 100644 index 000000000..b9d0e1bf7 --- /dev/null +++ b/app/views/twofa/_twofa_code_form.html.erb @@ -0,0 +1,9 @@ +
+

<%=l 'twofa_label_enter_otp' %>

+
+

+ + <%= text_field_tag :twofa_code, nil, autocomplete: 'off' -%> +

+
+
diff --git a/app/views/twofa/deactivate_confirm.html.erb b/app/views/twofa/deactivate_confirm.html.erb index f2ecb0d07..a515143ad 100644 --- a/app/views/twofa/deactivate_confirm.html.erb +++ b/app/views/twofa/deactivate_confirm.html.erb @@ -5,16 +5,7 @@ scheme: @twofa_view[:scheme_name] }, { method: :post, id: 'twofa_form' }) do -%> -
- -

<%=l 'twofa_label_enter_otp' %>

-
-

- - <%= text_field_tag :twofa_code, nil, autocomplete: 'off' -%> -

-
-
+ <%= render partial: 'twofa_code_form' -%> <%= submit_tag l('button_disable'), name: :submit_otp -%> <%= link_to l('twofa_resend_code'), { action: 'deactivate_init', scheme: @twofa_view[:scheme_name] }, method: :post if @twofa_view[:resendable] -%> <% end %> diff --git a/app/views/twofa_backup_codes/confirm.html.erb b/app/views/twofa_backup_codes/confirm.html.erb new file mode 100644 index 000000000..34e33d455 --- /dev/null +++ b/app/views/twofa_backup_codes/confirm.html.erb @@ -0,0 +1,15 @@ +

<%=l 'twofa_generate_backup_codes' -%>

+ +
+ <%= form_tag({ action: :create }, + { method: :post, + id: 'twofa_form' }) do -%> + <%= render partial: 'twofa/twofa_code_form' -%> + <%= submit_tag l('button_submit'), name: :submit_otp -%> + <%= link_to l('twofa_resend_code'), { action: 'init' }, method: :post if @twofa_view[:resendable] -%> + <% end %> +
+ +<% content_for :sidebar do %> +<%= render :partial => 'my/sidebar' %> +<% end %> diff --git a/app/views/twofa_backup_codes/show.html.erb b/app/views/twofa_backup_codes/show.html.erb new file mode 100644 index 000000000..50b9948f8 --- /dev/null +++ b/app/views/twofa_backup_codes/show.html.erb @@ -0,0 +1,17 @@ +

<%=l 'twofa_label_backup_codes' -%>

+ +
+
+

<%=l 'twofa_text_backup_codes_hint' -%>

+
    + <% @backup_codes.each do |code| -%> +
  • <%= code.scan(/.{4}/).join(' ') -%>
  • + <% end -%> +
+

<%=l 'twofa_text_backup_codes_created_at', datetime: format_time(@created_at) -%>

+
+
+ +<% content_for :sidebar do %> +<%= render :partial => 'my/sidebar' %> +<% end %> diff --git a/config/locales/de.yml b/config/locales/de.yml index b588ac2eb..6d49d91f7 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1337,12 +1337,22 @@ de: twofa_label_deactivation_confirmation: Zwei-Faktor-Authentifizierung abschalten twofa_notice_select: "Bitte wählen Sie Ihr gewünschtes Schema für die Zwei-Faktor-Authentifizierung:" twofa_warning_require: Der Administrator fordert Sie dazu auf Zwei-Faktor-Authentifizierung einzurichten. - twofa_activated: Zwei-Faktor-Authentifizierung erfolgreich eingerichtet. + twofa_activated: Zwei-Faktor-Authentifizierung erfolgreich eingerichtet. Es ist empfohlen hierzu Backup-Codes zu generieren. twofa_deactivated: Zwei-Faktor-Authentifizierung abgeschaltet. twofa_mail_body_security_notification_paired: "Zwei-Faktor-Authentifizierung per %{field} eingerichtet." twofa_mail_body_security_notification_unpaired: "Zwei-Faktor-Authentifizierung für Ihr Konto abgeschaltet." + twofa_mail_body_backup_codes_generated: "Neue Backup-Codes für Zwei-Faktor-Authentifizierung generiert." + twofa_mail_body_backup_code_used: "Ein Backup-Code für Zwei-Faktor-Authentifizierung ist verwendet worden." twofa_invalid_code: Der eingegebene Code ist ungültig oder abgelaufen. twofa_label_enter_otp: Bitte geben Sie Ihren Code für die Zwei-Faktor-Authentifizierung ein. twofa_too_many_tries: Zu viele Versuche. twofa_resend_code: Code erneut senden twofa_code_sent: Ein Code für die Zwei-Faktor-Authentifizierung wurde Ihnen zugesendet. + twofa_generate_backup_codes: Backup-Codes generieren + twofa_text_generate_backup_codes_confirmation: Im nächsten Schritt werden alle bestehenden Backup-Codes ungültig gemacht und neue generiert. Möchten Sie fortfahren? + twofa_notice_backup_codes_generated: Ihre Backup-Codes wurden generiert. + twofa_warning_backup_codes_generated_invalidated: Es wurden neue Backup-Codes generiert. Die bestehenden Codes vom %{time} sind nicht mehr gültig. + twofa_label_backup_codes: Zwei-Faktor-Authentifizierung Backup-Codes + twofa_text_backup_codes_hint: Sie können einen dieser Codes benutzen wenn Sie vorübergehend keinen Zugriff auf Ihren zweiten Faktor haben. Jeder Code kann nur ein Mal verwendet werden. Es wird empfohlen, diese Codes auszudrucken und sie an einem sicheren Ort zu verwahren. + twofa_text_backup_codes_created_at: Backup-Codes generiert am %{datetime}. + twofa_backup_codes_already_shown: Aus Sicherheitsgründen können Backup-Codes nicht erneut angezeigt werden. Bitte generieren Sie neue Codes falls nötig. diff --git a/config/locales/en.yml b/config/locales/en.yml index ee2196501..7e7def09a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1315,12 +1315,22 @@ en: twofa_label_deactivation_confirmation: Disable two-factor authentication twofa_notice_select: "Please select the two-factor scheme you would like to use:" twofa_warning_require: The administrator requires you to enable two-factor authentication. - twofa_activated: Two-factor authentication successfully enabled. + twofa_activated: Two-factor authentication successfully enabled. It is recommended to generate backup codes for your account. twofa_deactivated: Two-factor authentication disabled. twofa_mail_body_security_notification_paired: "Two-factor authentication successfully enabled using %{field}." twofa_mail_body_security_notification_unpaired: "Two-factor authentication disabled for your account." + twofa_mail_body_backup_codes_generated: "New two-factor authentication backup codes generated." + twofa_mail_body_backup_code_used: "A two-factor authentication backup code has been used." twofa_invalid_code: Code is invalid or outdated. twofa_label_enter_otp: Please enter your two-factor authentication code. twofa_too_many_tries: Too many tries. twofa_resend_code: Resend code twofa_code_sent: An authentication code has been sent to you. + twofa_generate_backup_codes: Generate backup codes + twofa_text_generate_backup_codes_confirmation: This will invalidate all existing backup codes and generate new ones. Would you like to continue? + twofa_notice_backup_codes_generated: Your backup codes have been generated. + twofa_warning_backup_codes_generated_invalidated: New backup codes have been generated. Your existing codes from %{time} are now invalid. + twofa_label_backup_codes: Two-factor authentication backup codes + twofa_text_backup_codes_hint: Use these codes instead of a one-time password should you not have access to your second factor. Each code can only be used once. It is recommended to print and store them in a safe place. + twofa_text_backup_codes_created_at: Backup codes generated %{datetime}. + twofa_backup_codes_already_shown: Backup codes cannot be shown again, please generate new backup codes if required. diff --git a/config/routes.rb b/config/routes.rb index 97ecf2913..5884aa49f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -96,6 +96,10 @@ Rails.application.routes.draw do match 'my/twofa/:scheme/deactivate/confirm', :controller => 'twofa', :action => 'deactivate_confirm', :via => :get match 'my/twofa/:scheme/deactivate', :controller => 'twofa', :action => 'deactivate', :via => [:get, :post] match 'my/twofa/select_scheme', :controller => 'twofa', :action => 'select_scheme', :via => :get + match 'my/twofa/backup_codes/init', :controller => 'twofa_backup_codes', :action => 'init', :via => :post + match 'my/twofa/backup_codes/confirm', :controller => 'twofa_backup_codes', :action => 'confirm', :via => :get + match 'my/twofa/backup_codes/create', :controller => 'twofa_backup_codes', :action => 'create', :via => [:get, :post] + match 'my/twofa/backup_codes', :controller => 'twofa_backup_codes', :action => 'show', :via => [:get] match 'users/:user_id/twofa/deactivate', :controller => 'twofa', :action => 'admin_deactivate', :via => :post resources :users do diff --git a/lib/redmine/twofa/base.rb b/lib/redmine/twofa/base.rb index 8369c8f6a..e959aa930 100644 --- a/lib/redmine/twofa/base.rb +++ b/lib/redmine/twofa/base.rb @@ -42,7 +42,7 @@ module Redmine end def confirm_pairing!(code) - # make sure an otp is used + # make sure an otp and not a backup code is used if verify_otp!(code) @user.update!(twofa_scheme: scheme_name) deliver_twofa_paired @@ -77,6 +77,7 @@ module Redmine def destroy_pairing_without_verify! @user.update!(twofa_scheme: nil) + backup_codes.delete_all deliver_twofa_unpaired end @@ -98,13 +99,58 @@ module Redmine end def verify!(code) - verify_otp!(code) + verify_otp!(code) || verify_backup_code!(code) end def verify_otp!(code) raise 'not implemented' end + def verify_backup_code!(code) + # backup codes are case-insensitive and white-space-insensitive + code = code.to_s.remove(/[[:space:]]/).downcase + user_from_code = Token.find_active_user('twofa_backup_code', code) + # invalidate backup code after usage + Token.where(user_id: @user.id).find_token('twofa_backup_code', code).try(:delete) + # make sure the user using the backup code is the same it's been issued to + return false unless @user.present? && @user == user_from_code + Mailer.security_notification( + @user, + User.current, + { + originator: @user, + title: :label_my_account, + message: 'twofa_mail_body_backup_code_used', + url: { controller: 'my', action: 'account' } + } + ).deliver + return true + end + + def init_backup_codes! + backup_codes.delete_all + tokens = [] + 10.times do + token = Token.create(user_id: @user.id, action: 'twofa_backup_code') + token.update_columns value: Redmine::Utils.random_hex(6) + tokens << token + end + Mailer.security_notification( + @user, + User.current, + { + title: :label_my_account, + message: 'twofa_mail_body_backup_codes_generated', + url: { controller: 'my', action: 'account' } + } + ).deliver + tokens + end + + def backup_codes + Token.where(user_id: @user.id, action: 'twofa_backup_code') + end + # this will only be used on pairing initialization def init_pairing_view_variables otp_confirm_view_variables diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index f7aa80c35..891faf69a 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -795,6 +795,9 @@ html>body .tabular p {overflow:hidden;} .tabular input, .tabular select {max-width:95%} .tabular textarea {width:95%; resize:vertical;} input#twofa_code, img#twofa_code { width: 140px; } +ul.twofa_backup_codes { list-style-type: none; padding: 0; display: inline-block; } +ul.twofa_backup_codes li { float: left; } +ul.twofa_backup_codes li:nth-child(odd) { float: left; clear: left; padding-right: 4em; } .tabular label{ font-weight: bold; -- 2.26.2