Project

General

Profile

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

Jens Krämer, 2019-08-20 08:43

View differences:

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

  
21
# TOTP-based 2-factor authentication
22
gem 'rotp'
23
gem 'rqrcode'
24

  
21 25
# Optional gem for LDAP authentication
22 26
group :ldap do
23 27
  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
    unless @user.present?
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
class TwofaController < ApplicationController
2
  self.main_menu = false
3

  
4
  before_action :require_login
5
  before_action :require_admin, only: :admin_deactivate
6

  
7
  require_sudo_mode :activate_init, :deactivate_init
8

  
9
  before_action :activate_setup, only: [:activate_init, :activate_confirm, :activate]
10

  
11
  def activate_init
12
    @twofa.init_pairing!
13
    if @twofa.send_code(controller: 'twofa', action: 'activate')
14
      flash[:notice] = l('twofa_code_sent')
15
    end
16
    redirect_to action: :activate_confirm, scheme: @twofa.scheme_name
17
  end
18

  
19
  def activate_confirm
20
    @twofa_view = @twofa.init_pairing_view_variables
21
  end
22

  
23
  def activate
24
    if @twofa.confirm_pairing!(params[:twofa_code].to_s)
25
      flash[:notice] = l('twofa_activated')
26
      redirect_to my_account_path
27
    else
28
      flash[:error] = l('twofa_invalid_code')
29
      redirect_to action: :activate_confirm, scheme: @twofa.scheme_name
30
    end
31
  end
32

  
33
  before_action :deactivate_setup, only: [:deactivate_init, :deactivate_confirm, :deactivate]
34

  
35
  def deactivate_init
36
    if @twofa.send_code(controller: 'twofa', action: 'deactivate')
37
      flash[:notice] = l('twofa_code_sent')
38
    end
39
    redirect_to action: :deactivate_confirm, scheme: @twofa.scheme_name
40
  end
41

  
42
  def deactivate_confirm
43
    @twofa_view = @twofa.otp_confirm_view_variables
44
  end
45

  
46
  def deactivate
47
    if @twofa.destroy_pairing!(params[:twofa_code].to_s)
48
      flash[:notice] = l('twofa_deactivated')
49
      redirect_to my_account_path
50
    else
51
      flash[:error] = l('twofa_invalid_code')
52
      redirect_to action: :deactivate_confirm, scheme: @twofa.scheme_name
53
    end
54
  end
55

  
56
  def admin_deactivate
57
    @user = User.find(params[:user_id])
58
    # do not allow administrators to unpair 2FA without confirmation for themselves
59
    (render_403; return false) if @user == User.current
60

  
61
    twofa = Redmine::Twofa.for_user(@user)
62
    twofa.destroy_pairing_without_verify!
63
    flash[:notice] = l('twofa_deactivated')
64
    redirect_to edit_user_path(@user)
65
  end
66

  
67
  private
68

  
69
  def activate_setup
70
    twofa_scheme = Redmine::Twofa.for_twofa_scheme(params[:scheme].to_s)
71

  
72
    unless twofa_scheme.present?
73
      redirect_to my_account_path
74
      return
75
    end
76
    @user = User.current
77
    @twofa = twofa_scheme.new(@user)
78
  end
79

  
80
  def deactivate_setup
81
    @user = User.current
82
    @twofa = Redmine::Twofa.for_user(@user)
83
    if params[:scheme].to_s != @twofa.scheme_name
84
      redirect_to my_account_path
85
    end
86
  end
87
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
......
376 377
    self
377 378
  end
378 379

  
380
  def twofa_active?
381
    twofa_scheme.present?
382
  end
383

  
379 384
  def pref
380 385
    self.preference ||= UserPreference.new(:user => self)
381 386
  end
......
436 441
    Token.where(:user_id => id, :action => 'autologin', :value => value).delete_all
437 442
  end
438 443

  
444
  def twofa_totp_key
445
    read_ciphered_attribute(:twofa_totp_key)
446
  end
447

  
448
  def twofa_totp_key=(key)
449
    write_ciphered_attribute(:twofa_totp_key, key)
450
  end
451

  
439 452
  # Returns true if token is a valid session token for the user whose id is user_id
440 453
  def self.verify_session_token(user_id, token)
