Project

General

Profile

Feature #1237 » 0001-Adds-two-factor-authentication-support-1237.patch

Go MAEDA, 2020-08-26 18:22

View differences:

Gemfile
22 22
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
23 23
gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin]
24 24

  
25
# TOTP-based 2-factor authentication
26
gem 'rotp'
27
gem 'rqrcode'
28

  
25 29
# Optional gem for LDAP authentication
26 30
group :ldap do
27 31
  gem "net-ldap", "~> 0.16.0"
app/controllers/account_controller.rb
204 204
    redirect_to(home_url)
205 205
  end
206 206

  
207
  before_action :require_active_twofa, :twofa_setup, only: [:twofa_resend, :twofa_confirm, :twofa]
208
  before_action :prevent_twofa_session_replay, only: [:twofa_resend, :twofa]
209

  
210
  def twofa_resend
211
    # otp resends count toward the maximum of 3 otp entry tries per password entry
212
    if session[:twofa_tries_counter] > 3
213
      destroy_twofa_session
214
      flash[:error] = l('twofa_too_many_tries')
215
      redirect_to home_url
216
    else
217
      if @twofa.send_code(controller: 'account', action: 'twofa')
218
        flash[:notice] = l('twofa_code_sent')
219
      end
220
      redirect_to account_twofa_confirm_path
221
    end
222
  end
223

  
224
  def twofa_confirm
225
    @twofa_view = @twofa.otp_confirm_view_variables
226
  end
227

  
228
  def twofa
229
    if @twofa.verify!(params[:twofa_code].to_s)
230
      destroy_twofa_session
231
      handle_active_user(@user)
232
    # allow at most 3 otp entry tries per successfull password entry
233
    # this allows using anti brute force techniques on the password entry to also
234
    # prevent brute force attacks on the one-time password
235
    elsif session[:twofa_tries_counter] > 3
236
      destroy_twofa_session
237
      flash[:error] = l('twofa_too_many_tries')
238
      redirect_to home_url
239
    else
240
      flash[:error] = l('twofa_invalid_code')
241
      redirect_to account_twofa_confirm_path
242
    end
243
  end
244

  
207 245
  private
208 246

  
247
  def prevent_twofa_session_replay
248
    renew_twofa_session(@user)
249
  end
250

  
251
  def twofa_setup
252
    # twofa sessions are only valid 2 minutes at a time
253
    twomind = 0.0014 # a little more than 2 minutes in days
254
    @user = Token.find_active_user('twofa_session', session[:twofa_session_token].to_s, twomind)
255
    if @user.blank?
256
      destroy_twofa_session
257
      redirect_to home_url
258
      return
259
    end
260

  
261
    # copy back_url, autologin back to params where they are expected
262
    params[:back_url] ||= session[:twofa_back_url]
263
    params[:autologin] ||= session[:twofa_autologin]
264

  
265
    # set locale for the twofa user
266
    set_localization(@user)
267

  
268
    @twofa = Redmine::Twofa.for_user(@user)
269
  end
270

  
271
  def require_active_twofa
272
    Setting.twofa? ? true : deny_access
273
  end
274

  
275
  def setup_twofa_session(user, previous_tries=1)
276
    token = Token.create(user: user, action: 'twofa_session')
277
    session[:twofa_session_token] = token.value
278
    session[:twofa_tries_counter] = previous_tries
279
    session[:twofa_back_url] = params[:back_url]
280
    session[:twofa_autologin] = params[:autologin]
281
  end
282

  
283
  # Prevent replay attacks by using each twofa_session_token only for exactly one request
284
  def renew_twofa_session(user)
285
    twofa_tries = session[:twofa_tries_counter].to_i + 1
286
    destroy_twofa_session
287
    setup_twofa_session(user, twofa_tries)
288
  end
289

  
290
  def destroy_twofa_session
291
    # make sure tokens can only be used once server-side to prevent replay attacks
292
    Token.find_token('twofa_session', session[:twofa_session_token].to_s).try(:delete)
293
    session[:twofa_session_token] = nil
294
    session[:twofa_tries_counter] = nil
295
    session[:twofa_back_url] = nil
296
    session[:twofa_autologin] = nil
297
  end
298

  
209 299
  def authenticate_user
210 300
    if Setting.openid? && using_open_id?
