summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGo MAEDA <maeda@farend.jp>2020-08-29 06:21:50 +0000
committerGo MAEDA <maeda@farend.jp>2020-08-29 06:21:50 +0000
commit560bca344ae467cda03e758159fbf131d5c49f43 (patch)
tree15b4b2ec74c1d98e28f47453093588b271e18865
parent657ddfef452b145bbbce2970369ce42624dfca8e (diff)
downloadredmine-560bca344ae467cda03e758159fbf131d5c49f43.tar.gz
redmine-560bca344ae467cda03e758159fbf131d5c49f43.zip
Adds two factor authentication support (#1237).
Patch by Felix Schäfer. git-svn-id: http://svn.redmine.org/redmine/trunk@19988 e93f8b46-1217-0410-a6f0-8f06a7374b81
-rw-r--r--Gemfile4
-rw-r--r--app/controllers/account_controller.rb107
-rw-r--r--app/controllers/twofa_controller.rb109
-rw-r--r--app/models/user.rb13
-rw-r--r--app/views/account/twofa_confirm.html.erb20
-rw-r--r--app/views/my/_sidebar.html.erb2
-rw-r--r--app/views/my/account.html.erb11
-rw-r--r--app/views/twofa/activate_confirm.html.erb27
-rw-r--r--app/views/twofa/deactivate_confirm.html.erb25
-rw-r--r--app/views/twofa/totp/_new.html.erb8
-rw-r--r--app/views/users/_form.html.erb13
-rw-r--r--config/locales/de.yml20
-rw-r--r--config/locales/en.yml21
-rw-r--r--config/routes.rb10
-rw-r--r--db/migrate/20200826153401_add_twofa_scheme_to_user.rb5
-rw-r--r--db/migrate/20200826153402_add_totp_to_user.rb6
-rw-r--r--lib/redmine.rb1
-rw-r--r--lib/redmine/twofa.rb58
-rw-r--r--lib/redmine/twofa/base.rb127
-rw-r--r--lib/redmine/twofa/totp.rb68
-rw-r--r--public/stylesheets/application.css2
-rw-r--r--public/stylesheets/responsive.css3
22 files changed, 656 insertions, 4 deletions
diff --git a/Gemfile b/Gemfile
index 7facc3064..b06d719f0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -22,6 +22,10 @@ gem 'rubyzip', (RUBY_VERSION < '2.4' ? '~> 1.3.0' : '~> 2.3.0')
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin]
+# TOTP-based 2-factor authentication
+gem 'rotp'
+gem 'rqrcode'
+
# Optional gem for LDAP authentication
group :ldap do
gem "net-ldap", "~> 0.16.0"
diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb
index dd6002de0..56f29e30c 100644
--- a/app/controllers/account_controller.rb
+++ b/app/controllers/account_controller.rb
@@ -204,8 +204,98 @@ class AccountController < ApplicationController
redirect_to(home_url)
end
+ before_action :require_active_twofa, :twofa_setup, only: [:twofa_resend, :twofa_confirm, :twofa]
+ before_action :prevent_twofa_session_replay, only: [:twofa_resend, :twofa]
+
+ def twofa_resend
+ # otp resends count toward the maximum of 3 otp entry tries per password entry
+ if session[:twofa_tries_counter] > 3
+ destroy_twofa_session
+ flash[:error] = l('twofa_too_many_tries')
+ redirect_to home_url
+ else
+ if @twofa.send_code(controller: 'account', action: 'twofa')
+ flash[:notice] = l('twofa_code_sent')
+ end
+ redirect_to account_twofa_confirm_path
+ end
+ end
+
+ def twofa_confirm
+ @twofa_view = @twofa.otp_confirm_view_variables
+ end
+
+ def twofa
+ if @twofa.verify!(params[:twofa_code].to_s)
+ destroy_twofa_session
+ handle_active_user(@user)
+ # allow at most 3 otp entry tries per successfull password entry
+ # this allows using anti brute force techniques on the password entry to also
+ # prevent brute force attacks on the one-time password
+ elsif session[:twofa_tries_counter] > 3
+ destroy_twofa_session
+ flash[:error] = l('twofa_too_many_tries')
+ redirect_to home_url
+ else
+ flash[:error] = l('twofa_invalid_code')
+ redirect_to account_twofa_confirm_path
+ end
+ end
+
private
+ def prevent_twofa_session_replay
+ renew_twofa_session(@user)
+ end
+
+ def twofa_setup
+ # twofa sessions are only valid 2 minutes at a time
+ twomind = 0.0014 # a little more than 2 minutes in days
+ @user = Token.find_active_user('twofa_session', session[:twofa_session_token].to_s, twomind)
+ if @user.blank?
+ destroy_twofa_session
+ redirect_to home_url
+ return
+ end
+
+ # copy back_url, autologin back to params where they are expected
+ params[:back_url] ||= session[:twofa_back_url]
+ params[:autologin] ||= session[:twofa_autologin]
+
+ # set locale for the twofa user
+ set_localization(@user)
+
+ @twofa = Redmine::Twofa.for_user(@user)
+ end
+
+ def require_active_twofa
+ Setting.twofa? ? true : deny_access
+ end
+
+ def setup_twofa_session(user, previous_tries=1)
+ token = Token.create(user: user, action: 'twofa_session')
+ session[:twofa_session_token] = token.value
+ session[:twofa_tries_counter] = previous_tries
+ session[:twofa_back_url] = params[:back_url]
+ session[:twofa_autologin] = params[:autologin]
+ end
+
+ # Prevent replay attacks by using each twofa_session_token only for exactly one request
+ def renew_twofa_session(user)
+ twofa_tries = session[:twofa_tries_counter].to_i + 1
+ destroy_twofa_session
+ setup_twofa_session(user, twofa_tries)
+ end
+
+ def destroy_twofa_session
+ # make sure tokens can only be used once server-side to prevent replay attacks
+ Token.find_token('twofa_session', session[:twofa_session_token].to_s).try(:delete)
+ session[:twofa_session_token] = nil
+ session[:twofa_tries_counter] = nil
+ session[:twofa_back_url] = nil
+ session[:twofa_autologin] = nil
+ end
+
def authenticate_user
if Setting.openid? && using_open_id?
open_id_authenticate(params[:openid_url])
@@ -224,14 +314,27 @@ class AccountController < ApplicationController
else
# Valid user
if user.active?
- successful_authentication(user)
- update_sudo_timestamp! # activate Sudo Mode
+ if user.twofa_active?
+ setup_twofa_session user
+ twofa = Redmine::Twofa.for_user(user)
+ if twofa.send_code(controller: 'account', action: 'twofa')
+ flash[:notice] = l('twofa_code_sent')
+ end
+ redirect_to account_twofa_confirm_path
+ else
+ handle_active_user(user)
+ end
else
handle_inactive_user(user)
end
end
end
+ def handle_active_user(user)
+ successful_authentication(user)
+ update_sudo_timestamp! # activate Sudo Mode
+ end
+
def open_id_authenticate(openid_url)
back_url = signin_url(:autologin => params[:autologin])
authenticate_with_open_id(
diff --git a/app/controllers/twofa_controller.rb b/app/controllers/twofa_controller.rb
new file mode 100644
index 000000000..4cdeeee4f
--- /dev/null
+++ b/app/controllers/twofa_controller.rb
@@ -0,0 +1,109 @@
+# 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 TwofaController < ApplicationController
+ self.main_menu = false
+
+ before_action :require_login
+ before_action :require_admin, only: :admin_deactivate
+
+ require_sudo_mode :activate_init, :deactivate_init
+
+ before_action :activate_setup, only: [:activate_init, :activate_confirm, :activate]
+
+ def activate_init
+ @twofa.init_pairing!
+ if @twofa.send_code(controller: 'twofa', action: 'activate')
+ flash[:notice] = l('twofa_code_sent')
+ end
+ redirect_to action: :activate_confirm, scheme: @twofa.scheme_name
+ end
+
+ def activate_confirm
+ @twofa_view = @twofa.init_pairing_view_variables
+ end
+
+ def activate
+ if @twofa.confirm_pairing!(params[:twofa_code].to_s)
+ flash[:notice] = l('twofa_activated')
+ redirect_to my_account_path
+ else
+ flash[:error] = l('twofa_invalid_code')
+ redirect_to action: :activate_confirm, scheme: @twofa.scheme_name
+ end
+ end
+
+ before_action :deactivate_setup, only: [:deactivate_init, :deactivate_confirm, :deactivate]
+
+ def deactivate_init
+ if @twofa.send_code(controller: 'twofa', action: 'deactivate')
+ flash[:notice] = l('twofa_code_sent')
+ end
+ redirect_to action: :deactivate_confirm, scheme: @twofa.scheme_name
+ end
+
+ def deactivate_confirm
+ @twofa_view = @twofa.otp_confirm_view_variables
+ end
+
+ def deactivate
+ if @twofa.destroy_pairing!(params[:twofa_code].to_s)
+ flash[:notice] = l('twofa_deactivated')
+ redirect_to my_account_path
+ else
+ flash[:error] = l('twofa_invalid_code')
+ redirect_to action: :deactivate_confirm, scheme: @twofa.scheme_name
+ end
+ end
+
+ def admin_deactivate
+ @user = User.find(params[:user_id])
+ # do not allow administrators to unpair 2FA without confirmation for themselves
+ if @user == User.current
+ render_403
+ return false
+ end
+
+ twofa = Redmine::Twofa.for_user(@user)
+ twofa.destroy_pairing_without_verify!
+ flash[:notice] = l('twofa_deactivated')
+ redirect_to edit_user_path(@user)
+ end
+
+ private
+
+ def activate_setup
+ twofa_scheme = Redmine::Twofa.for_twofa_scheme(params[:scheme].to_s)
+
+ if twofa_scheme.blank?
+ redirect_to my_account_path
+ return
+ end
+ @user = User.current
+ @twofa = twofa_scheme.new(@user)
+ end
+
+ def deactivate_setup
+ @user = User.current
+ @twofa = Redmine::Twofa.for_user(@user)
+ if params[:scheme].to_s != @twofa.scheme_name
+ redirect_to my_account_path
+ end
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 6c2adc141..5b4089ea4 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -20,6 +20,7 @@
require "digest/sha1"
class User < Principal
+ include Redmine::Ciphering
include Redmine::SafeAttributes
# Different ways of displaying/sorting users
@@ -391,6 +392,10 @@ class User < Principal
self
end
+ def twofa_active?
+ twofa_scheme.present?
+ end
+
def pref
self.preference ||= UserPreference.new(:user => self)
end
@@ -451,6 +456,14 @@ class User < Principal
Token.where(:user_id => id, :action => 'autologin', :value => value).delete_all
end
+ def twofa_totp_key
+ read_ciphered_attribute(:twofa_totp_key)
+ end
+
+ def twofa_totp_key=(key)
+ write_ciphered_attribute(:twofa_totp_key, key)
+ end
+
# Returns true if token is a valid session token for the user whose id is user_id
def self.verify_session_token(user_id, token)
return false if user_id.blank? || token.blank?
diff --git a/app/views/account/twofa_confirm.html.erb b/app/views/account/twofa_confirm.html.erb
new file mode 100644
index 000000000..5cf3b3dda
--- /dev/null
+++ b/app/views/account/twofa_confirm.html.erb
@@ -0,0 +1,20 @@
+<div id="login-form">
+
+ <h3><%=l :setting_twofa %></h3>
+ <p><%=l 'twofa_label_enter_otp' %></p>
+
+ <%= form_tag({ action: 'twofa' },
+ { id: 'twofa_form',
+ onsubmit: 'return keepAnchorOnSignIn(this);' }) do -%>
+
+
+ <label for="twofa_code">
+ <%=l 'twofa_label_code' -%>
+ <%= link_to l('twofa_resend_code'), { controller: 'account', action: 'twofa_resend' }, method: :post, class: 'lost_password' if @twofa_view[:resendable] -%>
+ </label>
+ <%= text_field_tag :twofa_code, nil, tabindex: '1', autocomplete: 'off', autofocus: true -%>
+
+ <%= submit_tag l(:button_login), tabindex: '2', id: 'login-submit', name: :submit_otp -%>
+ <% end %>
+
+</div>
diff --git a/app/views/my/_sidebar.html.erb b/app/views/my/_sidebar.html.erb
index e372425aa..e962538b5 100644
--- a/app/views/my/_sidebar.html.erb
+++ b/app/views/my/_sidebar.html.erb
@@ -4,7 +4,7 @@
<%=l(:field_created_on)%>: <%= format_time(@user.created_on) %></p>
<% if @user.own_account_deletable? %>
- <p><%= link_to(l(:button_delete_my_account), {:action => 'destroy'}, :class => 'icon icon-del') %></p>
+ <p><%= link_to(l(:button_delete_my_account), {:controller => 'my', :action => 'destroy'}, :class => 'icon icon-del') %></p>
<% end %>
<h4><%= l(:label_feeds_access_key) %></h4>
diff --git a/app/views/my/account.html.erb b/app/views/my/account.html.erb
index 87b2d7cbd..da7746bb2 100644
--- a/app/views/my/account.html.erb
+++ b/app/views/my/account.html.erb
@@ -28,6 +28,17 @@
<% if Setting.openid? %>
<p><%= f.text_field :identity_url %></p>
<% end %>
+ <p>
+ <label><%=l :setting_twofa -%></label>
+ <% 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/>
+ <% 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/>
+ <% end %>
+ <% end %>
+ </p>
<% @user.custom_field_values.select(&:editable?).each do |value| %>
<p><%= custom_field_tag_with_label :user, value %></p>
diff --git a/app/views/twofa/activate_confirm.html.erb b/app/views/twofa/activate_confirm.html.erb
new file mode 100644
index 000000000..fc356323c
--- /dev/null
+++ b/app/views/twofa/activate_confirm.html.erb
@@ -0,0 +1,27 @@
+<h2><%=l 'twofa_label_setup' -%></h2>
+
+<div class="splitcontentleft">
+ <%= form_tag({ action: :activate,
+ scheme: @twofa_view[:scheme_name] },
+ { method: :post,
+ id: 'twofa_form' }) do -%>
+
+ <div class="box">
+ <p><%=t "twofa__#{@twofa_view[:scheme_name]}__text_pairing_info_html" -%></p>
+ <div class="tabular">
+ <%= render partial: "twofa/#{@twofa_view[:scheme_name]}/new", locals: { twofa_view: @twofa_view } -%>
+ <p>
+ <label for="twofa_code"><%=l 'twofa_label_code' -%></label>
+ <%= text_field_tag :twofa_code, nil, autocomplete: 'off', autofocus: true -%>
+ </p>
+ </div>
+ </div>
+
+ <%= submit_tag l('button_activate'), name: :submit_otp -%>
+ <%= link_to l('twofa_resend_code'), { action: 'activate_init', scheme: @twofa_view[:scheme_name] }, method: :post if @twofa_view[:resendable] -%>
+ <% end %>
+</div>
+
+<% content_for :sidebar do %>
+<%= render :partial => 'my/sidebar' %>
+<% end %>
diff --git a/app/views/twofa/deactivate_confirm.html.erb b/app/views/twofa/deactivate_confirm.html.erb
new file mode 100644
index 000000000..f2ecb0d07
--- /dev/null
+++ b/app/views/twofa/deactivate_confirm.html.erb
@@ -0,0 +1,25 @@
+<h2><%=l 'twofa_label_deactivation_confirmation' -%></h2>
+
+<div class="splitcontentleft">
+ <%= form_tag({ action: :deactivate,
+ 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>
+ <%= 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 %>
+</div>
+
+<% content_for :sidebar do %>
+<%= render :partial => 'my/sidebar' %>
+<% end %>
diff --git a/app/views/twofa/totp/_new.html.erb b/app/views/twofa/totp/_new.html.erb
new file mode 100644
index 000000000..c1f4375f2
--- /dev/null
+++ b/app/views/twofa/totp/_new.html.erb
@@ -0,0 +1,8 @@
+<p>
+ <label>&nbsp;</label>
+ <%= image_tag RQRCode::QRCode.new(twofa_view[:provisioning_uri]).as_png(fill: ChunkyPNG::Color::TRANSPARENT, resize_exactly_to: 280, border_modules: 0).to_data_url, id: 'twofa_code' -%>
+</p>
+<p>
+ <label><%=l 'twofa__totp__label_plain_text_key' -%></label>
+ <code><%= twofa_view[:totp_key].scan(/.{4}/).join(' ') -%></code>
+</p>
diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb
index bb20a4f9d..b9054a3b0 100644
--- a/app/views/users/_form.html.erb
+++ b/app/views/users/_form.html.erb
@@ -42,6 +42,19 @@
<p><%= f.check_box :generate_password %></p>
<p><%= f.check_box :must_change_passwd %></p>
</div>
+ <p>
+ <label><%=l :setting_twofa -%></label>
+ <% if @user.twofa_active? %>
+ <%=l 'twofa_currently_active', twofa_scheme_name: l("twofa__#{@user.twofa_scheme}__name") -%><br/>
+ <% if @user == User.current # administrators cannot deactivate their own 2FA without confirmation code %>
+ <%= link_to l('button_disable'), { controller: 'twofa', action: 'deactivate_init', scheme: @user.twofa_scheme }, method: :post -%>
+ <% else %>
+ <%= link_to l('button_disable'), { controller: 'twofa', action: 'admin_deactivate', user_id: @user }, method: :post -%>
+ <% end %>
+ <% else %>
+ <%=l 'twofa_not_active' %>
+ <% end %>
+ </p>
</fieldset>
</div>
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 181374ab8..c86f39533 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -156,6 +156,7 @@ de:
actionview_instancetag_blank_option: Bitte auswählen
button_activate: Aktivieren
+ button_disable: Deaktivieren
button_add: Hinzufügen
button_annotate: Annotieren
button_apply: Anwenden
@@ -1321,3 +1322,22 @@ de:
field_passwd_changed_on: Password last changed
label_import_users: Import users
label_days_to_html: "%{days} days up to %{date}"
+ setting_twofa: Zwei-Faktor-Authentifizierung
+ twofa__totp__name: Authentifizierungs-App
+ twofa__totp__text_pairing_info_html: 'Bitte scannen Sie diesen QR-Code oder verwenden Sie den Klartext-Schlüssel in einer TOTP-kompatiblen Authentifizierungs-App (z.B. <a href="https://support.google.com/accounts/answer/1066447?hl=de">Google Authenticator</a>, <a href="https://authy.com/download/">Authy</a>, <a href="https://guide.duo.com/third-party-accounts">Duo Mobile</a>). Anschließend geben Sie bitte den in der App generierten Code unten ein.'
+ twofa__totp__label_plain_text_key: Klartext-Schlüssel
+ twofa__totp__label_activate: 'Authentifizierungs-App aktivieren'
+ twofa_currently_active: "Aktiv: %{twofa_scheme_name}"
+ twofa_not_active: "Nicht aktiv"
+ twofa_label_code: Code
+ twofa_label_setup: Zwei-Faktor-Authentifizierung einrichten
+ twofa_label_deactivation_confirmation: Zwei-Faktor-Authentifizierung abschalten
+ twofa_activated: Zwei-Faktor-Authentifizierung erfolgreich eingerichtet.
+ 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_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.
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c3f4925cd..cc820eabd 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -494,6 +494,7 @@ en:
setting_timelog_accept_future_dates: Accept time logs on future dates
setting_show_status_changes_in_mail_subject: Show status changes in issue mail notifications subject
setting_project_list_defaults: Projects list defaults
+ setting_twofa: Two-factor authentication
permission_add_project: Create project
permission_add_subprojects: Create subprojects
@@ -1117,6 +1118,7 @@ en:
button_back: Back
button_cancel: Cancel
button_activate: Activate
+ button_disable: Disable
button_sort: Sort
button_log_time: Log time
button_rollback: Rollback to this version
@@ -1297,3 +1299,22 @@ en:
text_project_is_public_anonymous: Public projects and their contents are openly available on the network.
label_import_time_entries: Import time entries
label_import_users: Import users
+
+ twofa__totp__name: Authenticator app
+ twofa__totp__text_pairing_info_html: 'Scan this QR code or enter the plain text key into a TOTP app (e.g. <a href="https://support.google.com/accounts/answer/1066447">Google Authenticator</a>, <a href="https://authy.com/download/">Authy</a>, <a href="https://guide.duo.com/third-party-accounts">Duo Mobile</a>) and enter the code in the field below to activate two-factor authentication.'
+ twofa__totp__label_plain_text_key: Plain text key
+ twofa__totp__label_activate: 'Enable authenticator app'
+ twofa_currently_active: "Currently active: %{twofa_scheme_name}"
+ twofa_not_active: "Not activated"
+ twofa_label_code: Code
+ twofa_label_setup: Enable two-factor authentication
+ twofa_label_deactivation_confirmation: Disable two-factor authentication
+ twofa_activated: Two-factor authentication successfully enabled.
+ 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_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.
diff --git a/config/routes.rb b/config/routes.rb
index 03071fad9..3e6ae9cc7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -22,6 +22,9 @@ Rails.application.routes.draw do
match 'login', :to => 'account#login', :as => 'signin', :via => [:get, :post]
match 'logout', :to => 'account#logout', :as => 'signout', :via => [:get, :post]
+ match 'account/twofa/confirm', :to => 'account#twofa_confirm', :via => :get
+ match 'account/twofa/resend', :to => 'account#twofa_resend', :via => :post
+ match 'account/twofa', :to => 'account#twofa', :via => [:get, :post]
match 'account/register', :to => 'account#register', :via => [:get, :post], :as => 'register'
match 'account/lost_password', :to => 'account#lost_password', :via => [:get, :post], :as => 'lost_password'
match 'account/activate', :to => 'account#activate', :via => :get
@@ -85,6 +88,13 @@ Rails.application.routes.draw do
match 'my/add_block', :controller => 'my', :action => 'add_block', :via => :post
match 'my/remove_block', :controller => 'my', :action => 'remove_block', :via => :post
match 'my/order_blocks', :controller => 'my', :action => 'order_blocks', :via => :post
+ match 'my/twofa/:scheme/activate/init', :controller => 'twofa', :action => 'activate_init', :via => :post
+ match 'my/twofa/:scheme/activate/confirm', :controller => 'twofa', :action => 'activate_confirm', :via => :get
+ match 'my/twofa/:scheme/activate', :controller => 'twofa', :action => 'activate', :via => [:get, :post]
+ match 'my/twofa/:scheme/deactivate/init', :controller => 'twofa', :action => 'deactivate_init', :via => :post
+ 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 'users/:user_id/twofa/deactivate', :controller => 'twofa', :action => 'admin_deactivate', :via => :post
resources :users do
resources :memberships, :controller => 'principal_memberships'
diff --git a/db/migrate/20200826153401_add_twofa_scheme_to_user.rb b/db/migrate/20200826153401_add_twofa_scheme_to_user.rb
new file mode 100644
index 000000000..ea0b48fc8
--- /dev/null
+++ b/db/migrate/20200826153401_add_twofa_scheme_to_user.rb
@@ -0,0 +1,5 @@
+class AddTwofaSchemeToUser < ActiveRecord::Migration[5.2]
+ def change
+ add_column :users, :twofa_scheme, :string
+ end
+end
diff --git a/db/migrate/20200826153402_add_totp_to_user.rb b/db/migrate/20200826153402_add_totp_to_user.rb
new file mode 100644
index 000000000..6842878e3
--- /dev/null
+++ b/db/migrate/20200826153402_add_totp_to_user.rb
@@ -0,0 +1,6 @@
+class AddTotpToUser < ActiveRecord::Migration[5.2]
+ def change
+ add_column :users, :twofa_totp_key, :string
+ add_column :users, :twofa_totp_last_used_at, :integer
+ end
+end
diff --git a/lib/redmine.rb b/lib/redmine.rb
index 668a65da2..666111d34 100644
--- a/lib/redmine.rb
+++ b/lib/redmine.rb
@@ -68,6 +68,7 @@ require 'redmine/hook'
require 'redmine/hook/listener'
require 'redmine/hook/view_listener'
require 'redmine/plugin'
+require 'redmine/twofa'
Redmine::Scm::Base.add "Subversion"
Redmine::Scm::Base.add "Mercurial"
diff --git a/lib/redmine/twofa.rb b/lib/redmine/twofa.rb
new file mode 100644
index 000000000..44f1b7ac6
--- /dev/null
+++ b/lib/redmine/twofa.rb
@@ -0,0 +1,58 @@
+# 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 Redmine
+ module Twofa
+ def self.register_scheme(name, klass)
+ initialize_schemes
+ @@schemes[name] = klass
+ end
+
+ def self.available_schemes
+ schemes.keys
+ end
+
+ def self.for_twofa_scheme(name)
+ schemes[name]
+ end
+
+ def self.for_user(user)
+ for_twofa_scheme(user.twofa_scheme).try(:new, user)
+ end
+
+ def self.schemes
+ initialize_schemes
+ @@schemes
+ end
+ private_class_method :schemes
+
+ def self.initialize_schemes
+ @@schemes ||= { }
+ scan_builtin_schemes if @@schemes.blank?
+ end
+ private_class_method :initialize_schemes
+
+ def self.scan_builtin_schemes
+ Dir[Rails.root.join('lib', 'redmine', 'twofa', '*.rb')].each do |file|
+ require_dependency file
+ end
+ end
+ private_class_method :scan_builtin_schemes
+ end
+end
diff --git a/lib/redmine/twofa/base.rb b/lib/redmine/twofa/base.rb
new file mode 100644
index 000000000..8369c8f6a
--- /dev/null
+++ b/lib/redmine/twofa/base.rb
@@ -0,0 +1,127 @@
+# 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 Redmine
+ module Twofa
+ class Base
+ def self.inherited(child)
+ # require-ing a Base subclass will register it as a 2FA scheme
+ Redmine::Twofa.register_scheme(scheme_name(child), child)
+ end
+
+ def self.scheme_name(klass = self)
+ klass.name.demodulize.underscore
+ end
+
+ def scheme_name
+ self.class.scheme_name
+ end
+
+ def initialize(user)
+ @user = user
+ end
+
+ def init_pairing!
+ @user
+ end
+
+ def confirm_pairing!(code)
+ # make sure an otp is used
+ if verify_otp!(code)
+ @user.update!(twofa_scheme: scheme_name)
+ deliver_twofa_paired
+ return true
+ else
+ return false
+ end
+ end
+
+ def deliver_twofa_paired
+ Mailer.security_notification(
+ @user,
+ User.current,
+ {
+ title: :label_my_account,
+ message: 'twofa_mail_body_security_notification_paired',
+ # (mis-)use field here as value wouldn't get localized
+ field: "twofa__#{scheme_name}__name",
+ url: { controller: 'my', action: 'account' }
+ }
+ ).deliver
+ end
+
+ def destroy_pairing!(code)
+ if verify!(code)
+ destroy_pairing_without_verify!
+ return true
+ else
+ return false
+ end
+ end
+
+ def destroy_pairing_without_verify!
+ @user.update!(twofa_scheme: nil)
+ deliver_twofa_unpaired
+ end
+
+ def deliver_twofa_unpaired
+ Mailer.security_notification(
+ @user,
+ User.current,
+ {
+ title: :label_my_account,
+ message: 'twofa_mail_body_security_notification_unpaired',
+ url: { controller: 'my', action: 'account' }
+ }
+ ).deliver
+ end
+
+ def send_code(controller: nil, action: nil)
+ # return true only if the scheme sends a code to the user
+ false
+ end
+
+ def verify!(code)
+ verify_otp!(code)
+ end
+
+ def verify_otp!(code)
+ raise 'not implemented'
+ end
+
+ # this will only be used on pairing initialization
+ def init_pairing_view_variables
+ otp_confirm_view_variables
+ end
+
+ def otp_confirm_view_variables
+ {
+ scheme_name: scheme_name,
+ resendable: false
+ }
+ end
+
+ private
+
+ def allowed_drift
+ 30
+ end
+ end
+ end
+end
diff --git a/lib/redmine/twofa/totp.rb b/lib/redmine/twofa/totp.rb
new file mode 100644
index 000000000..ff4fe9cf1
--- /dev/null
+++ b/lib/redmine/twofa/totp.rb
@@ -0,0 +1,68 @@
+# 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 Redmine
+ module Twofa
+ class Totp < Base
+ def init_pairing!
+ @user.update!(twofa_totp_key: ROTP::Base32.random)
+ # reset the cached totp as the key might have changed
+ @totp = nil
+ super
+ end
+
+ def destroy_pairing_without_verify!
+ @user.update!(twofa_totp_key: nil, twofa_totp_last_used_at: nil)
+ # reset the cached totp as the key might have changed
+ @totp = nil
+ super
+ end
+
+ def verify_otp!(code)
+ # topt codes are white-space-insensitive
+ code = code.to_s.remove(/[[:space:]]/)
+ last_verified_at = @user.twofa_totp_last_used_at
+ verified_at = totp.verify(code.to_s, drift_behind: allowed_drift, after: last_verified_at)
+ if verified_at
+ @user.update!(twofa_totp_last_used_at: verified_at)
+ return true
+ else
+ return false
+ end
+ end
+
+ def provisioning_uri
+ totp.provisioning_uri(@user.mail)
+ end
+
+ def init_pairing_view_variables
+ super.merge({
+ provisioning_uri: provisioning_uri,
+ totp_key: @user.twofa_totp_key
+ })
+ end
+
+ private
+
+ def totp
+ @totp ||= ROTP::TOTP.new(@user.twofa_totp_key, issuer: Setting.app_title)
+ end
+ end
+ end
+end
diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css
index 282a4adf6..f7aa80c35 100644
--- a/public/stylesheets/application.css
+++ b/public/stylesheets/application.css
@@ -122,6 +122,7 @@ html>body #content { min-height: 600px; }
#login-form input[type=text], #login-form input[type=password] {margin-bottom: 15px;}
#login-form a.lost_password {float:right; font-weight:normal;}
#login-form input#openid_url {background:#fff url(../images/openid-bg.gif) no-repeat 4px 50%; padding-left:24px !important;}
+#login-form h3 {text-align: center;}
div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
div.modal h3.title {display:none;}
@@ -793,6 +794,7 @@ 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; }
.tabular label{
font-weight: bold;
diff --git a/public/stylesheets/responsive.css b/public/stylesheets/responsive.css
index 6ac4299d6..9e689523b 100644
--- a/public/stylesheets/responsive.css
+++ b/public/stylesheets/responsive.css
@@ -664,7 +664,8 @@
#login-form input#username,
#login-form input#password,
- #login-form input#openid_url {
+ #login-form input#openid_url,
+ #login-form input#twofa_code {
width: 100%;
height: auto;
}