Project

General

Profile

Feature #1237 » 0001-2-factor-authentication-using-TOTP.patch

Felix Schäfer, 2018-01-02 18:42

View differences:

Gemfile
24 24
gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin]
25 25
gem "rbpdf", "~> 1.19.3"
26 26

  
27
# TOTP-based 2-factor authentication
28
gem 'rotp'
29
gem 'rqrcode'
30

  
27 31
# Optional gem for LDAP authentication
28 32
group :ldap do
29 33
  gem "net-ldap", "~> 0.16.0"
app/controllers/account_controller.rb
196 196
    redirect_to(home_url)
197 197
  end
198 198

  
199
  before_action :require_active_twofa, :twofa_setup, only: [:twofa_resend, :twofa_confirm, :twofa]
200
  before_action :prevent_twofa_session_replay, only: [:twofa_resend, :twofa]
201

  
202
  def twofa_resend
203
    # otp resends count toward the maximum of 3 otp entry tries per password entry
204
    if session[:twofa_tries_counter] > 3
205
      destroy_twofa_session
206
      flash[:error] = l('twofa_too_many_tries')
207
      redirect_to home_url
208
    else
209
      if @twofa.send_code(controller: 'account', action: 'twofa')
210
        flash[:notice] = l('twofa_code_sent')
211
      end
212
      redirect_to account_twofa_confirm_path
213
    end
214
  end
215

  
216
  def twofa_confirm
217
    @twofa_view = @twofa.otp_confirm_view_variables
218
  end
219

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

  
199 237
  private
200 238

  
239
  def prevent_twofa_session_replay
240
    renew_twofa_session(@user)
241
  end
242

  
243
  def twofa_setup
244
    # twofa sessions are only valid 2 minutes at a time
245
    twomind = 0.0014 # a little more than 2 minutes in days
246
    @user = Token.find_active_user('twofa_session', session[:twofa_session_token].to_s, twomind)
247
    unless @user.present?
248
      destroy_twofa_session
249
      redirect_to home_url
250
      return
251
    end
252

  
253
    # copy back_url, autologin back to params where they are expected
254
    params[:back_url] ||= session[:twofa_back_url]
255
    params[:autologin] ||= session[:twofa_autologin]
256

  
257
    # set locale for the twofa user
258
    set_localization(@user)
259

  
260
    @twofa = Redmine::Twofa.for_user(@user)
261
  end
262

  
263
  def require_active_twofa
264
    Setting.twofa? ? true : deny_access
265
  end
266

  
267
  def setup_twofa_session(user, previous_tries=1)
268
    token = Token.create(user: user, action: 'twofa_session')
269
    session[:twofa_session_token] = token.value
270
    session[:twofa_tries_counter] = previous_tries
271
    session[:twofa_back_url] = params[:back_url]
272
    session[:twofa_autologin] = params[:autologin]
273
  end
274

  
275
  # Prevent replay attacks by using each twofa_session_token only for exactly one request
276
  def renew_twofa_session(user)
277
    twofa_tries = session[:twofa_tries_counter].to_i + 1
278
    destroy_twofa_session
279
    setup_twofa_session(user, twofa_tries)
280
  end
281

  
282
  def destroy_twofa_session
283
    # make sure tokens can only be used once server-side to prevent replay attacks
284
    Token.find_token('twofa_session', session[:twofa_session_token].to_s).try(:delete)
285
    session[:twofa_session_token] = nil
286
    session[:twofa_tries_counter] = nil
287
    session[:twofa_back_url] = nil
288
    session[:twofa_autologin] = nil
289
  end
290

  
201 291
  def authenticate_user
202 292
    if Setting.openid? && using_open_id?
203 293
      open_id_authenticate(params[:openid_url])
......
216 306
    else
217 307
      # Valid user
218 308
      if user.active?
219
        successful_authentication(user)
220
        update_sudo_timestamp! # activate Sudo Mode
309
        if user.twofa_active?
310
          setup_twofa_session user
311
          twofa = Redmine::Twofa.for_user(user)
312
          if twofa.send_code(controller: 'account', action: 'twofa')
313
            flash[:notice] = l('twofa_code_sent')
314
          end
315
          redirect_to account_twofa_confirm_path
316
        else
317
          handle_active_user(user)
318
        end
221 319
      else
222 320
        handle_inactive_user(user)
223 321
      end
224 322
    end
225 323
  end
226 324

  
325
  def handle_active_user(user)
326
    successful_authentication(user)
327
    update_sudo_timestamp! # activate Sudo Mode
328
  end
329

  
227 330
  def open_id_authenticate(openid_url)
