From ff81a84b1e22867cd8df9bfc07095018ce5dd798 Mon Sep 17 00:00:00 2001
From: Jens Kraemer
Date: Fri, 16 Aug 2019 16:19:31 +0000
Subject: [PATCH 1/5] adds two factor authentication support
- patch by Felix Schaefer, http://www.redmine.org/issues/1237
---
Gemfile | 4 +
app/controllers/account_controller.rb | 107 +++++++++++++++++++-
app/controllers/twofa_controller.rb | 87 +++++++++++++++++
app/models/user.rb | 13 +++
app/views/account/twofa_confirm.html.erb | 20 ++++
app/views/my/_sidebar.html.erb | 2 +-
app/views/my/account.html.erb | 11 +++
app/views/twofa/activate_confirm.html.erb | 27 ++++++
app/views/twofa/deactivate_confirm.html.erb | 25 +++++
app/views/twofa/totp/_new.html.erb | 8 ++
app/views/users/_form.html.erb | 13 +++
config/locales/de.yml | 20 ++++
config/locales/en.yml | 21 ++++
config/routes.rb | 10 ++
.../20170711134351_add_twofa_scheme_to_user.rb | 5 +
db/migrate/20170711134352_add_totp_to_user.rb | 6 ++
lib/redmine/twofa.rb | 38 ++++++++
lib/redmine/twofa/base.rb | 108 +++++++++++++++++++++
lib/redmine/twofa/totp.rb | 49 ++++++++++
public/stylesheets/application.css | 2 +
public/stylesheets/responsive.css | 3 +-
21 files changed, 575 insertions(+), 4 deletions(-)
create mode 100644 app/controllers/twofa_controller.rb
create mode 100644 app/views/account/twofa_confirm.html.erb
create mode 100644 app/views/twofa/activate_confirm.html.erb
create mode 100644 app/views/twofa/deactivate_confirm.html.erb
create mode 100644 app/views/twofa/totp/_new.html.erb
create mode 100644 db/migrate/20170711134351_add_twofa_scheme_to_user.rb
create mode 100644 db/migrate/20170711134352_add_totp_to_user.rb
create mode 100644 lib/redmine/twofa.rb
create mode 100644 lib/redmine/twofa/base.rb
create mode 100644 lib/redmine/twofa/totp.rb
diff --git a/Gemfile b/Gemfile
index 12fc054fb..c12f029a8 100644
--- a/Gemfile
+++ b/Gemfile
@@ -18,6 +18,10 @@ gem "rbpdf", "~> 1.20.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 ff8631e90..711f16856 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)
+ unless @user.present?
+ 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..8a64a05ae
--- /dev/null
+++ b/app/controllers/twofa_controller.rb
@@ -0,0 +1,87 @@
+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
+ (render_403; return false) if @user == User.current
+
+ 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)
+
+ unless twofa_scheme.present?
+ 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 096adc4a9..2c5b3ec20 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
@@ -376,6 +377,10 @@ class User < Principal
self
end
+ def twofa_active?
+ twofa_scheme.present?
+ end
+
def pref
self.preference ||= UserPreference.new(:user => self)
end
@@ -436,6 +441,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 @@
+
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) %>
<% if @user.own_account_deletable? %>
- <%= link_to(l(:button_delete_my_account), {:action => 'destroy'}, :class => 'icon icon-del') %>
+ <%= link_to(l(:button_delete_my_account), {:controller => 'my', :action => 'destroy'}, :class => 'icon icon-del') %>
<% end %>
<%= l(:label_feeds_access_key) %>
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? %>
<%= f.text_field :identity_url %>
<% end %>
+
+
+ <% 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 -%>
+ <% else %>
+ <% Redmine::Twofa.available_schemes.each do |s| %>
+ <%= link_to l("twofa__#{s}__label_activate"), { controller: 'twofa', action: 'activate_init', scheme: s }, method: :post -%>
+ <% end %>
+ <% end %>
+
<% @user.custom_field_values.select(&:editable?).each do |value| %>
<%= custom_field_tag_with_label :user, value %>
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 @@
+<%=l 'twofa_label_setup' -%>
+
+
+ <%= form_tag({ action: :activate,
+ scheme: @twofa_view[:scheme_name] },
+ { method: :post,
+ id: 'twofa_form' }) do -%>
+
+
+
<%=t "twofa__#{@twofa_view[:scheme_name]}__text_pairing_info_html" -%>
+
+ <%= render partial: "twofa/#{@twofa_view[:scheme_name]}/new", locals: { twofa_view: @twofa_view } -%>
+
+
+ <%= text_field_tag :twofa_code, nil, autocomplete: 'off', autofocus: true -%>
+
+
+
+
+ <%= 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 %>
+
+
+<% 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 @@
+<%=l 'twofa_label_deactivation_confirmation' -%>
+
+
+ <%= form_tag({ action: :deactivate,
+ scheme: @twofa_view[:scheme_name] },
+ { method: :post,
+ id: 'twofa_form' }) do -%>
+
+
+
<%=l 'twofa_label_enter_otp' %>
+
+
+
+ <%= text_field_tag :twofa_code, nil, autocomplete: 'off' -%>
+
+
+
+ <%= 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 %>
+
+
+<% 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 @@
+
+
+ <%= 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' -%>
+
+
+
+ <%= twofa_view[:totp_key].scan(/.{4}/).join(' ') -%>
+
diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb
index ce5b1f6c7..f01c26eb4 100644
--- a/app/views/users/_form.html.erb
+++ b/app/views/users/_form.html.erb
@@ -37,6 +37,19 @@
<%= f.check_box :generate_password %>
<%= f.check_box :must_change_passwd %>
+
+
+ <% if @user.twofa_active? %>
+ <%=l 'twofa_currently_active', twofa_scheme_name: l("twofa__#{@user.twofa_scheme}__name") -%>
+ <% 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 %>
+
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 356f4b475..b5a7a5e6f 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -152,6 +152,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
@@ -1288,3 +1289,22 @@ de:
label_issue_history_properties: Property changes
label_issue_history_notes: Notes
label_last_tab_visited: Last visited tab
+ 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. Google Authenticator, Authy, Duo Mobile). 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 262ad1ba4..bb0daefba 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1089,6 +1089,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
@@ -1266,3 +1267,23 @@ en:
label_login_required_no: "No, allow anonymous access to public projects"
text_project_is_public_non_member: Public projects and their contents are available to all logged-in users.
text_project_is_public_anonymous: Public projects and their contents are openly available on the network.
+
+ setting_twofa: Two-factor authentication
+ 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. Google Authenticator, Authy, Duo Mobile) 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 37eb86ecf..ec0ec6f7a 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
@@ -84,6 +87,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/20170711134351_add_twofa_scheme_to_user.rb b/db/migrate/20170711134351_add_twofa_scheme_to_user.rb
new file mode 100644
index 000000000..ac3f49717
--- /dev/null
+++ b/db/migrate/20170711134351_add_twofa_scheme_to_user.rb
@@ -0,0 +1,5 @@
+class AddTwofaSchemeToUser < ActiveRecord::Migration[4.2]
+ def change
+ add_column :users, :twofa_scheme, :string
+ end
+end
diff --git a/db/migrate/20170711134352_add_totp_to_user.rb b/db/migrate/20170711134352_add_totp_to_user.rb
new file mode 100644
index 000000000..d24bdba86
--- /dev/null
+++ b/db/migrate/20170711134352_add_totp_to_user.rb
@@ -0,0 +1,6 @@
+class AddTotpToUser < ActiveRecord::Migration[4.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/twofa.rb b/lib/redmine/twofa.rb
new file mode 100644
index 000000000..9e9a3e1df
--- /dev/null
+++ b/lib/redmine/twofa.rb
@@ -0,0 +1,38 @@
+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
+
+ private
+
+ def self.schemes
+ initialize_schemes
+ @@schemes
+ end
+
+ def self.initialize_schemes
+ @@schemes ||= { }
+ scan_builtin_schemes if @@schemes.blank?
+ end
+
+ def self.scan_builtin_schemes
+ Dir[Rails.root.join('lib', 'redmine', 'twofa', '*.rb')].each do |file|
+ require_dependency file
+ end
+ end
+ end
+end
diff --git a/lib/redmine/twofa/base.rb b/lib/redmine/twofa/base.rb
new file mode 100644
index 000000000..f446a94bb
--- /dev/null
+++ b/lib/redmine/twofa/base.rb
@@ -0,0 +1,108 @@
+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..d06fa1603
--- /dev/null
+++ b/lib/redmine/twofa/totp.rb
@@ -0,0 +1,49 @@
+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 58f413964..e7f23bc58 100644
--- a/public/stylesheets/application.css
+++ b/public/stylesheets/application.css
@@ -108,6 +108,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;}
@@ -748,6 +749,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 aa5502ee6..29457b4ee 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;
}
--
2.11.0