441 454
    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
37 37
  <p><%= f.check_box :generate_password %></p>
38 38
  <p><%= f.check_box :must_change_passwd %></p>
39 39
  </div>
40
  <p>
41
    <label><%=l :setting_twofa -%></label>
42
    <% if @user.twofa_active? %>
43
      <%=l 'twofa_currently_active', twofa_scheme_name: l("twofa__#{@user.twofa_scheme}__name") -%><br/>
44
      <% if @user == User.current # administrators cannot deactivate their own 2FA without confirmation code %>
45
        <%= link_to l('button_disable'), { controller: 'twofa', action: 'deactivate_init', scheme: @user.twofa_scheme }, method: :post -%>
46
      <% else %>
47
        <%= link_to l('button_disable'), { controller: 'twofa', action: 'admin_deactivate', user_id: @user }, method: :post -%>
48
      <% end %>
49
    <% else %>
50
      <%=l 'twofa_not_active' %>
51
    <% end %>
52
  </p>
40 53
</fieldset>
41 54
</div>
42 55

  
config/locales/de.yml
152 152
  actionview_instancetag_blank_option: Bitte auswählen
153 153

  
154 154
  button_activate: Aktivieren
155
  button_disable: Deaktivieren
155 156
  button_add: Hinzufügen
156 157
  button_annotate: Annotieren
157 158
  button_apply: Anwenden
......
1288 1289
  label_issue_history_properties: Property changes
1289 1290
  label_issue_history_notes: Notes
1290 1291
  label_last_tab_visited: Last visited tab
1292
  setting_twofa: Zwei-Faktor-Authentifizierung
1293
  twofa__totp__name: Authentifizierungs-App
1294
  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.'
1295
  twofa__totp__label_plain_text_key: Klartext-Schlüssel
1296
  twofa__totp__label_activate: 'Authentifizierungs-App aktivieren'
1297
  twofa_currently_active: "Aktiv: %{twofa_scheme_name}"
1298
  twofa_not_active: "Nicht aktiv"
1299
  twofa_label_code: Code
1300
  twofa_label_setup: Zwei-Faktor-Authentifizierung einrichten
1301
  twofa_label_deactivation_confirmation: Zwei-Faktor-Authentifizierung abschalten
1302
  twofa_activated: Zwei-Faktor-Authentifizierung erfolgreich eingerichtet.
1303
  twofa_deactivated: Zwei-Faktor-Authentifizierung abgeschaltet.
1304
  twofa_mail_body_security_notification_paired: "Zwei-Faktor-Authentifizierung per %{field} eingerichtet."
1305
  twofa_mail_body_security_notification_unpaired: "Zwei-Faktor-Authentifizierung für Ihr Konto abgeschaltet."
1306
  twofa_invalid_code: Der eingegebene Code ist ungültig oder abgelaufen.
1307
  twofa_label_enter_otp: Bitte geben Sie Ihren Code für die Zwei-Faktor-Authentifizierung ein.
1308
  twofa_too_many_tries: Zu viele Versuche.
1309
  twofa_resend_code: Code erneut senden
1310
  twofa_code_sent: Ein Code für die Zwei-Faktor-Authentifizierung wurde Ihnen zugesendet.
config/locales/en.yml
1089 1089
  button_back: Back
1090 1090
  button_cancel: Cancel
1091 1091
  button_activate: Activate
1092
  button_disable: Disable
1092 1093
  button_sort: Sort
1093 1094
  button_log_time: Log time
1094 1095
  button_rollback: Rollback to this version
......
1266 1267
  label_login_required_no: "No, allow anonymous access to public projects"
1267 1268
  text_project_is_public_non_member: Public projects and their contents are available to all logged-in users.
1268 1269
  text_project_is_public_anonymous: Public projects and their contents are openly available on the network.
1270

  
1271
  setting_twofa: Two-factor authentication
1272
  twofa__totp__name: Authenticator app
1273
  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.'
1274
  twofa__totp__label_plain_text_key: Plain text key
1275
  twofa__totp__label_activate: 'Enable authenticator app'
1276
  twofa_currently_active: "Currently active: %{twofa_scheme_name}"
1277
  twofa_not_active: "Not activated"
1278
  twofa_label_code: Code