211 301
      open_id_authenticate(params[:openid_url])
......
224 314
    else
225 315
      # Valid user
226 316
      if user.active?
227
        successful_authentication(user)
228
        update_sudo_timestamp! # activate Sudo Mode
317
        if user.twofa_active?
318
          setup_twofa_session user
319
          twofa = Redmine::Twofa.for_user(user)
320
          if twofa.send_code(controller: 'account', action: 'twofa')
321
            flash[:notice] = l('twofa_code_sent')
322
          end
323
          redirect_to account_twofa_confirm_path
324
        else
325
          handle_active_user(user)
326
        end
229 327
      else
230 328
        handle_inactive_user(user)
231 329
      end
232 330
    end
233 331
  end
234 332

  
333
  def handle_active_user(user)
334
    successful_authentication(user)
335
    update_sudo_timestamp! # activate Sudo Mode
336
  end
337

  
235 338
  def open_id_authenticate(openid_url)
236 339
    back_url = signin_url(:autologin => params[:autologin])
237 340
    authenticate_with_open_id(
app/controllers/twofa_controller.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2020  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class TwofaController < ApplicationController
21
  self.main_menu = false
22

  
23
  before_action :require_login
24
  before_action :require_admin, only: :admin_deactivate
25

  
26
  require_sudo_mode :activate_init, :deactivate_init
27

  
28
  before_action :activate_setup, only: [:activate_init, :activate_confirm, :activate]
29

  
30
  def activate_init
31
    @twofa.init_pairing!
32
    if @twofa.send_code(controller: 'twofa', action: 'activate')
33
      flash[:notice] = l('twofa_code_sent')
34
    end
35
    redirect_to action: :activate_confirm, scheme: @twofa.scheme_name
36
  end
37

  
38
  def activate_confirm
39
    @twofa_view = @twofa.init_pairing_view_variables
40
  end
41

  
42
  def activate
43
    if @twofa.confirm_pairing!(params[:twofa_code].to_s)
44
      flash[:notice] = l('twofa_activated')
45
      redirect_to my_account_path
46
    else
47
      flash[:error] = l('twofa_invalid_code')
48
      redirect_to action: :activate_confirm, scheme: @twofa.scheme_name
49
    end
50
  end
51

  
52
  before_action :deactivate_setup, only: [:deactivate_init, :deactivate_confirm, :deactivate]
53

  
54
  def deactivate_init
55
    if @twofa.send_code(controller: 'twofa', action: 'deactivate')
56
      flash[:notice] = l('twofa_code_sent')
57
    end
58
    redirect_to action: :deactivate_confirm, scheme: @twofa.scheme_name
59
  end
60

  
61
  def deactivate_confirm
62
    @twofa_view = @twofa.otp_confirm_view_variables
63
  end
64

  
65
  def deactivate
66
    if @twofa.destroy_pairing!(params[:twofa_code].to_s)
67
      flash[:notice] = l('twofa_deactivated')
68
      redirect_to my_account_path
69
    else
70
      flash[:error] = l('twofa_invalid_code')
71
      redirect_to action: :deactivate_confirm, scheme: @twofa.scheme_name
72
    end
73
  end
74

  
75
  def admin_deactivate
76
    @user = User.find(params[:user_id])
77
    # do not allow administrators to unpair 2FA without confirmation for themselves
78
    (render_403; return false) if @user == User.current
79

  
80
    twofa = Redmine::Twofa.for_user(@user)
81
    twofa.destroy_pairing_without_verify!
82
    flash[:notice] = l('twofa_deactivated')
83
    redirect_to edit_user_path(@user)
84
  end
85

  
86
  private
87

  
88
  def activate_setup
89
    twofa_scheme = Redmine::Twofa.for_twofa_scheme(params[:scheme].to_s)
90

  
91
    if twofa_scheme.blank?
92
      redirect_to my_account_path
93
      return
94
    end
95
    @user = User.current
96
    @twofa = twofa_scheme.new(@user)
97
  end
98

  
99
  def deactivate_setup
100
    @user = User.current
101
    @twofa = Redmine::Twofa.for_user(@user)
102
    if params[:scheme].to_s != @twofa.scheme_name
103
      redirect_to my_account_path
104
    end
105
  end
106
end
app/models/user.rb
20 20
require "digest/sha1"
21 21

  
22 22
class User < Principal
23
  include Redmine::Ciphering
23 24
  include Redmine::SafeAttributes
24 25

  
25 26
  # Different ways of displaying/sorting users
......
391 392
    self
392 393
  end
393 394

  
395
  def twofa_active?
396
    twofa_scheme.present?
397
  end
398

  
394 399
  def pref
395 400
    self.preference ||= UserPreference.new(:user => self)
396 401
  end
......
451 456
    Token.where(:user_id => id, :action => 'autologin', :value => value).delete_all
452 457
  end
453 458

  
459
  def twofa_totp_key
460
    read_ciphered_attribute(:twofa_totp_key)
461
  end
462

  
463
  def twofa_totp_key=(key)
464
    write_ciphered_attribute(:twofa_totp_key, key)
465
  end
466

  
454 467
  # Returns true if token is a valid session token for the user whose id is user_id
455 468
  def self.verify_session_token(user_id, token)
456 469
    return false if user_id.blank? || token.blank?
app/views/account/twofa_confirm.html.erb
1
<div id="login-form">
2

  
3
  <h3><%=l :setting_twofa %></h3>
4
  <p><%=l 'twofa_label_enter_otp' %></p>
5

  
6
  <%= form_tag({ action: 'twofa' },
7
               { id: 'twofa_form',
8
                 onsubmit: 'return keepAnchorOnSignIn(this);' }) do -%>
9

  
10

  
11
    <label for="twofa_code">
12
      <%=l 'twofa_label_code' -%>
13
      <%= link_to l('twofa_resend_code'), { controller: 'account', action: 'twofa_resend' }, method: :post, class: 'lost_password' if @twofa_view[:resendable] -%>
14
    </label>
15
    <%= text_field_tag :twofa_code, nil, tabindex: '1', autocomplete: 'off', autofocus: true -%>
16

  
17
    <%= submit_tag l(:button_login), tabindex: '2', id: 'login-submit', name: :submit_otp -%>
18
  <% end %>
19

  
20
</div>
app/views/my/_sidebar.html.erb
4 4
<%=l(:field_created_on)%>: <%= format_time(@user.created_on) %></p>
5 5

  
6 6
<% if @user.own_account_deletable? %>
7
  <p><%= link_to(l(:button_delete_my_account), {:action => 'destroy'}, :class => 'icon icon-del') %></p>
7
  <p><%= link_to(l(:button_delete_my_account), {:controller => 'my', :action => 'destroy'}, :class => 'icon icon-del') %></p>
8 8
<% end %>
9 9

  
10 10
<h4><%= l(:label_feeds_access_key) %></h4>
app/views/my/account.html.erb
28 28
  <% if Setting.openid? %>
29 29
  <p><%= f.text_field :identity_url  %></p>
30 30
  <% end %>
31
  <p>
32
    <label><%=l :setting_twofa -%></label>
33
    <% if @user.twofa_active? %>
34
      <%=l 'twofa_currently_active', twofa_scheme_name: l("twofa__#{@user.twofa_scheme}__name") -%><br/>
35
      <%= link_to l('button_disable'), { controller: 'twofa', action: 'deactivate_init', scheme: @user.twofa_scheme }, method: :post -%><br/>
36
    <% else %>
37
      <% Redmine::Twofa.available_schemes.each do |s| %>
38
        <%= link_to l("twofa__#{s}__label_activate"), { controller: 'twofa', action: 'activate_init', scheme: s }, method: :post -%><br/>
39
      <% end %>
40
    <% end %>
41
  </p>
31 42

  
32 43
  <% @user.custom_field_values.select(&:editable?).each do |value| %>
33 44
    <p><%= custom_field_tag_with_label :user, value %></p>
app/views/twofa/activate_confirm.html.erb
1
<h2><%=l 'twofa_label_setup' -%></h2>
2

  
3
<div class="splitcontentleft">
4
  <%= form_tag({ action: :activate,
5
                 scheme: @twofa_view[:scheme_name] },
6
               { method: :post,
7
                 id: 'twofa_form' }) do -%>
8

  
9
    <div class="box">
10
      <p><%=t "twofa__#{@twofa_view[:scheme_name]}__text_pairing_info_html" -%></p>
11
      <div class="tabular">
12
        <%= render partial: "twofa/#{@twofa_view[:scheme_name]}/new", locals: { twofa_view: @twofa_view } -%>
13
        <p>
14
          <label for="twofa_code"><%=l 'twofa_label_code' -%></label>
15
          <%= text_field_tag :twofa_code, nil, autocomplete: 'off', autofocus: true -%>
16
        </p>
17
      </div>
18
    </div>
19

  
20
    <%= submit_tag l('button_activate'), name: :submit_otp -%>
21
    <%= link_to l('twofa_resend_code'), { action: 'activate_init', scheme: @twofa_view[:scheme_name] }, method: :post if @twofa_view[:resendable] -%>
22
  <% end %>
23
</div>
24

  
25
<% content_for :sidebar do %>
26
<%= render :partial => 'my/sidebar' %>
27
<% end %>
app/views/twofa/deactivate_confirm.html.erb
1
<h2><%=l 'twofa_label_deactivation_confirmation' -%></h2>
2

  
3
<div class="splitcontentleft">
4
  <%= form_tag({ action: :deactivate,
5
                 scheme: @twofa_view[:scheme_name] },
6
               { method: :post,
7
                 id: 'twofa_form' }) do -%>
8
    <div class="box">
9

  
10
      <p><%=l 'twofa_label_enter_otp' %></p>
11
      <div class="tabular">
12
        <p>
13
          <label for="twofa_code"><%=l 'twofa_label_code' -%></label>
14
          <%= text_field_tag :twofa_code, nil, autocomplete: 'off' -%>
15
        </p>
16
      </div>
17
    </div>
18
    <%= submit_tag l('button_disable'), name: :submit_otp -%>
19
    <%= link_to l('twofa_resend_code'), { action: 'deactivate_init', scheme: @twofa_view[:scheme_name] }, method: :post if @twofa_view[:resendable] -%>
20
  <% end %>
21
</div>
22

  
23
<% content_for :sidebar do %>
24
<%= render :partial => 'my/sidebar' %>
25
<% end %>
app/views/twofa/totp/_new.html.erb
1
<p>
2
  <label>&nbsp;</label>
3
  <%= 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' -%>
4
</p>
5
<p>
6
  <label><%=l 'twofa__totp__label_plain_text_key' -%></label>
7
  <code><%= twofa_view[:totp_key].scan(/.{4}/).join(' ') -%></code>
8
</p>
app/views/users/_form.html.erb
42 42
  <p><%= f.check_box :generate_password %></p>
43 43
  <p><%= f.check_box :must_change_passwd %></p>
44 44
  </div>
45
  <p>
46
    <label><%=l :setting_twofa -%></label>
47
    <% if @user.twofa_active? %>
48
      <%=l 'twofa_currently_active', twofa_scheme_name: l("twofa__#{@user.twofa_scheme}__name") -%><br/>
49
      <% if @user == User.current # administrators cannot deactivate their own 2FA without confirmation code %>
50
        <%= link_to l('button_disable'), { controller: 'twofa', action: 'deactivate_init', scheme: @user.twofa_scheme }, method: :post -%>
51
      <% else %>
52
        <%= link_to l('button_disable'), { controller: 'twofa', action: 'admin_deactivate', user_id: @user }, method: :post -%>
53
      <% end %>
54
    <% else %>
55
      <%=l 'twofa_not_active' %>
56
    <% end %>
57
  </p>
45 58
</fieldset>
46 59
</div>
47 60

  
config/locales/de.yml
156 156
  actionview_instancetag_blank_option: Bitte auswählen
157 157

  
158 158
  button_activate: Aktivieren
159
  button_disable: Deaktivieren
159 160
  button_add: Hinzufügen
160 161
  button_annotate: Annotieren
161 162
  button_apply: Anwenden
......
1321 1322
  field_passwd_changed_on: Password last changed
1322 1323
  label_import_users: Import users
1323 1324
  label_days_to_html: "%{days} days up to %{date}"
1325
  setting_twofa: Zwei-Faktor-Authentifizierung
1326
  twofa__totp__name: Authentifizierungs-App
1327
  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.'
1328
  twofa__totp__label_plain_text_key: Klartext-Schlüssel
1329
  twofa__totp__label_activate: 'Authentifizierungs-App aktivieren'
1330
  twofa_currently_active: "Aktiv: %{twofa_scheme_name}"
1331
  twofa_not_active: "Nicht aktiv"
1332
  twofa_label_code: Code
1333
  twofa_label_setup: Zwei-Faktor-Authentifizierung einrichten
1334
  twofa_label_deactivation_confirmation: Zwei-Faktor-Authentifizierung abschalten
1335
  twofa_activated: Zwei-Faktor-Authentifizierung erfolgreich eingerichtet.
1336
  twofa_deactivated: Zwei-Faktor-Authentifizierung abgeschaltet.
1337
  twofa_mail_body_security_notification_paired: "Zwei-Faktor-Authentifizierung per %{field} eingerichtet."
1338
  twofa_mail_body_security_notification_unpaired: "Zwei-Faktor-Authentifizierung für Ihr Konto abgeschaltet."
1339
  twofa_invalid_code: Der eingegebene Code ist ungültig oder abgelaufen.
1340
  twofa_label_enter_otp: Bitte geben Sie Ihren Code für die Zwei-Faktor-Authentifizierung ein.
1341
  twofa_too_many_tries: Zu viele Versuche.
1342
  twofa_resend_code: Code erneut senden
1343
  twofa_code_sent: Ein Code für die Zwei-Faktor-Authentifizierung wurde Ihnen zugesendet.
config/locales/en.yml
494 494
  setting_timelog_accept_future_dates: Accept time logs on future dates
495 495
  setting_show_status_changes_in_mail_subject: Show status changes in issue mail notifications subject
496 496
  setting_project_list_defaults: Projects list defaults
497
  setting_twofa: Two-factor authentication
497 498

  
498 499
  permission_add_project: Create project
499 500
  permission_add_subprojects: Create subprojects
......
1117 1118
  button_back: Back
1118 1119
  button_cancel: Cancel
1119 1120
  button_activate: Activate
1121
  button_disable: Disable
1120 1122
  button_sort: Sort
1121 1123
  button_log_time: Log time
1122 1124
  button_rollback: Rollback to this version
......
1297 1299
  text_project_is_public_anonymous: Public projects and their contents are openly available on the network.
1298 1300
  label_import_time_entries: Import time entries
1299 1301
  label_import_users: Import users
1302

  
1303
  twofa__totp__name: Authenticator app
1304
  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.'
1305
  twofa__totp__label_plain_text_key: Plain text key
1306
  twofa__totp__label_activate: 'Enable authenticator app'
1307
  twofa_currently_active: "Currently active: %{twofa_scheme_name}"
1308
  twofa_not_active: "Not activated"
1309
  twofa_label_code: Code
1310
  twofa_label_setup: Enable two-factor authentication
1311
  twofa_label_deactivation_confirmation: Disable two-factor authentication
1312
  twofa_activated: Two-factor authentication successfully enabled.
1313
  twofa_deactivated: Two-factor authentication disabled.
1314
  twofa_mail_body_security_notification_paired: "Two-factor authentication successfully enabled using %{field}."
1315
  twofa_mail_body_security_notification_unpaired: "Two-factor authentication disabled for your account."
1316
  twofa_invalid_code: Code is invalid or outdated.
1317
  twofa_label_enter_otp: Please enter your two-factor authentication code.
1318
  twofa_too_many_tries: Too many tries.
1319
  twofa_resend_code: Resend code
1320
  twofa_code_sent: An authentication code has been sent to you.
config/routes.rb
22 22

  
23 23
  match 'login', :to => 'account#login', :as => 'signin', :via => [:get, :post]
24 24
  match 'logout', :to => 'account#logout', :as => 'signout', :via => [:get, :post]
25
  match 'account/twofa/confirm', :to => 'account#twofa_confirm', :via => :get
26
  match 'account/twofa/resend', :to => 'account#twofa_resend', :via => :post
27
  match 'account/twofa', :to => 'account#twofa', :via => [:get, :post]
25 28
  match 'account/register', :to => 'account#register', :via => [:get, :post], :as => 'register'
26 29
  match 'account/lost_password', :to => 'account#lost_password', :via => [:get, :post], :as => 'lost_password'
27 30
  match 'account/activate', :to => 'account#activate', :via => :get
......
85 88
  match 'my/add_block', :controller => 'my', :action => 'add_block', :via => :post
86 89
  match 'my/remove_block', :controller => 'my', :action => 'remove_block', :via => :post
87 90
  match 'my/order_blocks', :controller => 'my', :action => 'order_blocks', :via => :post
91
  match 'my/twofa/:scheme/activate/init', :controller => 'twofa', :action => 'activate_init', :via => :post
92
  match 'my/twofa/:scheme/activate/confirm', :controller => 'twofa', :action => 'activate_confirm', :via => :get
93
  match 'my/twofa/:scheme/activate', :controller => 'twofa', :action => 'activate', :via => [:get, :post]
94
  match 'my/twofa/:scheme/deactivate/init', :controller => 'twofa', :action => 'deactivate_init', :via => :post
95
  match 'my/twofa/:scheme/deactivate/confirm', :controller => 'twofa', :action => 'deactivate_confirm', :via => :get
96
  match 'my/twofa/:scheme/deactivate', :controller => 'twofa', :action => 'deactivate', :via => [:get, :post]
97
  match 'users/:user_id/twofa/deactivate', :controller => 'twofa', :action => 'admin_deactivate', :via => :post
88 98

  
89 99
  resources :users do
90 100
    resources :memberships, :controller => 'principal_memberships'
db/migrate/20200826153401_add_twofa_scheme_to_user.rb
1
class AddTwofaSchemeToUser < ActiveRecord::Migration[5.2]
2
  def change
3
    add_column :users, :twofa_scheme, :string
4
  end
5
end
db/migrate/20200826153402_add_totp_to_user.rb
1
class AddTotpToUser < ActiveRecord::Migration[5.2]
2
  def change
3
    add_column :users, :twofa_totp_key, :string
4
    add_column :users, :twofa_totp_last_used_at, :integer
5
  end
6
end
lib/redmine.rb
68 68
require 'redmine/hook/listener'
69 69
require 'redmine/hook/view_listener'
70 70
require 'redmine/plugin'
71
require 'redmine/twofa'
71 72

  
72 73
Redmine::Scm::Base.add "Subversion"
73 74
Redmine::Scm::Base.add "Mercurial"
lib/redmine/twofa.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2020  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  module Twofa
22
    def self.register_scheme(name, klass)
23
      initialize_schemes
24
      @@schemes[name] = klass
25
    end
26

  
27
    def self.available_schemes
28
      schemes.keys
29
    end
30

  
31
    def self.for_twofa_scheme(name)
32
      schemes[name]
33
    end
34

  
35
    def self.for_user(user)
36
      for_twofa_scheme(user.twofa_scheme).try(:new, user)
37
    end
38

  
39
    def self.schemes
40
      initialize_schemes
41
      @@schemes
42
    end
43
    private_class_method :schemes
44

  
45
    def self.initialize_schemes
46
      @@schemes ||= { }
47
      scan_builtin_schemes if @@schemes.blank?
48
    end
49
    private_class_method :initialize_schemes
50

  
51
    def self.scan_builtin_schemes
52
      Dir[Rails.root.join('lib', 'redmine', 'twofa', '*.rb')].each do |file|
53
        require_dependency file
54
      end
55
    end
56
    private_class_method :scan_builtin_schemes
57
  end
58
end
lib/redmine/twofa/base.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2020  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  module Twofa
22
    class Base
23
      def self.inherited(child)
24
        # require-ing a Base subclass will register it as a 2FA scheme
25
        Redmine::Twofa.register_scheme(scheme_name(child), child)
26
      end
27

  
28
      def self.scheme_name(klass = self)
29
        klass.name.demodulize.underscore
30
      end
31

  
32
      def scheme_name
33
        self.class.scheme_name
34
      end
35

  
36
      def initialize(user)
37
        @user = user
38
      end
39

  
40
      def init_pairing!
41
        @user
42
      end
43

  
44
      def confirm_pairing!(code)
45
        # make sure an otp is used
46
        if verify_otp!(code)
47
          @user.update!(twofa_scheme: scheme_name)
48
          deliver_twofa_paired
49
          return true
50
        else
51
          return false
52
        end
53
      end
54

  
55
      def deliver_twofa_paired
56
        Mailer.security_notification(
57
          @user,
58
          User.current,
59
          {
60
            title: :label_my_account,
61
            message: 'twofa_mail_body_security_notification_paired',
62
            # (mis-)use field here as value wouldn't get localized
63
            field: "twofa__#{scheme_name}__name",
64
            url: { controller: 'my', action: 'account' }
65
          }
66
        ).deliver
67
      end
68

  
69
      def destroy_pairing!(code)
70
        if verify!(code)
71
          destroy_pairing_without_verify!
72
          return true
73
        else
74
          return false
75
        end
76
      end
77

  
78
      def destroy_pairing_without_verify!
79
        @user.update!(twofa_scheme: nil)
80
        deliver_twofa_unpaired
81
      end
82

  
83
      def deliver_twofa_unpaired
84
        Mailer.security_notification(
85
          @user,
86
          User.current,
87
          {
88
            title: :label_my_account,
89
            message: 'twofa_mail_body_security_notification_unpaired',
90
            url: { controller: 'my', action: 'account' }
91
          }
92
        ).deliver
93
      end
94

  
95
      def send_code(controller: nil, action: nil)
96
        # return true only if the scheme sends a code to the user
97
        false
98
      end
99

  
100
      def verify!(code)
101
        verify_otp!(code)
102
      end
103

  
104
      def verify_otp!(code)
105
        raise 'not implemented'
106
      end
107

  
108
      # this will only be used on pairing initialization
109
      def init_pairing_view_variables
110
        otp_confirm_view_variables
111
      end
112

  
113
      def otp_confirm_view_variables
114
        {
115
          scheme_name: scheme_name,
116
          resendable: false
117
        }
118
      end
119

  
120
      private
121

  
122
      def allowed_drift
123
        30
124
      end
125
    end
126
  end
127
end
lib/redmine/twofa/totp.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2020  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  module Twofa
22
    class Totp < Base
23
      def init_pairing!
24
        @user.update!(twofa_totp_key: ROTP::Base32.random)
25
        # reset the cached totp as the key might have changed
26
        @totp = nil
27
        super
28
      end
29

  
30
      def destroy_pairing_without_verify!
31
        @user.update!(twofa_totp_key: nil, twofa_totp_last_used_at: nil)
32
        # reset the cached totp as the key might have changed
33
        @totp = nil
34
        super
35
      end
36

  
37
      def verify_otp!(code)
38
        # topt codes are white-space-insensitive
39
        code = code.to_s.remove(/[[:space:]]/)
40
        last_verified_at = @user.twofa_totp_last_used_at
41
        verified_at = totp.verify(code.to_s, drift_behind: allowed_drift, after: last_verified_at)
42
        if verified_at
43
          @user.update!(twofa_totp_last_used_at: verified_at)
44
          return true
45
        else
46
          return false
47
        end
48
      end
49

  
50
      def provisioning_uri
51
        totp.provisioning_uri(@user.mail)
52
      end
53

  
54
      def init_pairing_view_variables
55
        super.merge({
56
          provisioning_uri: provisioning_uri,
57
          totp_key: @user.twofa_totp_key
58
        })
59
      end
60

  
61
      private
62

  
63
      def totp
64
        @totp ||= ROTP::TOTP.new(@user.twofa_totp_key, issuer: Setting.app_title)
65
      end
66
    end
67
  end
68
end
public/stylesheets/application.css
122 122
#login-form input[type=text], #login-form input[type=password] {margin-bottom: 15px;}
123 123
#login-form a.lost_password {float:right; font-weight:normal;}
124 124
#login-form input#openid_url {background:#fff url(../images/openid-bg.gif) no-repeat 4px 50%; padding-left:24px !important;}
125
#login-form h3 {text-align: center;}
125 126

  
126 127
div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
127 128
div.modal h3.title {display:none;}
......
793 794

  
794 795
.tabular input, .tabular select {max-width:95%}
795 796
.tabular textarea {width:95%; resize:vertical;}
797
input#twofa_code, img#twofa_code { width: 140px; }
796 798

  
797 799
.tabular label{
798 800
  font-weight: bold;
public/stylesheets/responsive.css
664 664

  
665 665
  #login-form input#username,
666 666
  #login-form input#password,
667
  #login-form input#openid_url {
667
  #login-form input#openid_url,
668
  #login-form input#twofa_code {
668 669
    width: 100%;
669 670
    height: auto;
670 671
  }
(19-19/22)