228 331
    back_url = signin_url(:autologin => params[:autologin])
229 332
    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
18 18
require "digest/sha1"
19 19

  
20 20
class User < Principal
21
  include Redmine::Ciphering
21 22
  include Redmine::SafeAttributes
22 23

  
23 24
  # Different ways of displaying/sorting users
......
368 369
    self
369 370
  end
370 371

  
372
  def twofa_active?
373
    twofa_scheme.present?
374
  end
375

  
371 376
  def pref
372 377
    self.preference ||= UserPreference.new(:user => self)
373 378
  end
......
428 433
    Token.where(:user_id => id, :action => 'autologin', :value => value).delete_all
429 434
  end
430 435

  
436
  def twofa_totp_key
437
    read_ciphered_attribute(:twofa_totp_key)
438
  end
439

  
440
  def twofa_totp_key=(key)
441
    write_ciphered_attribute(:twofa_totp_key, key)
442
  end
443

  
431 444
  # Returns true if token is a valid session token for the user whose id is user_id
432 445
  def self.verify_session_token(user_id, token)
433 446
    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/account.html.erb
27 27
  <% if Setting.openid? %>
28 28
  <p><%= f.text_field :identity_url  %></p>
29 29
  <% end %>
30
  <p>
31
    <label><%=l :setting_twofa -%></label>
32
    <% if @user.twofa_active? %>
33
      <%=l 'twofa_currently_active', twofa_scheme_name: l("twofa__#{@user.twofa_scheme}__name") -%><br/>
34
      <%= link_to l('button_disable'), { controller: 'twofa', action: 'deactivate_init', scheme: @user.twofa_scheme }, method: :post -%><br/>
35
    <% else %>
36
      <% Redmine::Twofa.available_schemes.each do |s| %>
37
        <%= link_to l("twofa__#{s}__label_activate"), { controller: 'twofa', action: 'activate_init', scheme: s }, method: :post -%><br/>
38
      <% end %>
39
    <% end %>
40
  </p>
30 41

  
31 42
  <% @user.custom_field_values.select(&:editable?).each do |value| %>
32 43
    <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
36 36
  <p><%= f.check_box :generate_password %></p>
37 37
  <p><%= f.check_box :must_change_passwd %></p>
38 38
  </div>
39
  <p>
40
    <label><%=l :setting_twofa -%></label>
41
    <% if @user.twofa_active? %>
42
      <%=l 'twofa_currently_active', twofa_scheme_name: l("twofa__#{@user.twofa_scheme}__name") -%><br/>
43
      <% if @user == User.current # administrators cannot deactivate their own 2FA without confirmation code %>
44
        <%= link_to l('button_disable'), { controller: 'twofa', action: 'deactivate_init', scheme: @user.twofa_scheme }, method: :post -%>
45
      <% else %>
46
        <%= link_to l('button_disable'), { controller: 'twofa', action: 'admin_deactivate', user_id: @user }, method: :post -%>
47
      <% end %>
48
    <% else %>
49
      <%=l 'twofa_not_active' %>
50
    <% end %>
51
  </p>
39 52
</fieldset>
40 53
</div>
41 54

  
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
......
1234 1235
  setting_timelog_accept_0_hours: Accept time logs with 0 hours
1235 1236
  setting_timelog_max_hours_per_day: Maximum hours that can be logged per day and user
1236 1237
  label_x_revisions: "%{count} revisions"
1238

  
1239
  setting_twofa: Zwei-Faktor-Authentifizierung
1240
  twofa__totp__name: Authentifizierungs-App
1241
  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.'
1242
  twofa__totp__label_plain_text_key: Klartext-Schlüssel
1243
  twofa__totp__label_activate: 'Authentifizierungs-App aktivieren'
1244
  twofa_currently_active: "Aktiv: %{twofa_scheme_name}"
1245
  twofa_not_active: "Nicht aktiv"
1246
  twofa_label_code: Code
1247
  twofa_label_setup: Zwei-Faktor-Authentifizierung einrichten
1248
  twofa_label_deactivation_confirmation: Zwei-Faktor-Authentifizierung abschalten
1249
  twofa_activated: Zwei-Faktor-Authentifizierung erfolgreich eingerichtet.
1250
  twofa_deactivated: Zwei-Faktor-Authentifizierung abgeschaltet.
1251
  twofa_mail_body_security_notification_paired: "Zwei-Faktor-Authentifizierung per %{field} eingerichtet."
1252
  twofa_mail_body_security_notification_unpaired: "Zwei-Faktor-Authentifizierung für Ihr Konto abgeschaltet."
