From 6ff562cfea90c3f25bae2978f2ca91373ce07488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Sch=C3=A4fer?= Date: Wed, 6 Dec 2017 00:25:08 +0100 Subject: [PATCH 3/3] Backup codes for 2-factor authentication --- app/controllers/account_controller.rb | 3 ++ app/controllers/twofa_backup_codes_controller.rb | 60 ++++++++++++++++++++++++ app/controllers/twofa_controller.rb | 8 ++-- app/helpers/twofa_helper.rb | 5 ++ 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 | 48 ++++++++++++++++++- public/stylesheets/application.css | 3 ++ 15 files changed, 190 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 604dc5669..e90940d25 100644 --- a/app/controllers/account_controller.rb +++ b/app/controllers/account_controller.rb @@ -257,6 +257,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..17dad1e92 --- /dev/null +++ b/app/controllers/twofa_backup_codes_controller.rb @@ -0,0 +1,60 @@ +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 b18157735..49d66c0b5 100644 --- a/app/controllers/twofa_controller.rb +++ b/app/controllers/twofa_controller.rb @@ -1,4 +1,6 @@ class TwofaController < ApplicationController + include TwofaHelper + self.main_menu = false before_action :require_login @@ -26,7 +28,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') @@ -88,8 +90,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..ad08fe048 --- /dev/null +++ b/app/helpers/twofa_helper.rb @@ -0,0 +1,5 @@ +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 5990056f5..aabd6763f 100644 --- a/app/models/token.rb +++ b/app/models/token.rb @@ -40,6 +40,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 f12ce5eb3..a2da6572a 100644 --- a/app/views/my/account.html.erb +++ b/app/views/my/account.html.erb @@ -33,6 +33,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 f377e18fa..7e9ab9a0b 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1251,12 +1251,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 5337107f4..e24f92359 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1233,12 +1233,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 c33800a76..05d5f3154 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -93,6 +93,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 12628ec19..1c016e722 100644 --- a/lib/redmine/twofa/base.rb +++ b/lib/redmine/twofa/base.rb @@ -23,7 +23,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 @@ -57,6 +57,7 @@ module Redmine def destroy_pairing_without_verify! @user.update!(twofa_scheme: nil) + backup_codes.delete_all deliver_twofa_unpaired end @@ -77,13 +78,56 @@ 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, + { + 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, + { + 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 4ccd04391..393a005ec 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -672,6 +672,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.15.1