1279
  twofa_label_setup: Enable two-factor authentication
1280
  twofa_label_deactivation_confirmation: Disable two-factor authentication
1281
  twofa_activated: Two-factor authentication successfully enabled.
1282
  twofa_deactivated: Two-factor authentication disabled.
1283
  twofa_mail_body_security_notification_paired: "Two-factor authentication successfully enabled using %{field}."
1284
  twofa_mail_body_security_notification_unpaired: "Two-factor authentication disabled for your account."
1285
  twofa_invalid_code: Code is invalid or outdated.
1286
  twofa_label_enter_otp: Please enter your two-factor authentication code.
1287
  twofa_too_many_tries: Too many tries.
1288
  twofa_resend_code: Resend code
1289
  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
......
84 87
  match 'my/add_block', :controller => 'my', :action => 'add_block', :via => :post
85 88
  match 'my/remove_block', :controller => 'my', :action => 'remove_block', :via => :post
86 89
  match 'my/order_blocks', :controller => 'my', :action => 'order_blocks', :via => :post
90
  match 'my/twofa/:scheme/activate/init', :controller => 'twofa', :action => 'activate_init', :via => :post
91
  match 'my/twofa/:scheme/activate/confirm', :controller => 'twofa', :action => 'activate_confirm', :via => :get
92
  match 'my/twofa/:scheme/activate', :controller => 'twofa', :action => 'activate', :via => [:get, :post]
93
  match 'my/twofa/:scheme/deactivate/init', :controller => 'twofa', :action => 'deactivate_init', :via => :post
94
  match 'my/twofa/:scheme/deactivate/confirm', :controller => 'twofa', :action => 'deactivate_confirm', :via => :get
95
  match 'my/twofa/:scheme/deactivate', :controller => 'twofa', :action => 'deactivate', :via => [:get, :post]
96
  match 'users/:user_id/twofa/deactivate', :controller => 'twofa', :action => 'admin_deactivate', :via => :post
87 97

  
88 98
  resources :users do
89 99
    resources :memberships, :controller => 'principal_memberships'
db/migrate/20170711134351_add_twofa_scheme_to_user.rb
1
class AddTwofaSchemeToUser < ActiveRecord::Migration[4.2]
2
  def change
3
    add_column :users, :twofa_scheme, :string
4
  end
