rodauth 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +24 -0
  3. data/README.rdoc +20 -11
  4. data/doc/base.rdoc +2 -2
  5. data/doc/email_auth.rdoc +53 -0
  6. data/doc/email_base.rdoc +4 -0
  7. data/doc/internals.rdoc +3 -3
  8. data/doc/lockout.rdoc +28 -48
  9. data/doc/login.rdoc +4 -4
  10. data/doc/otp.rdoc +1 -3
  11. data/doc/release_notes/1.19.0.txt +116 -0
  12. data/doc/reset_password.rdoc +29 -49
  13. data/doc/verify_account.rdoc +30 -50
  14. data/doc/verify_login_change.rdoc +4 -0
  15. data/lib/rodauth/features/base.rb +0 -1
  16. data/lib/rodauth/features/change_login.rb +4 -0
  17. data/lib/rodauth/features/disallow_common_passwords.rb +1 -1
  18. data/lib/rodauth/features/email_auth.rb +253 -0
  19. data/lib/rodauth/features/email_base.rb +2 -0
  20. data/lib/rodauth/features/lockout.rb +35 -6
  21. data/lib/rodauth/features/login.rb +46 -9
  22. data/lib/rodauth/features/otp.rb +8 -4
  23. data/lib/rodauth/features/recovery_codes.rb +0 -2
  24. data/lib/rodauth/features/remember.rb +1 -1
  25. data/lib/rodauth/features/reset_password.rb +32 -4
  26. data/lib/rodauth/features/sms_codes.rb +2 -8
  27. data/lib/rodauth/features/two_factor_base.rb +22 -15
  28. data/lib/rodauth/features/verify_account.rb +27 -1
  29. data/lib/rodauth/features/verify_login_change.rb +30 -7
  30. data/lib/rodauth/migrations.rb +2 -8
  31. data/lib/rodauth/version.rb +1 -1
  32. data/spec/email_auth_spec.rb +285 -0
  33. data/spec/lockout_spec.rb +24 -2
  34. data/spec/login_spec.rb +47 -1
  35. data/spec/migrate/001_tables.rb +13 -0
  36. data/spec/migrate_travis/001_tables.rb +10 -0
  37. data/spec/reset_password_spec.rb +20 -2
  38. data/spec/two_factor_spec.rb +46 -0
  39. data/spec/verify_account_grace_period_spec.rb +1 -1
  40. data/spec/verify_account_spec.rb +33 -3
  41. data/spec/verify_login_change_spec.rb +54 -1
  42. data/templates/email-auth-email.str +5 -0
  43. data/templates/email-auth-request-form.str +7 -0
  44. data/templates/email-auth.str +5 -0
  45. data/templates/login-display.str +4 -0
  46. data/templates/login.str +2 -2
  47. data/templates/otp-setup.str +13 -11
  48. metadata +12 -2
@@ -6,6 +6,8 @@ module Rodauth
6
6
  auth_value_method :require_mail?, true
7
7
  auth_value_method :token_separator, "_"
8
8
 
9
+ redirect :default_post_email
10
+
9
11
  auth_value_methods(
10
12
  :email_from
11
13
  )
@@ -11,16 +11,19 @@ module Rodauth
11
11
  before 'unlock_account_request'
12
12
  after 'unlock_account'
13
13
  after 'unlock_account_request'
14
+ after 'account_lockout'
14
15
  additional_form_tags 'unlock_account'
15
16
  additional_form_tags 'unlock_account_request'
16
17
  button 'Unlock Account', 'unlock_account'
17
18
  button 'Request Account Unlock', 'unlock_account_request'
18
19
  error_flash "There was an error unlocking your account", 'unlock_account'
19
20
  error_flash "This account is currently locked out and cannot be logged in to.", "login_lockout"
21
+ error_flash "An email has recently been sent to you with a link to unlock the account", 'unlock_account_email_recently_sent'
20
22
  notice_flash "Your account has been unlocked", 'unlock_account'
21
23
  notice_flash "An email has been sent to you with a link to unlock your account", 'unlock_account_request'
22
24
  redirect :unlock_account
23
- redirect :unlock_account_request
25
+ redirect(:unlock_account_request){default_post_email_redirect}
26
+ redirect(:unlock_account_email_recently_sent){default_post_email_redirect}
24
27
 
25
28
  auth_value_method :unlock_account_autologin?, true
26
29
  auth_value_method :max_invalid_logins, 100
