rodauth 0.10.0 → 1.0.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +146 -0
  3. data/README.rdoc +644 -220
  4. data/Rakefile +99 -11
  5. data/doc/account_expiration.rdoc +55 -0
  6. data/doc/base.rdoc +104 -0
  7. data/doc/change_login.rdoc +29 -0
  8. data/doc/change_password.rdoc +26 -0
  9. data/doc/close_account.rdoc +31 -0
  10. data/doc/confirm_password.rdoc +22 -0
  11. data/doc/create_account.rdoc +34 -0
  12. data/doc/disallow_password_reuse.rdoc +37 -0
  13. data/doc/email_base.rdoc +19 -0
  14. data/doc/jwt.rdoc +35 -0
  15. data/doc/lockout.rdoc +83 -0
  16. data/doc/login.rdoc +27 -0
  17. data/doc/login_password_requirements_base.rdoc +50 -0
  18. data/doc/logout.rdoc +21 -0
  19. data/doc/otp.rdoc +100 -0
  20. data/doc/password_complexity.rdoc +50 -0
  21. data/doc/password_expiration.rdoc +52 -0
  22. data/doc/password_grace_period.rdoc +10 -0
  23. data/doc/recovery_codes.rdoc +60 -0
  24. data/doc/release_notes/1.0.0.txt +443 -0
  25. data/doc/remember.rdoc +82 -0
  26. data/doc/reset_password.rdoc +70 -0
  27. data/doc/session_expiration.rdoc +27 -0
  28. data/doc/single_session.rdoc +43 -0
  29. data/doc/sms_codes.rdoc +119 -0
  30. data/doc/two_factor_base.rdoc +27 -0
  31. data/doc/verify_account.rdoc +70 -0
  32. data/doc/verify_account_grace_period.rdoc +15 -0
  33. data/doc/verify_change_login.rdoc +9 -0
  34. data/lib/roda/plugins/rodauth.rb +3 -262
  35. data/lib/rodauth.rb +260 -0
  36. data/lib/rodauth/features/account_expiration.rb +108 -0
  37. data/lib/rodauth/features/base.rb +479 -0
  38. data/lib/rodauth/features/change_login.rb +77 -0
  39. data/lib/rodauth/features/change_password.rb +66 -0
  40. data/lib/rodauth/features/close_account.rb +82 -0
  41. data/lib/rodauth/features/confirm_password.rb +51 -0
  42. data/lib/rodauth/features/create_account.rb +128 -0
  43. data/lib/rodauth/features/disallow_password_reuse.rb +82 -0
  44. data/lib/rodauth/features/email_base.rb +63 -0
  45. data/lib/rodauth/features/jwt.rb +151 -0
  46. data/lib/rodauth/features/lockout.rb +262 -0
  47. data/lib/rodauth/features/login.rb +61 -0
  48. data/lib/rodauth/features/login_password_requirements_base.rb +123 -0
  49. data/lib/rodauth/features/logout.rb +37 -0
  50. data/lib/rodauth/features/otp.rb +338 -0
  51. data/lib/rodauth/features/password_complexity.rb +89 -0
  52. data/lib/rodauth/features/password_expiration.rb +111 -0
  53. data/lib/rodauth/features/password_grace_period.rb +46 -0
  54. data/lib/rodauth/features/recovery_codes.rb +240 -0
  55. data/lib/rodauth/features/remember.rb +200 -0
  56. data/lib/rodauth/features/reset_password.rb +207 -0
  57. data/lib/rodauth/features/session_expiration.rb +55 -0
  58. data/lib/rodauth/features/single_session.rb +87 -0
  59. data/lib/rodauth/features/sms_codes.rb +498 -0
  60. data/lib/rodauth/features/two_factor_base.rb +135 -0
  61. data/lib/rodauth/features/verify_account.rb +232 -0
  62. data/lib/rodauth/features/verify_account_grace_period.rb +76 -0
  63. data/lib/rodauth/features/verify_change_login.rb +20 -0
  64. data/lib/rodauth/migrations.rb +130 -0
  65. data/lib/rodauth/version.rb +9 -0
  66. data/spec/account_expiration_spec.rb +90 -0
  67. data/spec/all.rb +1 -0
  68. data/spec/change_login_spec.rb +149 -0
  69. data/spec/change_password_spec.rb +177 -0
  70. data/spec/close_account_spec.rb +162 -0
  71. data/spec/confirm_password_spec.rb +70 -0
  72. data/spec/create_account_spec.rb +127 -0
  73. data/spec/disallow_password_reuse_spec.rb +84 -0
  74. data/spec/lockout_spec.rb +228 -0
  75. data/spec/login_spec.rb +188 -0
  76. data/spec/migrate/001_tables.rb +103 -16
  77. data/spec/migrate/002_account_password_hash_column.rb +11 -0
  78. data/spec/migrate_password/001_tables.rb +60 -42
  79. data/spec/migrate_travis/001_tables.rb +116 -0
  80. data/spec/password_complexity_spec.rb +108 -0
  81. data/spec/password_expiration_spec.rb +243 -0
  82. data/spec/password_grace_period_spec.rb +93 -0
  83. data/spec/remember_spec.rb +424 -0
  84. data/spec/reset_password_spec.rb +185 -0
  85. data/spec/rodauth_spec.rb +57 -980
  86. data/spec/session_expiration_spec.rb +58 -0
  87. data/spec/single_session_spec.rb +107 -0
  88. data/spec/spec_helper.rb +202 -0
  89. data/spec/two_factor_spec.rb +1310 -0
  90. data/spec/verify_account_grace_period_spec.rb +135 -0
  91. data/spec/verify_account_spec.rb +142 -0
  92. data/spec/verify_change_login_spec.rb +46 -0
  93. data/spec/views/login.str +2 -2
  94. data/templates/add-recovery-codes.str +2 -0
  95. data/templates/button.str +5 -0
  96. data/templates/change-login.str +5 -18
  97. data/templates/change-password.str +6 -14
  98. data/templates/close-account.str +3 -6
  99. data/templates/confirm-password.str +4 -14
  100. data/templates/create-account.str +6 -30
  101. data/templates/login-confirm-field.str +6 -0
  102. data/templates/login-field.str +6 -0
  103. data/templates/login.str +5 -19
  104. data/templates/logout.str +2 -6
  105. data/templates/otp-auth-code-field.str +6 -0
  106. data/templates/otp-auth.str +8 -0
  107. data/templates/otp-disable.str +6 -0
  108. data/templates/otp-setup.str +21 -0
  109. data/templates/password-confirm-field.str +6 -0
  110. data/templates/password-field.str +6 -0
  111. data/templates/recovery-auth.str +12 -0
  112. data/templates/recovery-codes.str +6 -0
  113. data/templates/remember.str +8 -12
  114. data/templates/reset-password-request.str +2 -2
  115. data/templates/reset-password.str +4 -18
  116. data/templates/sms-auth.str +6 -0
  117. data/templates/sms-code-field.str +6 -0
  118. data/templates/sms-confirm.str +7 -0
  119. data/templates/sms-disable.str +7 -0
  120. data/templates/sms-request.str +5 -0
  121. data/templates/sms-setup.str +12 -0
  122. data/templates/unlock-account-request.str +3 -7
  123. data/templates/unlock-account.str +4 -7
  124. data/templates/verify-account-resend.str +2 -2
  125. data/templates/verify-account.str +2 -6
  126. metadata +191 -29
  127. data/lib/roda/plugins/rodauth/base.rb +0 -428
  128. data/lib/roda/plugins/rodauth/change_login.rb +0 -48
  129. data/lib/roda/plugins/rodauth/change_password.rb +0 -42
  130. data/lib/roda/plugins/rodauth/close_account.rb +0 -42
  131. data/lib/roda/plugins/rodauth/create_account.rb +0 -92
  132. data/lib/roda/plugins/rodauth/lockout.rb +0 -292
  133. data/lib/roda/plugins/rodauth/login.rb +0 -81
  134. data/lib/roda/plugins/rodauth/logout.rb +0 -36
  135. data/lib/roda/plugins/rodauth/remember.rb +0 -226
  136. data/lib/roda/plugins/rodauth/reset_password.rb +0 -205
  137. data/lib/roda/plugins/rodauth/verify_account.rb +0 -228
