]> source.dussan.org Git - redmine.git/commitdiff
Backup codes for 2fa auth (#1237).
authorGo MAEDA <maeda@farend.jp>
Sat, 29 Aug 2020 06:51:21 +0000 (06:51 +0000)
committerGo MAEDA <maeda@farend.jp>
Sat, 29 Aug 2020 06:51:21 +0000 (06:51 +0000)
Patch by Felix Schäfer.

git-svn-id: http://svn.redmine.org/redmine/trunk@19990 e93f8b46-1217-0410-a6f0-8f06a7374b81

15 files changed:
app/controllers/account_controller.rb
app/controllers/twofa_backup_codes_controller.rb [new file with mode: 0644]
app/controllers/twofa_controller.rb
app/helpers/twofa_helper.rb [new file with mode: 0644]
app/models/token.rb
app/views/my/account.html.erb
app/views/twofa/_twofa_code_form.html.erb [new file with mode: 0644]
app/views/twofa/deactivate_confirm.html.erb
app/views/twofa_backup_codes/confirm.html.erb [new file with mode: 0644]
app/views/twofa_backup_codes/show.html.erb [new file with mode: 0644]
config/locales/de.yml
config/locales/en.yml
config/routes.rb
lib/redmine/twofa/base.rb
public/stylesheets/application.css

index 56f29e30cd585c7b722bc38a63b3b3d98f2b2ac1..cc7ff02bb2154e7aebe07fcf856aace9f56d4d1f 100644 (file)
@@ -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 (file)
index 0000000..364dafe
--- /dev/null
@@ -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
index 8bbdb80566c730ed93a1f2e27731cbca2cd11d03..a436634967626f7f60e7c21e6440ac38d99603e7 100644 (file)
@@ -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')
@@ -110,8 +112,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 (file)
index 0000000..c7ede1c
--- /dev/null
@@ -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
index 6beed101439e3342af232528667f69b5b41645d1..e34d6b65e862063575f974e90f90fefb901b7a63 100644 (file)
@@ -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
index 996bead61705c21658d3cbbb25490ba11bf3c0a3..c54183a8cbeb3ff84ac81bfd84203a36041d200d 100644 (file)
@@ -34,6 +34,7 @@
     <% if @user.twofa_active? %>
       <%=l 'twofa_currently_active', twofa_scheme_name: l("twofa__#{@user.twofa_scheme}__name") -%><br/>
       <%= link_to l('button_disable'), { controller: 'twofa', action: 'deactivate_init', scheme: @user.twofa_scheme }, method: :post -%><br/>
+      <%= 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 -%><br/>
diff --git a/app/views/twofa/_twofa_code_form.html.erb b/app/views/twofa/_twofa_code_form.html.erb
new file mode 100644 (file)
index 0000000..b9d0e1b
--- /dev/null
@@ -0,0 +1,9 @@
+<div class="box">
+  <p><%=l 'twofa_label_enter_otp' %></p>
+  <div class="tabular">
+    <p>
+      <label for="twofa_code"><%=l 'twofa_label_code' -%></label>
+      <%= text_field_tag :twofa_code, nil, autocomplete: 'off' -%>
+    </p>
+  </div>
+</div>
index f2ecb0d070f050aea8a7c996604a2ed76565266b..a515143adf478abf18e0a90cf3ecab3086462760 100644 (file)
@@ -5,16 +5,7 @@
                  scheme: @twofa_view[:scheme_name] },
                { method: :post,
                  id: 'twofa_form' }) do -%>
-    <div class="box">
-
-      <p><%=l 'twofa_label_enter_otp' %></p>
-      <div class="tabular">
-        <p>
-          <label for="twofa_code"><%=l 'twofa_label_code' -%></label>
-          <%= text_field_tag :twofa_code, nil, autocomplete: 'off' -%>
-        </p>
-      </div>
-    </div>
+    <%= 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 (file)
index 0000000..34e33d4
--- /dev/null
@@ -0,0 +1,15 @@
+<h2><%=l 'twofa_generate_backup_codes' -%></h2>
+
+<div class="splitcontentleft">
+  <%= 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 %>
+</div>
+
+<% 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 (file)
index 0000000..50b9948
--- /dev/null
@@ -0,0 +1,17 @@
+<h2><%=l 'twofa_label_backup_codes' -%></h2>
+
+<div class="splitcontentleft">
+  <div class="box">
+    <p><%=l 'twofa_text_backup_codes_hint' -%></p>
+    <ul class="twofa_backup_codes">
+    <% @backup_codes.each do |code| -%>
+      <li><code><%= code.scan(/.{4}/).join(' ') -%></code></li>
+    <% end -%>
+    </ul>
+    <p><em class="info"><%=l 'twofa_text_backup_codes_created_at', datetime: format_time(@created_at) -%></em></p>
+  </div>
+</div>
+
+<% content_for :sidebar do %>
+<%= render :partial => 'my/sidebar' %>
+<% end %>
index b588ac2ebd53bd2f9e853cb959f4821dee3c5a06..6d49d91f757824d36b575cb6b29be9df3b85c5d2 100644 (file)
@@ -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 <a data-method="post" href="%{bc_path}">Backup-Codes zu generieren</a>.
   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 <a data-method="post" href="%{bc_path}">generieren Sie neue Codes</a> falls nötig.
index ee21965016bef36771b955d08ad3d57ce85f2d32..7e7def09afca7c8193a4564b11efc8418b1b9783 100644 (file)
@@ -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 <a data-method="post" href="%{bc_path}">generate backup codes</a> 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 <a data-method="post" href="%{bc_path}">generate new backup codes</a> if required.
index 97ecf2913ef160a6bfd3424c68a35795f5caa5a0..5884aa49f4aec318417eededbba63aea9ffd26aa 100644 (file)
@@ -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
index 8369c8f6a19d64c2602915080f54a4554fa1e51f..e959aa930b9936afecf7cf400e8411dfe6d87b73 100644 (file)
@@ -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
index f7aa80c3594352e230e03824dd9dd540746b5dec..891faf69afa561007fb234473252448e4eddc7d0 100644 (file)
@@ -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;