From a06150aa6d789ce429d11b98d1cdc7372b91a2a3 Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Thu, 27 Aug 2020 00:36:38 +0900 Subject: [PATCH 1/4] Adds two factor authentication support (#1237). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch by Felix Schäfer. --- Gemfile | 4 + app/controllers/account_controller.rb | 107 ++++++++++++++- app/controllers/twofa_controller.rb | 106 +++++++++++++++ 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 ++ ...20200826153401_add_twofa_scheme_to_user.rb | 5 + db/migrate/20200826153402_add_totp_to_user.rb | 6 + lib/redmine.rb | 1 + lib/redmine/twofa.rb | 58 ++++++++ lib/redmine/twofa/base.rb | 127 ++++++++++++++++++ lib/redmine/twofa/totp.rb | 68 ++++++++++ public/stylesheets/application.css | 2 + public/stylesheets/responsive.css | 3 +- 22 files changed, 653 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/20200826153401_add_twofa_scheme_to_user.rb create mode 100644 db/migrate/20200826153402_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 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..eba55db82 --- /dev/null +++ b/app/controllers/twofa_controller.rb @@ -0,0 +1,106 @@ +# 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 + (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) + + 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 @@ +
+ +

<%=l :setting_twofa %>

+

<%=l 'twofa_label_enter_otp' %>

+ + <%= form_tag({ action: 'twofa' }, + { id: 'twofa_form', + onsubmit: 'return keepAnchorOnSignIn(this);' }) do -%> + + + + <%= 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 %> + +
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 bb20a4f9d..b9054a3b0 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -42,6 +42,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 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. 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 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. 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 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; } -- 2.26.2