mirror of
https://github.com/redmine/redmine.git
synced 2025-10-26 00:36:14 +02:00
Adds two factor authentication support (#1237).
Patch by Felix Schäfer. git-svn-id: http://svn.redmine.org/redmine/trunk@19988 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
4
Gemfile
4
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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
109
app/controllers/twofa_controller.rb
Normal file
109
app/controllers/twofa_controller.rb
Normal file
@@ -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?
|
||||
|
||||
20
app/views/account/twofa_confirm.html.erb
Normal file
20
app/views/account/twofa_confirm.html.erb
Normal file
@@ -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>
|
||||
|
||||
27
app/views/twofa/activate_confirm.html.erb
Normal file
27
app/views/twofa/activate_confirm.html.erb
Normal file
@@ -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 %>
|
||||
25
app/views/twofa/deactivate_confirm.html.erb
Normal file
25
app/views/twofa/deactivate_confirm.html.erb
Normal file
@@ -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 %>
|
||||
8
app/views/twofa/totp/_new.html.erb
Normal file
8
app/views/twofa/totp/_new.html.erb
Normal file
@@ -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'
|
||||
|
||||
5
db/migrate/20200826153401_add_twofa_scheme_to_user.rb
Normal file
5
db/migrate/20200826153401_add_twofa_scheme_to_user.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddTwofaSchemeToUser < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :users, :twofa_scheme, :string
|
||||
end
|
||||
end
|
||||
6
db/migrate/20200826153402_add_totp_to_user.rb
Normal file
6
db/migrate/20200826153402_add_totp_to_user.rb
Normal file
@@ -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"
|
||||
|
||||
58
lib/redmine/twofa.rb
Normal file
58
lib/redmine/twofa.rb
Normal file
@@ -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
|
||||
127
lib/redmine/twofa/base.rb
Normal file
127
lib/redmine/twofa/base.rb
Normal file
@@ -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
|
||||
68
lib/redmine/twofa/totp.rb
Normal file
68
lib/redmine/twofa/totp.rb
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user