@@ -0,0 +1,89 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ PasswordComplexity = Feature.define(:password_complexity) do
5
+ depends :login_password_requirements_base
6
+
7
+ auth_value_method :password_dictionary_file, nil
8
+ auth_value_method :password_dictionary, nil
9
+ auth_value_method :password_character_groups, [/[a-z]/, /[A-Z]/, /\d/, /[^a-zA-Z\d]/]
10
+ auth_value_method :password_min_groups, 3
11
+ auth_value_method :password_max_length_for_groups_check, 11
12
+ auth_value_method :password_max_repeating_characters, 3
13
+ auth_value_method :password_invalid_pattern, Regexp.union([/qwerty/i, /azerty/i, /asdf/i, /zxcv/i] + (1..8).map{|i| /#{i}#{i+1}#{(i+2)%10}/})
14
+ auth_value_method :password_not_enough_character_groups_message, "does not include uppercase letters, lowercase letters, and numbers"
15
+ auth_value_method :password_invalid_pattern_message, "includes common character sequence"
16
+ auth_value_method :password_in_dictionary_message, "is a word in a dictionary"
17
+
18
+ auth_value_methods(
19
+ :password_too_many_repeating_characters_message
20
+ )
21
+
22
+ def password_meets_requirements?(password)
23
+ super && \
24
+ password_has_enough_character_groups?(password) && \
25
+ password_has_no_invalid_pattern?(password) && \
26
+ password_not_too_many_repeating_characters?(password) && \
27
+ password_not_in_dictionary?(password)
28
+ end
29
+
30
+ def post_configure
31
+ super
32
+ return if singleton_methods.map(&:to_sym).include?(:password_dictionary)
33
+
34
+ case dictionary_file = password_dictionary_file
35
+ when false
36
+ return
37
+ when nil
38
+ default_dictionary_file = '/usr/share/dict/words'
39
+ if File.file?(default_dictionary_file)
40
+ words = File.read(default_dictionary_file)
41
+ end
42
+ else
43
+ words = File.read(password_dictionary_file)
44
+ end
45
+
46
+ return unless words
47
+
48
+ require 'set'
49
+ dict = Set.new(words.downcase.split)
50
+ self.class.send(:define_method, :password_dictionary){dict}
51
+ end
52
+
53
+ private
54
+
55
+ def password_has_enough_character_groups?(password)
56
+ return true if password.length > password_max_length_for_groups_check
57
+ return true if password_character_groups.select{|re| password =~ re}.length >= password_min_groups
58
+ @password_requirement_message = password_not_enough_character_groups_message
59
+ false
60
+ end
61
+
62
+ def password_has_no_invalid_pattern?(password)
63
+ return true unless password_invalid_pattern
64
+ return true if password !~ password_invalid_pattern
65
+ @password_requirement_message = password_invalid_pattern_message
66
+ false
67
+ end
68
+
69
+ def password_not_too_many_repeating_characters?(password)
70
+ return true if password_max_repeating_characters < 2
71
+ return true if password !~ /(.)(\1){#{password_max_repeating_characters-1}}/
72
+ @password_requirement_message = password_too_many_repeating_characters_message
73
+ false
74
+ end
75
+
76
+ def password_too_many_repeating_characters_message
77
+ "contains #{password_max_repeating_characters} or more of the same character in a row"
78
+ end
79
+
80
+ def password_not_in_dictionary?(password)
81
+ return true unless dict = password_dictionary
82
+ return true unless password =~ /\A(?:\d*)([A-Za-z!@$+|][A-Za-z!@$+|0134578]+[A-Za-z!@$+|])(?:\d*)\z/
83
+ word = $1.downcase.tr('!@$+|0134578', 'iastloleastb')
84
+ return true if !dict.include?(word)
85
+ @password_requirement_message = password_in_dictionary_message
86
+ false
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,111 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ PasswordExpiration = Feature.define(:password_expiration) do
5
+ depends :login, :change_password
6
+
7
+ error_flash "Your password has expired and needs to be changed"
8
+ error_flash "Your password cannot be changed yet", 'password_not_changeable_yet'
9
+
10
+ redirect :password_not_changeable_yet
11
+ redirect(:password_change_needed){"#{prefix}/#{change_password_route}"}
12
+
13
+ auth_value_method :allow_password_change_after, 0
14
+ auth_value_method :require_password_change_after, 90*86400
15
+ auth_value_method :password_expiration_table, :account_password_change_times
16
+ auth_value_method :password_expiration_id_column, :id
17
+ auth_value_method :password_expiration_changed_at_column, :changed_at
18
+ auth_value_method :password_changed_at_session_key, :password_changed_at
19
+ auth_value_method :password_expiration_default, false
20
+
21
+ auth_methods(
22
+ :password_expired?,
23
+ :update_password_changed_at
24
+ )
25
+
26
+ def get_password_changed_at
27
+ convert_timestamp(password_expiration_ds.get(password_expiration_changed_at_column))
28
+ end
29
+
30
+ def check_password_change_allowed
31
+ if password_changed_at = get_password_changed_at
32
+ if password_changed_at > Time.now - allow_password_change_after
33
+ set_redirect_error_flash password_not_changeable_yet_error_flash
34
+ redirect password_not_changeable_yet_redirect
35
+ end
36
+ end
37
+ end
38
+
39
+ def set_password(password)
40
+ update_password_changed_at
41
+ session[password_changed_at_session_key] = Time.now.to_i
42
+ super
43
+ end
44
+
45
+ def account_from_reset_password_key(key)
46
+ if a = super
47
+ check_password_change_allowed
48
+ end
49
+ a
50
+ end
51
+
52
+ def update_password_changed_at
53
+ ds = password_expiration_ds
54
+ if ds.update(password_expiration_changed_at_column=>Sequel::CURRENT_TIMESTAMP) == 0
55
+ # Ignoring the violation is safe here, since a concurrent insert would also set it to the
56
+ # current timestamp.
57
+ ignore_uniqueness_violation{ds.insert(password_expiration_id_column=>account_id)}
58
+ end
59
+ end
60
+
61
+ def require_current_password
62
+ if authenticated? && password_expired? && password_change_needed_redirect != request.path_info
63
+ set_redirect_error_flash password_expiration_error_flash
64
+ redirect password_change_needed_redirect
65
+ end
66
+ end
67
+
68
+ def password_expired?
69
+ if password_changed_at = session[password_changed_at_session_key]
70
+ return password_changed_at + require_password_change_after < Time.now.to_i
71
+ end
72
+
73
+ account_from_session
74
+ if password_changed_at = get_password_changed_at
75
+ set_session_value(password_changed_at_session_key, password_changed_at.to_i)
76
+ password_changed_at + require_password_change_after < Time.now
77
+ else
78
+ set_session_value(password_changed_at_session_key, password_expiration_default ? 0 : 2147483647)
79
+ password_expiration_default
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def after_close_account
86
+ super if defined?(super)
87
+ password_expiration_ds.delete
88
+ end
89
+
90
+ def before_change_password_route
91
+ check_password_change_allowed
92
+ super
93
+ end
94
+
95
+ def after_create_account
96
+ if account_password_hash_column
97
+ update_password_changed_at
98
+ end
99
+ super if defined?(super)
100
+ end
101
+
102
+ def after_login
103
+ require_current_password
104
+ super
105
+ end
106
+
107
+ def password_expiration_ds
108
+ db[password_expiration_table].where(password_expiration_id_column=>account_id)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,46 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ PasswordGracePeriod = Feature.define(:password_grace_period) do
5
+ auth_value_method :password_grace_period, 300
6
+ auth_value_method :last_password_entry_session_key, :last_password_entry
7
+
8
+ def modifications_require_password?
9
+ return false unless super
10
+ !password_recently_entered?
11
+ end
12
+
13
+ def password_match?(_)
14
+ if v = super
15
+ @last_password_entry = set_last_password_entry
16
+ end
17
+ v
18
+ end
19
+
20
+ private
21
+
22
+ def after_create_account
23
+ super if defined?(super)
24
+ @last_password_entry = Time.now.to_i
25
+ end
26
+
27
+ def after_reset_password
28
+ super if defined?(super)
29
+ @last_password_entry = Time.now.to_i
30
+ end
31
+
32
+ def update_session
33
+ super
34
+ session[last_password_entry_session_key] = @last_password_entry if defined?(@last_password_entry)
35
+ end
36
+
37
+ def password_recently_entered?
38
+ return false unless last_password_entry = session[last_password_entry_session_key]
39
+ last_password_entry + password_grace_period > Time.now.to_i
40
+ end
41
+
42
+ def set_last_password_entry
43
+ session[last_password_entry_session_key] = Time.now.to_i
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,240 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ RecoveryCodes = Feature.define(:recovery_codes) do
5
+ depends :two_factor_base
6
+
7
+ additional_form_tags 'recovery_auth'
8
+ additional_form_tags 'recovery_codes'
9
+
10
+ before 'add_recovery_codes'
11
+ before 'view_recovery_codes'
12
+ before 'recovery_auth'
13
+ before 'recovery_auth_route'
14
+ before 'recovery_codes_route'
15
+
16
+ after 'add_recovery_codes'
17
+
18
+ button 'Add Authentication Recovery Codes', 'add_recovery_codes'
19
+ button 'Authenticate via Recovery Code', 'recovery_auth'
20
+ button 'View Authentication Recovery Codes', 'view_recovery_codes'
21
+
22
+ error_flash "Error authenticating via recovery code.", 'invalid_recovery_code'
23
+ error_flash "Unable to add recovery codes.", 'add_recovery_codes'
24
+ error_flash "Unable to view recovery codes.", 'view_recovery_codes'
25
+
26
+ notice_flash "Additional authentication recovery codes have been added.", 'recovery_codes_added'
27
+
28
+ redirect(:recovery_auth){"#{prefix}/#{recovery_auth_route}"}
29
+ redirect(:add_recovery_codes){"#{prefix}/#{recovery_codes_route}"}
30
+
31
+ view 'add-recovery-codes', 'Authentication Recovery Codes', 'add_recovery_codes'
32
+ view 'recovery-auth', 'Enter Authentication Recovery Code', 'recovery_auth'
33
+ view 'recovery-codes', 'View Authentication Recovery Codes', 'recovery_codes'
34
+
35
+ auth_value_method :add_recovery_codes_param, 'add'
36
+ auth_value_method :invalid_recovery_code_message, "Invalid recovery code"
37
+ auth_value_method :recovery_codes_limit, 16
38
+ auth_value_method :recovery_codes_column, :code
39
+ auth_value_method :recovery_codes_id_column, :id
40
+ auth_value_method :recovery_codes_label, 'Recovery Code'
41
+ auth_value_method :recovery_codes_param, 'recovery-code'
42
+ auth_value_method :recovery_codes_table, :account_recovery_codes
43
+
44
+ auth_cached_method :recovery_codes
45
+
46
+ auth_value_methods(
47
+ :recovery_codes_primary?
48
+ )
49
+
50
+ auth_methods(
51
+ :add_recovery_code,
52
+ :can_add_recovery_codes?,
53
+ :new_recovery_code,
54
+ :recovery_code_match?,
55
+ :recovery_codes
56
+ )
57
+
58
+ route(:recovery_auth) do |r|
59
+ require_login
60
+ require_account_session
61
+ require_two_factor_setup
62
+ require_two_factor_not_authenticated
63
+ before_recovery_auth_route
64
+
65
+ r.get do
66
+ recovery_auth_view
67
+ end
68
+
69
+ r.post do
70
+ if recovery_code_match?(param(recovery_codes_param))
71
+ before_recovery_auth
72
+ two_factor_authenticate(:recovery_code)
73
+ end
74
+
75
+ set_field_error(recovery_codes_param, invalid_recovery_code_message)
76
+ set_error_flash invalid_recovery_code_error_flash
77
+ recovery_auth_view
78
+ end
79
+ end
80
+
81
+ route(:recovery_codes) do |r|
82
+ require_account
83
+ unless recovery_codes_primary?
84
+ require_two_factor_setup
85
+ require_two_factor_authenticated
86
+ end
87
+ before_recovery_codes_route
88
+
89
+ r.get do
90
+ recovery_codes_view
91
+ end
92
+
93
+ r.post do
94
+ if two_factor_password_match?(param(password_param))
95
+ if can_add_recovery_codes?
96
+ if param_or_nil(add_recovery_codes_param)
97
+ transaction do
98
+ before_add_recovery_codes
99
+ add_recovery_codes(recovery_codes_limit - recovery_codes.length)
100
+ after_add_recovery_codes
101
+ end
102
+ set_notice_now_flash recovery_codes_added_notice_flash
103
+ end
104
+
105
+ self.recovery_codes_button = add_recovery_codes_button
106
+ end
107
+
108
+ before_view_recovery_codes
109
+ add_recovery_codes_view
110
+ else
111
+ if param_or_nil(add_recovery_codes_param)
112
+ set_error_flash add_recovery_codes_error_flash
113
+ else
114
+ set_error_flash view_recovery_codes_error_flash
115
+ end
116
+
117
+ set_field_error(password_param, invalid_password_message)
118
+ recovery_codes_view
119
+ end
120
+ end
121
+ end
122
+
123
+ attr_accessor :recovery_codes_button
124
+
125
+ def two_factor_need_setup_redirect
126
+ super || (add_recovery_codes_redirect if recovery_codes_primary?)
127
+ end
128
+
129
+ def two_factor_auth_required_redirect
130
+ super || (recovery_auth_redirect if recovery_codes_primary?)
131
+ end
132
+
133
+ def two_factor_auth_fallback_redirect
134
+ recovery_auth_redirect
135
+ end
136
+
137
+ def two_factor_remove
138
+ super
139
+ recovery_codes_remove
140
+ end
141
+
142
+ def two_factor_authentication_setup?
143
+ super || (recovery_codes_primary? && !recovery_codes.empty?)
144
+ end
145
+
146
+ def otp_auth_form_footer
147
+ "#{super if defined?(super)}<p><a href=\"#{recovery_auth_route}\">Authenticate using recovery code</a></p>"
148
+ end
149
+
150
+ def otp_lockout_redirect
151
+ recovery_auth_redirect
152
+ end
153
+
154
+ def otp_lockout_error_flash
155
+ "#{super if defined?(super)} Can use recovery code to unlock."
156
+ end
157
+
158
+ def otp_add_key
159
+ super if defined?(super)
160
+ add_recovery_codes(recovery_codes_limit - recovery_codes.length)
161
+ end
162
+
163
+ def sms_confirm
164
+ super if defined?(super)
165
+ add_recovery_codes(recovery_codes_limit - recovery_codes.length)
166
+ end
167
+
168
+ def otp_remove
169
+ super if defined?(super)
170
+ unless recovery_codes_primary?
171
+ recovery_codes_remove
172
+ end
173
+ end
174
+
175
+ def sms_disable
176
+ super if defined?(super)
177
+ unless recovery_codes_primary?
178
+ recovery_codes_remove
179
+ end
180
+ end
181
+
182
+ def recovery_codes_remove
183
+ recovery_codes_ds.delete
184
+ end
185
+
186
+ def recovery_code_match?(code)
187
+ recovery_codes.each do |s|
188
+ if timing_safe_eql?(code, s)
189
+ recovery_codes_ds.where(recovery_codes_column=>code).delete
190
+ if recovery_codes_primary?
191
+ add_recovery_code
192
+ end
193
+ return true
194
+ end
195
+ end
196
+ false
197
+ end
198
+
199
+ def can_add_recovery_codes?
200
+ recovery_codes.length < recovery_codes_limit
201
+ end
202
+
203
+ def add_recovery_codes(number)
204
+ return if number <= 0
205
+ transaction do
206
+ number.times do
207
+ add_recovery_code
208
+ end
209
+ end
210
+ remove_instance_variable(:@recovery_codes)
211
+ end
212
+
213
+ def add_recovery_code
214
+ # This should never raise uniqueness violations unless the recovery code is the same, and the odds of that
215
+ # are 1/256**32 assuming a good random number generator. Still, attempt to handle that case by retrying
216
+ # on such a uniqueness violation.
217
+ retry_on_uniqueness_violation do
218
+ recovery_codes_ds.insert(recovery_codes_id_column=>session_value, recovery_codes_column=>new_recovery_code)
219
+ end
220
+ end
221
+
222
+ private
223
+
224
+ def new_recovery_code
225
+ random_key
226
+ end
227
+
228
+ def recovery_codes_primary?
229
+ (features & [:otp, :sms_codes]).empty?
230
+ end
231
+
232
+ def _recovery_codes
233
+ recovery_codes_ds.select_map(recovery_codes_column)
234
+ end
235
+
236
+ def recovery_codes_ds
237
+ db[recovery_codes_table].where(recovery_codes_id_column=>session_value)
238
+ end
239
+ end
240
+ end