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 @@ +
+ +

<%=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 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