summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app/controllers/account_controller.rb3
-rw-r--r--app/controllers/twofa_backup_codes_controller.rb79
-rw-r--r--app/controllers/twofa_controller.rb8
-rw-r--r--app/helpers/twofa_helper.rb24
-rw-r--r--app/models/token.rb1
-rw-r--r--app/views/my/account.html.erb1
-rw-r--r--app/views/twofa/_twofa_code_form.html.erb9
-rw-r--r--app/views/twofa/deactivate_confirm.html.erb11
-rw-r--r--app/views/twofa_backup_codes/confirm.html.erb15
-rw-r--r--app/views/twofa_backup_codes/show.html.erb17
-rw-r--r--config/locales/de.yml12
-rw-r--r--config/locales/en.yml12
-rw-r--r--config/routes.rb4
-rw-r--r--lib/redmine/twofa/base.rb50
-rw-r--r--public/stylesheets/application.css3
15 files changed, 230 insertions, 19 deletions
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 8bbdb8056..a43663496 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')
@@ -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
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") -%><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
index 000000000..b9d0e1bf7
--- /dev/null
+++ b/app/views/twofa/_twofa_code_form.html.erb
@@ -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>
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 -%>
- <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
index 000000000..34e33d455
--- /dev/null
+++ b/app/views/twofa_backup_codes/confirm.html.erb
@@ -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
index 000000000..50b9948f8
--- /dev/null
+++ b/app/views/twofa_backup_codes/show.html.erb
@@ -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 %>
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 <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.
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 <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.
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;