Browse Source

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
tags/4.2.0
Go MAEDA 3 years ago
parent
commit
560bca344a

+ 4
- 0
Gemfile View File

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

+ 105
- 2
app/controllers/account_controller.rb View File

@@ -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
- 0
app/controllers/twofa_controller.rb View 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

+ 13
- 0
app/models/user.rb View File

@@ -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
- 0
app/views/account/twofa_confirm.html.erb View 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>

+ 1
- 1
app/views/my/_sidebar.html.erb View File

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

+ 11
- 0
app/views/my/account.html.erb View File

@@ -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
- 0
app/views/twofa/activate_confirm.html.erb View 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
- 0
app/views/twofa/deactivate_confirm.html.erb View 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
- 0
app/views/twofa/totp/_new.html.erb View File

@@ -0,0 +1,8 @@
<p>
<label>&nbsp;</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>

+ 13
- 0
app/views/users/_form.html.erb View File

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


+ 20
- 0
config/locales/de.yml View File

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

+ 21
- 0
config/locales/en.yml View File

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

+ 10
- 0
config/routes.rb View File

@@ -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
- 0
db/migrate/20200826153401_add_twofa_scheme_to_user.rb View File

@@ -0,0 +1,5 @@
class AddTwofaSchemeToUser < ActiveRecord::Migration[5.2]
def change
add_column :users, :twofa_scheme, :string
end
end

+ 6
- 0
db/migrate/20200826153402_add_totp_to_user.rb View 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

+ 1
- 0
lib/redmine.rb View File

@@ -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
- 0
lib/redmine/twofa.rb View 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
- 0
lib/redmine/twofa/base.rb View 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
- 0
lib/redmine/twofa/totp.rb View 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

+ 2
- 0
public/stylesheets/application.css View File

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

+ 2
- 1
public/stylesheets/responsive.css View File

@@ -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;
}

Loading…
Cancel
Save