1253
  twofa_invalid_code: Der eingegebene Code ist ungültig oder abgelaufen.
1254
  twofa_label_enter_otp: Bitte geben Sie Ihren Code für die Zwei-Faktor-Authentifizierung ein.
1255
  twofa_too_many_tries: Zu viele Versuche.
1256
  twofa_resend_code: Code erneut senden
1257
  twofa_code_sent: Ein Code für die Zwei-Faktor-Authentifizierung wurde Ihnen zugesendet.
config/locales/en.yml
1053 1053
  button_back: Back
1054 1054
  button_cancel: Cancel
1055 1055
  button_activate: Activate
1056
  button_disable: Disable
1056 1057
  button_sort: Sort
1057 1058
  button_log_time: Log time
1058 1059
  button_rollback: Rollback to this version
......
1215 1216
  description_issue_category_reassign: Choose issue category
1216 1217
  description_wiki_subpages_reassign: Choose new parent page
1217 1218
  text_repository_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.<br />Once saved, the identifier cannot be changed.'
1219

  
1220
  setting_twofa: Two-factor authentication
1221
  twofa__totp__name: Authenticator app
1222
  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.'
1223
  twofa__totp__label_plain_text_key: Plain text key
1224
  twofa__totp__label_activate: 'Enable authenticator app'
1225
  twofa_currently_active: "Currently active: %{twofa_scheme_name}"
1226
  twofa_not_active: "Not activated"
1227
  twofa_label_code: Code
1228
  twofa_label_setup: Enable two-factor authentication
1229
  twofa_label_deactivation_confirmation: Disable two-factor authentication
1230
  twofa_activated: Two-factor authentication successfully enabled.
1231
  twofa_deactivated: Two-factor authentication disabled.
1232
  twofa_mail_body_security_notification_paired: "Two-factor authentication successfully enabled using %{field}."
1233
  twofa_mail_body_security_notification_unpaired: "Two-factor authentication disabled for your account."
1234
  twofa_invalid_code: Code is invalid or outdated.
1235
  twofa_label_enter_otp: Please enter your two-factor authentication code.
1236
  twofa_too_many_tries: Too many tries.
1237
  twofa_resend_code: Resend code
1238
  twofa_code_sent: An authentication code has been sent to you.
config/routes.rb
20 20

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

  
86 96
  resources :users do
87 97
    resources :memberships, :controller => 'principal_memberships'
......
156 166
        end
157 167
      end
158 168
    end
159
  
169

  
160 170
    match 'wiki/index', :controller => 'wiki', :action => 'index', :via => :get
161 171
    resources :wiki, :except => [:index, :create], :as => 'wiki_page' do
162 172
      member do
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/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
          {
40
            title: :label_my_account,
41
            message: 'twofa_mail_body_security_notification_paired',
42
            # (mis-)use field here as value wouldn't get localized
43
            field: "twofa__#{scheme_name}__name",
44
            url: { controller: 'my', action: 'account' }
45
          }
46
        ).deliver
47
      end
48

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

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

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

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

  
79
      def verify!(code)
80
        verify_otp!(code)
81
      end
82

  
83
      def verify_otp!(code)
84
        raise 'not implemented'
85
      end
86

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

  
92
      def otp_confirm_view_variables
93
        {
94
          scheme_name: scheme_name,
95
          resendable: false
96
        }
97
      end
98

  
99
      private
100

  
101
      def allowed_drift
102
        30
103
      end
104
    end
105
  end
106
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_base32)
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_with_drift_and_prior(code.to_s, allowed_drift, 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
111 111
#login-form a.lost_password {float:right; font-weight:normal;}
112 112
#login-form input#openid_url {background:#fff url(../images/openid-bg.gif) no-repeat 4px 50%; padding-left:24px !important;}
113 113
#login-form input#login-submit {margin-top:15px; padding:7px; display:block; width:100%; box-sizing: border-box;}
114
#login-form h3 {text-align: center;}
114 115

  
115 116
div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
116 117
div.modal h3.title {display:none;}
......
670 671

  
671 672
.tabular input, .tabular select {max-width:95%}
672 673
.tabular textarea {width:95%; resize:vertical;}
674
input#twofa_code, img#twofa_code { width: 140px; }
673 675

  
674 676
.tabular label{
675 677
  font-weight: bold;
public/stylesheets/responsive.css
656 656

  
657 657
  #login-form input#username,
658 658
  #login-form input#password,
659
  #login-form input#openid_url {
659
  #login-form input#openid_url,
660
  #login-form input#twofa_code {
660 661
    width: 100%;
661 662
    height: auto;
662 663
  }
(2-2/22)