@@ -30,26 +33,26 @@ module Rodauth
30
33
  auth_value_method :account_lockouts_table, :account_lockouts
31
34
  auth_value_method :account_lockouts_id_column, :id
32
35
  auth_value_method :account_lockouts_key_column, :key
36
+ auth_value_method :account_lockouts_email_last_sent_column, nil
33
37
  auth_value_method :account_lockouts_deadline_column, :deadline
34
38
  auth_value_method :account_lockouts_deadline_interval, {:days=>1}
35
39
  auth_value_method :no_matching_unlock_account_key_message, 'No matching unlock account key'
36
40
  auth_value_method :unlock_account_email_subject, 'Unlock Account'
37
41
  auth_value_method :unlock_account_key_param, 'key'
38
42
  auth_value_method :unlock_account_requires_password?, false
43
+ auth_value_method :unlock_account_skip_resend_email_within, 300
39
44
  session_key :unlock_account_session_key, :unlock_account_key
40
45
 
41
- auth_value_methods(
42
- :unlock_account_redirect,
43
- :unlock_account_request_redirect
44
- )
45
46
  auth_methods(
46
47
  :clear_invalid_login_attempts,
47
48
  :create_unlock_account_email,
48
49
  :generate_unlock_account_key,
49
50
  :get_unlock_account_key,
51
+ :get_unlock_account_email_last_sent,
50
52
  :invalid_login_attempted,
51
53
  :locked_out?,
52
54
  :send_unlock_account_email,
55
+ :set_unlock_account_email_last_sent,
53
56
  :unlock_account_email_body,
54
57
  :unlock_account_email_link,
55
58
  :unlock_account,
@@ -63,8 +66,14 @@ module Rodauth
63
66
 
64
67
  r.post do
65
68
  if account_from_login(param(login_param)) && get_unlock_account_key
69
+ if unlock_account_email_recently_sent?
70
+ set_redirect_error_flash unlock_account_email_recently_sent_error_flash
71
+ redirect unlock_account_email_recently_sent_redirect
72
+ end
73
+
66
74
  transaction do
67
75
  before_unlock_account_request
76
+ set_unlock_account_email_last_sent
68
77
  send_unlock_account_email
69
78
  after_unlock_account_request
70
79
  end
@@ -186,7 +195,11 @@ module Rodauth
186
195
  # key out of it. If that doesn't return a valid key, we should reraise the error.
187
196
  raise e unless @unlock_account_key_value = account_lockouts_ds.get(account_lockouts_key_column)
188
197
 
198
+ after_account_lockout
189
199
  show_lockout_page
200
+ else
201
+ after_account_lockout
202
+ e
190
203
  end
191
204
  end
192
205
  end
@@ -208,6 +221,18 @@ module Rodauth
208
221
  token_link(unlock_account_route, unlock_account_key_param, unlock_account_key_value)
209
222
  end
210
223
 
224
+ def get_unlock_account_email_last_sent
225
+ if column = account_lockouts_email_last_sent_column
226
+ if ts = account_lockouts_ds.get(column)
227
+ convert_timestamp(ts)
228
+ end
229
+ end
230
+ end
231
+
232
+ def set_unlock_account_email_last_sent
233
+ account_lockouts_ds.update(account_lockouts_email_last_sent_column=>Sequel::CURRENT_TIMESTAMP) if account_lockouts_email_last_sent_column
234
+ end
235
+
211
236
  private
212
237
 
213
238
  attr_reader :unlock_account_key_value
@@ -258,8 +283,12 @@ module Rodauth
258
283
  render('unlock-account-email')
259
284
  end
260
285
 
286
+ def unlock_account_email_recently_sent?
287
+ (email_last_sent = get_unlock_account_email_last_sent) && (Time.now - email_last_sent < unlock_account_skip_resend_email_within)
288
+ end
289
+
261
290
  def use_date_arithmetic?
262
- db.database_type == :mysql
291
+ super || db.database_type == :mysql
263
292
  end
264
293
 
265
294
  def account_login_failures_ds
@@ -3,8 +3,9 @@
3
3
  module Rodauth
4
4
  Feature.define(:login, :Login) do
5
5
  notice_flash "You have been logged in"
6
+ notice_flash "Login recognized, please enter your password", "need_password"
6
7
  error_flash "There was an error logging in"
7
- loaded_templates %w'login login-field password-field'
8
+ loaded_templates %w'login login-field password-field login-display'
8
9
  view 'login', 'Login'
9
10
  additional_form_tags
10
11
  button 'Login'
@@ -12,6 +13,7 @@ module Rodauth
12
13
 
13
14
  auth_value_method :login_error_status, 401
14
15
  auth_value_method :login_form_footer, ''
16
+ auth_value_method :use_multi_phase_login?, false
15
17
 
16
18
  route do |r|
17
19
  check_already_logged_in
@@ -23,6 +25,7 @@ module Rodauth
23
25
 
24
26
  r.post do
25
27
  clear_session
28
+ skip_error_flash = false
26
29
 
27
30
  catch_error do
28
31
  unless account_from_login(param(login_param))
@@ -35,25 +38,59 @@ module Rodauth
35
38
  throw_error_status(unopen_account_error_status, login_param, unverified_account_message)
36
39
  end
37
40
 
41
+ if use_multi_phase_login?
42
+ @valid_login_entered = true
43
+
44
+ unless param_or_nil(password_param)
45
+ after_login_entered_during_multi_phase_login
46
+ skip_error_flash = true
47
+ next
48
+ end
49
+ end
50
+
38
51
  unless password_match?(param(password_param))
39
52
  after_login_failure
40
53
  throw_error_status(login_error_status, password_param, invalid_password_message)
41
54
  end
42
55
 
43
- transaction do
44
- before_login
45
- update_session
46
- after_login
47
- end
48
- set_notice_flash login_notice_flash
49
- redirect login_redirect
56
+ _login
50
57
  end
51
58
 
52
- set_error_flash login_error_flash
59
+ set_error_flash login_error_flash unless skip_error_flash
53
60
  login_view
54
61
  end
55
62
  end
56
63
 
57
64
  attr_reader :login_form_header
65
+
66
+ def after_login_entered_during_multi_phase_login
67
+ set_notice_now_flash need_password_notice_flash
68
+ end
69
+
70
+ def skip_login_field_on_login?
71
+ return false unless use_multi_phase_login?
72
+ @valid_login_entered
73
+ end
74
+
75
+ def skip_password_field_on_login?
76
+ return false unless use_multi_phase_login?
77
+ @valid_login_entered != true
78
+ end
79
+
80
+ def login_hidden_field
81
+ "<input type='hidden' name=\"#{login_param}\" value=\"#{scope.h param(login_param)}\" />"
82
+ end
83
+
84
+ private
85
+
86
+ def _login
87
+ transaction do
88
+ before_login
89
+ update_session
90
+ after_login
91
+ end
92
+ set_notice_flash login_notice_flash
93
+ redirect login_redirect
94
+ end
58
95
  end
59
96
  end
@@ -18,9 +18,13 @@ module Rodauth
18
18
  before 'otp_authentication'
19
19
  before 'otp_setup'
20
20
  before 'otp_disable'
21
- before 'otp_authentication_route'
22
- before 'otp_setup_route'
23
- before 'otp_disable_route'
21
+
22
+ configuration_module_eval do
23
+ def before_otp_authentication_route(&block)
24
+ warn "before_otp_authentication_route is deprecated, switch to before_otp_auth_route"
25
+ before_otp_auth_route(&block)
26
+ end
27
+ end
24
28
 
25
29
  button 'Authenticate via 2nd Factor', 'otp_auth'
26
30
  button 'Disable Two Factor Authentication', 'otp_disable'
@@ -104,7 +108,7 @@ module Rodauth
104
108
  redirect otp_lockout_redirect
105
109
  end
106
110
 
107
- before_otp_authentication_route
111
+ before_otp_auth_route
108
112
 
109
113
  r.get do
110
114
  otp_auth_view
@@ -10,8 +10,6 @@ module Rodauth
10
10
  before 'add_recovery_codes'
11
11
  before 'view_recovery_codes'
12
12
  before 'recovery_auth'
13
- before 'recovery_auth_route'
14
- before 'recovery_codes_route'
15
13
 
16
14
  after 'add_recovery_codes'
17
15
 
@@ -188,7 +188,7 @@ module Rodauth
188
188
  end
189
189
 
190
190
  def use_date_arithmetic?
191
- extend_remember_deadline? || db.database_type == :mysql
191
+ super || extend_remember_deadline? || db.database_type == :mysql
192
192
  end
193
193
 
194
194
  def remember_key_ds(id=account_id)
@@ -8,6 +8,7 @@ module Rodauth
8
8
  notice_flash "An email has been sent to you with a link to reset the password for your account", 'reset_password_email_sent'
9
9
  error_flash "There was an error resetting your password"
10
10
  error_flash "There was an error requesting a password reset", 'reset_password_request'
11
+ error_flash "An email has recently been sent to you with a link to reset your password", 'reset_password_email_recently_sent'
11
12
  loaded_templates %w'reset-password-request reset-password password-field password-confirm-field reset-password-email'
12
13
  view 'reset-password', 'Reset Password'
13
14
  view 'reset-password-request', 'Request Password Reset', 'reset_password_request'
@@ -20,7 +21,8 @@ module Rodauth
20
21
  button 'Reset Password'
21
22
  button 'Request Password Reset', 'reset_password_request'
22
23
  redirect
23
- redirect :reset_password_email_sent
24
+ redirect(:reset_password_email_sent){default_post_email_redirect}
25
+ redirect(:reset_password_email_recently_sent){default_post_email_redirect}
24
26
 
25
27
  auth_value_method :reset_password_deadline_column, :deadline
26
28
  auth_value_method :reset_password_deadline_interval, {:days=>1}
@@ -31,21 +33,25 @@ module Rodauth
31
33
  auth_value_method :reset_password_table, :account_password_reset_keys
32
34
  auth_value_method :reset_password_id_column, :id
33
35
  auth_value_method :reset_password_key_column, :key
36
+ auth_value_method :reset_password_email_last_sent_column, nil
37
+ auth_value_method :reset_password_skip_resend_email_within, 300
34
38
  session_key :reset_password_session_key, :reset_password_key
35
39
 
36
- auth_value_methods :reset_password_email_sent_redirect, :reset_password_request_link
40
+ auth_value_methods :reset_password_request_link
37
41
 
38
42
  auth_methods(
39
43
  :create_reset_password_key,
40
44
  :create_reset_password_email,
41
45
  :get_reset_password_key,
46
+ :get_reset_password_email_last_sent,
42
47
  :login_failed_reset_password_request_form,
43
48
  :remove_reset_password_key,
44
49
  :reset_password_email_body,
45
50
  :reset_password_email_link,
46
51
  :reset_password_key_insert_hash,
47
52
  :reset_password_key_value,
48
- :send_reset_password_email
53
+ :send_reset_password_email,
54
+ :set_reset_password_email_last_sent
49
55
  )
50
56
  auth_private_methods(
51
57
  :account_from_reset_password_key
@@ -61,6 +67,11 @@ module Rodauth
61
67
 
62
68
  r.post do
63
69
  if account_from_login(param(login_param)) && open_account?
70
+ if reset_password_email_recently_sent?
71
+ set_redirect_error_flash reset_password_email_recently_sent_error_flash
72
+ redirect reset_password_email_recently_sent_redirect
73
+ end
74
+
64
75
  generate_reset_password_key_value
65
76
  transaction do
66
77
  before_reset_password_request
@@ -146,6 +157,7 @@ module Rodauth
146
157
  def create_reset_password_key
147
158
  transaction do
148
159
  if reset_password_key_value = get_password_reset_key(account_id)
160
+ set_reset_password_email_last_sent
149
161
  @reset_password_key_value = reset_password_key_value
150
162
  elsif e = raised_uniqueness_violation{password_reset_ds.insert(reset_password_key_insert_hash)}
151
163
  # If inserting into the reset password table causes a violation, we can pull the
@@ -185,8 +197,24 @@ module Rodauth
185
197
  "<p><a href=\"#{prefix}/#{reset_password_request_route}\">Forgot Password?</a></p>"
186
198
  end
187
199
 
200
+ def set_reset_password_email_last_sent
201
+ password_reset_ds.update(reset_password_email_last_sent_column=>Sequel::CURRENT_TIMESTAMP) if reset_password_email_last_sent_column
202
+ end
203
+
204
+ def get_reset_password_email_last_sent
205
+ if column = reset_password_email_last_sent_column
206
+ if ts = password_reset_ds.get(column)
207
+ convert_timestamp(ts)
208
+ end
209
+ end
210
+ end
211
+
188
212
  private
189
213
 
214
+ def reset_password_email_recently_sent?
215
+ (email_last_sent = get_reset_password_email_last_sent) && (Time.now - email_last_sent < reset_password_skip_resend_email_within)
216
+ end
217
+
190
218
  attr_reader :reset_password_key_value
191
219
 
192
220
  def after_login_failure
@@ -218,7 +246,7 @@ module Rodauth
218
246
  end
219
247
 
220
248
  def use_date_arithmetic?
221
- db.database_type == :mysql
249
+ super || db.database_type == :mysql
222
250
  end
223
251
 
224
252
  def reset_password_key_insert_hash
@@ -49,6 +49,7 @@ module Rodauth
49
49
  redirect(:sms_needs_confirmation){"#{prefix}/#{sms_confirm_route}"}
50
50
  redirect(:sms_needs_setup){"#{prefix}/#{sms_setup_route}"}
51
51
  redirect(:sms_request){"#{prefix}/#{sms_request_route}"}
52
+ redirect(:sms_lockout){_two_factor_auth_required_redirect}
52
53
 
53
54
  loaded_templates %w'sms-auth sms-confirm sms-disable sms-request sms-setup sms-code-field password-field'
54
55
  view 'sms-auth', 'Authenticate via SMS Code', 'sms_auth'
@@ -80,10 +81,7 @@ module Rodauth
80
81
 
81
82
  auth_cached_method :sms
82
83
 
83
- auth_value_methods(
84
- :sms_lockout_redirect,
85
- :sms_codes_primary?
86
- )
84
+ auth_value_methods :sms_codes_primary?
87
85
 
88
86
  auth_methods(
89
87
  :sms_auth_message,
@@ -416,10 +414,6 @@ module Rodauth
416
414
  phone.length >= sms_phone_min_length
417
415
  end
418
416
 
419
- def sms_lockout_redirect
420
- _two_factor_auth_required_redirect
421
- end
422
-
423
417
  def sms_auth_message(code)
424
418
  "SMS authentication code for #{request.host} is #{code}"
425
419
  end
@@ -20,11 +20,9 @@ module Rodauth
20
20
  session_key :two_factor_session_key, :two_factor_auth
21
21
  session_key :two_factor_setup_session_key, :two_factor_auth_setup
22
22
  auth_value_method :two_factor_need_setup_redirect, nil
23
+ auth_value_method :two_factor_auth_required_redirect, nil
23
24
 
24
- auth_value_methods(
25
- :two_factor_auth_required_redirect,
26
- :two_factor_modifications_require_password?
27
- )
25
+ auth_value_methods :two_factor_modifications_require_password?
28
26
 
29
27
  auth_methods(
30
28
  :two_factor_authenticated?,
@@ -39,21 +37,34 @@ module Rodauth
39
37
  end
40
38
 
41
39
  def authenticated?
42
- super
43
- two_factor_authenticated? if two_factor_authentication_setup?
40
+ # False if not authenticated via single factor
41
+ return false unless super
42
+
43
+ # True if already authenticated via 2nd factor
44
+ return true if two_factor_authenticated?
45
+
46
+ # True if authenticated via single factor and 2nd factor not setup
47
+ !two_factor_authentication_setup?
44
48
  end
45
49
 
46
50
  def require_authentication
47
51
  super
52
+
53
+ # Avoid database query if already authenticated via 2nd factor
54
+ return if two_factor_authenticated?
55
+
48
56
  require_two_factor_authenticated if two_factor_authentication_setup?
49
57
  end
50
58
 
51
59
  def require_two_factor_setup
52
- unless uses_two_factor_authentication?
53
- set_redirect_error_status(two_factor_not_setup_error_status)
54
- set_redirect_error_flash two_factor_not_setup_error_flash
55
- redirect two_factor_need_setup_redirect
56
- end
60
+ # Avoid database query if already authenticated via 2nd factor
61
+ return if two_factor_authenticated?
62
+
63
+ return if uses_two_factor_authentication?
64
+
65
+ set_redirect_error_status(two_factor_not_setup_error_status)
66
+ set_redirect_error_flash two_factor_not_setup_error_flash
67
+ redirect two_factor_need_setup_redirect
57
68
  end
58
69
 
59
70
  def require_two_factor_not_authenticated
@@ -76,10 +87,6 @@ module Rodauth
76
87
  nil
77
88
  end
78
89
 
79
- def two_factor_auth_required_redirect
80
- nil
81
- end
82
-
83
90
  def two_factor_auth_fallback_redirect
84
91
  nil
85
92
  end