5
end
db/migrate/20170711134352_add_totp_to_user.rb
1
class AddTotpToUser < ActiveRecord::Migration[4.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
module Redmine
2
  module Twofa
3
    def self.register_scheme(name, klass)
4
      initialize_schemes
5
      @@schemes[name] = klass
6
    end
7

  
8
    def self.available_schemes
9
      schemes.keys
10
    end
11

  
12
    def self.for_twofa_scheme(name)
13
      schemes[name]
14
    end
15

  
16
    def self.for_user(user)
17
      for_twofa_scheme(user.twofa_scheme).try(:new, user)
18
    end
19

  
20
    private
21

  
22
    def self.schemes
23
      initialize_schemes
24
      @@schemes
25
    end
26

  
27
    def self.initialize_schemes
28
      @@schemes ||= { }
29
      scan_builtin_schemes if @@schemes.blank?
30
    end
31

  
32
    def self.scan_builtin_schemes
33
      Dir[Rails.root.join('lib', 'redmine', 'twofa', '*.rb')].each do |file|
34
        require_dependency file
35
      end
36
    end
37
  end
38
end
lib/redmine/twofa/base.rb
1
module Redmine
2
  module Twofa
3
    class Base
4
      def self.inherited(child)
5
        # require-ing a Base subclass will register it as a 2FA scheme
6
        Redmine::Twofa.register_scheme(scheme_name(child), child)
7
      end
8

  
9
      def self.scheme_name(klass = self)
10
        klass.name.demodulize.underscore
11
      end
12

  
13
      def scheme_name
14
        self.class.scheme_name
15
      end
16

  
17
      def initialize(user)
18
        @user = user
19
      end
20

  
21
      def init_pairing!
22
        @user
23
      end
24

  
25
      def confirm_pairing!(code)
26
        # make sure an otp is used
27
        if verify_otp!(code)
28
          @user.update!(twofa_scheme: scheme_name)
29
          deliver_twofa_paired
30
          return true
31
        else
32
          return false
33
        end
34
      end
35

  
36
      def deliver_twofa_paired
37
        Mailer.security_notification(
38
          @user,
39
          User.current,
40
          {
41
            title: :label_my_account,
42
            message: 'twofa_mail_body_security_notification_paired',
43
            # (mis-)use field here as value wouldn't get localized
44
            field: "twofa__#{scheme_name}__name",
45
            url: { controller: 'my', action: 'account' }
46
          }
47
        ).deliver
48
      end
49

  
50
      def destroy_pairing!(code)
51
        if verify!(code)
52
          destroy_pairing_without_verify!
53
          return true
54
        else
55
          return false
56
        end
57
      end
58

  
59
      def destroy_pairing_without_verify!
60
        @user.update!(twofa_scheme: nil)
61
        deliver_twofa_unpaired
62
      end
63

  
64
      def deliver_twofa_unpaired
65
        Mailer.security_notification(
66
          @user,
67
          User.current,
68
          {
69
            title: :label_my_account,
70
            message: 'twofa_mail_body_security_notification_unpaired',
71
            url: { controller: 'my', action: 'account' }
72
          }
73
        ).deliver
74
      end
75

  
76
      def send_code(controller: nil, action: nil)
77
        # return true only if the scheme sends a code to the user
78
        false
79
      end
80

  
81
      def verify!(code)
82
        verify_otp!(code)
83
      end
84

  
85
      def verify_otp!(code)
86
        raise 'not implemented'
87
      end
88

  
89
      # this will only be used on pairing initialization
90
      def init_pairing_view_variables
91
        otp_confirm_view_variables
92
      end
93

  
94
      def otp_confirm_view_variables
95
        {
96
          scheme_name: scheme_name,
97
          resendable: false
98
        }
99
      end
100

  
101
      private
102

  
103
      def allowed_drift
104
        30
105
      end
106
    end
107
  end
108
end
lib/redmine/twofa/totp.rb
1
module Redmine
2
  module Twofa
3
    class Totp < Base
4
      def init_pairing!
5
        @user.update!(twofa_totp_key: ROTP::Base32.random)
6
        # reset the cached totp as the key might have changed
7
        @totp = nil
8
        super
9
      end
10

  
11
      def destroy_pairing_without_verify!
12
        @user.update!(twofa_totp_key: nil, twofa_totp_last_used_at: nil)
13
        # reset the cached totp as the key might have changed
14
        @totp = nil
15
        super
16
      end
17

  
18
      def verify_otp!(code)
19
        # topt codes are white-space-insensitive
20
        code = code.to_s.remove(/[[:space:]]/)
21
        last_verified_at = @user.twofa_totp_last_used_at
22
        verified_at = totp.verify(code.to_s, drift_behind: allowed_drift, after: last_verified_at)
23
        if verified_at
24
          @user.update!(twofa_totp_last_used_at: verified_at)
25
          return true
26
        else
27
          return false
28
        end
29
      end
30

  
31
      def provisioning_uri
32
        totp.provisioning_uri(@user.mail)
33
      end
34

  
35
      def init_pairing_view_variables
36
        super.merge({
37
          provisioning_uri: provisioning_uri,
38
          totp_key: @user.twofa_totp_key
39
        })
40
      end
41

  
42
      private
43

  
44
      def totp
45
        @totp ||= ROTP::TOTP.new(@user.twofa_totp_key, issuer: Setting.app_title)
46
      end
47
    end
48
  end
49
end
public/stylesheets/application.css
108 108
#login-form input[type=text], #login-form input[type=password] {margin-bottom: 15px;}
109 109
#login-form a.lost_password {float:right; font-weight:normal;}
110 110
#login-form input#openid_url {background:#fff url(../images/openid-bg.gif) no-repeat 4px 50%; padding-left:24px !important;}
111
#login-form h3 {text-align: center;}
111 112

  
112 113
div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
113 114
div.modal h3.title {display:none;}
......
748 749

  
749 750
.tabular input, .tabular select {max-width:95%}
750 751
.tabular textarea {width:95%; resize:vertical;}
752
input#twofa_code, img#twofa_code { width: 140px; }
751 753

  
752 754
.tabular label{
753 755
  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
  }
(18-18/22)