Patch by Felix Schäfer. git-svn-id: http://svn.redmine.org/redmine/trunk@19988 e93f8b46-1217-0410-a6f0-8f06a7374b81tags/4.2.0
@@ -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" |
@@ -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( |
@@ -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 |
@@ -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? |
@@ -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> |
@@ -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> |
@@ -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> |
@@ -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 %> |
@@ -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 %> |
@@ -0,0 +1,8 @@ | |||
<p> | |||
<label> </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> |
@@ -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> | |||
@@ -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. |
@@ -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. |
@@ -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' |
@@ -0,0 +1,5 @@ | |||
class AddTwofaSchemeToUser < ActiveRecord::Migration[5.2] | |||
def change | |||
add_column :users, :twofa_scheme, :string | |||
end | |||
end |
@@ -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 |
@@ -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" |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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; |
@@ -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; | |||
} |