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,63 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ EmailBase = Feature.define(:email_base) do
5
+ auth_value_method :email_subject_prefix, nil
6
+ auth_value_method :require_mail?, true
7
+ auth_value_method :token_separator, "_"
8
+
9
+ auth_value_methods(
10
+ :email_from
11
+ )
12
+
13
+ auth_methods(
14
+ :create_email,
15
+ :email_to
16
+ )
17
+
18
+ def post_configure
19
+ super
20
+ require 'mail' if require_mail?
21
+ end
22
+
23
+ private
24
+
25
+ def create_email(subject, body)
26
+ m = Mail.new
27
+ m.from = email_from
28
+ m.to = email_to
29
+ m.subject = "#{email_subject_prefix}#{subject}"
30
+ m.body = body
31
+ m
32
+ end
33
+
34
+ def email_from
35
+ "webmaster@#{request.host}"
36
+ end
37
+
38
+ def email_to
39
+ account[login_column]
40
+ end
41
+
42
+ def split_token(token)
43
+ token.split(token_separator, 2)
44
+ end
45
+
46
+ def token_link(route, param, key)
47
+ "#{request.base_url}#{prefix}/#{route}?#{param}=#{account_id}#{token_separator}#{key}"
48
+ end
49
+
50
+ def account_from_key(token, status_id=nil)
51
+ id, key = split_token(token)
52
+ return unless id && key
53
+
54
+ return unless actual = yield(id)
55
+
56
+ return unless timing_safe_eql?(key, actual)
57
+
58
+ ds = account_ds(id)
59
+ ds = ds.where(account_status_column=>status_id) if status_id && !skip_status_checks?
60
+ ds.first
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,151 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module Rodauth
6
+ Jwt = Feature.define(:jwt) do
7
+ auth_value_method :json_non_post_error_message, 'non-POST method used in JSON API'
8
+ auth_value_method :json_response_error_status, 400
9
+ auth_value_method :json_response_error_key, "error"
10
+ auth_value_method :json_response_field_error_key, "field-error"
11
+ auth_value_method :json_response_success_key, nil
12
+ auth_value_method :jwt_algorithm, "HS256"
13
+ auth_value_method :non_json_request_error_message, 'Only JSON format requests are allowed'
14
+ auth_value_method :only_json?, true
15
+
16
+ auth_value_methods :jwt_secret
17
+
18
+ auth_methods(
19
+ :json_request?,
20
+ :jwt_token,
21
+ :set_jwt_token
22
+ )
23
+
24
+ def session
25
+ return super unless json_request?
26
+ return @session if defined?(@session)
27
+ @session = if token = jwt_token
28
+ s = {}
29
+ payload, header = JWT.decode(token, jwt_secret, true, :algorithm=>jwt_algorithm)
30
+ payload.each do |k,v|
31
+ s[k.to_sym] = v
32
+ end
33
+ s
34
+ else
35
+ {}
36
+ end
37
+ end
38
+
39
+ def clear_session
40
+ super
41
+ set_jwt if json_request?
42
+ end
43
+
44
+ def set_field_error(field, message)
45
+ return super unless json_request?
46
+ json_response[json_response_field_error_key] = [field, message]
47
+ end
48
+
49
+ def set_error_flash(message)
50
+ return super unless json_request?
51
+ json_response[json_response_error_key] = message
52
+ end
53
+
54
+ def set_redirect_error_flash(message)
55
+ return super unless json_request?
56
+ json_response[json_response_error_key] = message
57
+ end
58
+
59
+ def set_notice_flash(message)
60
+ return super unless json_request?
61
+ json_response[json_response_success_key] = message if include_success_messages?
62
+ end
63
+
64
+ def set_notice_now_flash(message)
65
+ return super unless json_request?
66
+ json_response[json_response_success_key] = message if include_success_messages?
67
+ end
68
+
69
+ def jwt_token
70
+ if v = request.env['HTTP_AUTHORIZATION']
71
+ v.sub(/\ABearer:?\s+/, '')
72
+ end
73
+ end
74
+
75
+ def set_jwt_token(token)
76
+ response.headers['Authorization'] = token
77
+ end
78
+
79
+ private
80
+
81
+ def before_rodauth
82
+ if only_json? && !json_request?
83
+ response.status = json_response_error_status
84
+ response.write non_json_request_error_message
85
+ request.halt
86
+ end
87
+
88
+ if json_request? && !request.post?
89
+ response.status = 405
90
+ response.headers['Allow'] = 'POST'
91
+ json_response[json_response_error_key] = json_non_post_error_message
92
+ return_json_response
93
+ end
94
+
95
+ super
96
+ end
97
+
98
+ def before_view_recovery_codes
99
+ super if defined?(super)
100
+ if json_request?
101
+ json_response[:codes] = recovery_codes
102
+ json_response[json_response_success_key] ||= "" if include_success_messages?
103
+ end
104
+ end
105
+
106
+ def jwt_secret
107
+ raise ArgumentError, "jwt_secret not set"
108
+ end
109
+
110
+ def redirect(_)
111
+ return super unless json_request?
112
+ return_json_response
113
+ end
114
+
115
+ def include_success_messages?
116
+ !json_response_success_key.nil?
117
+ end
118
+
119
+ def set_session_value(key, value)
120
+ super
121
+ set_jwt if json_request?
122
+ value
123
+ end
124
+
125
+ def json_response
126
+ @json_response ||= {}
127
+ end
128
+
129
+ def _view(meth, page)
130
+ return super unless json_request?
131
+ return super if meth == :render
132
+ return_json_response
133
+ end
134
+
135
+ def return_json_response
136
+ response.status ||= json_response_error_status if json_response[json_response_error_key]
137
+ set_jwt
138
+ response.write(request.send(:convert_to_json, json_response))
139
+ request.halt
140
+ end
141
+
142
+ def set_jwt
143
+ set_jwt_token(JWT.encode(session, jwt_secret, jwt_algorithm))
144
+ end
145
+
146
+ def json_request?
147
+ return @json_request if defined?(@json_request)
148
+ @json_request = request.content_type =~ /application\/json/
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,262 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ Lockout = Feature.define(:lockout) do
5
+ depends :login, :email_base
6
+
7
+ view 'unlock-account-request', 'Request Account Unlock', 'unlock_account_request'
8
+ view 'unlock-account', 'Unlock Account', 'unlock_account'
9
+ before 'unlock_account'
10
+ before 'unlock_account_request'
11
+ after 'unlock_account'
12
+ after 'unlock_account_request'
13
+ additional_form_tags 'unlock_account'
14
+ additional_form_tags 'unlock_account_request'
15
+ button 'Unlock Account', 'unlock_account'
16
+ button 'Request Account Unlock', 'unlock_account_request'
17
+ error_flash "There was an error unlocking your account", 'unlock_account'
18
+ error_flash "This account is currently locked out and cannot be logged in to.", "login_lockout"
19
+ notice_flash "Your account has been unlocked", 'unlock_account'
20
+ notice_flash "An email has been sent to you with a link to unlock your account", 'unlock_account_request'
21
+ redirect :unlock_account
22
+ redirect :unlock_account_request
23
+
24
+ auth_value_method :unlock_account_autologin?, true
25
+ auth_value_method :max_invalid_logins, 100
26
+ auth_value_method :account_login_failures_table, :account_login_failures
27
+ auth_value_method :account_login_failures_id_column, :id
28
+ auth_value_method :account_login_failures_number_column, :number
29
+ auth_value_method :account_lockouts_table, :account_lockouts
30
+ auth_value_method :account_lockouts_id_column, :id
31
+ auth_value_method :account_lockouts_key_column, :key
32
+ auth_value_method :account_lockouts_deadline_column, :deadline
33
+ auth_value_method :account_lockouts_deadline_interval, {:days=>1}
34
+ auth_value_method :no_matching_unlock_account_key_message, 'No matching unlock account key'
35
+ auth_value_method :unlock_account_email_subject, 'Unlock Account'
36
+ auth_value_method :unlock_account_key_param, 'key'
37
+ auth_value_method :unlock_account_requires_password?, false
38
+
39
+ auth_value_methods(
40
+ :unlock_account_redirect,
41
+ :unlock_account_request_redirect
42
+ )
43
+ auth_methods(
44
+ :clear_invalid_login_attempts,
45
+ :create_unlock_account_email,
46
+ :generate_unlock_account_key,
47
+ :get_unlock_account_key,
48
+ :invalid_login_attempted,
49
+ :locked_out?,
50
+ :send_unlock_account_email,
51
+ :unlock_account_email_body,
52
+ :unlock_account_email_link,
53
+ :unlock_account,
54
+ :unlock_account_key
55
+ )
56
+ auth_private_methods :account_from_unlock_key
57
+
58
+ route(:unlock_account_request) do |r|
59
+ check_already_logged_in
60
+ before_unlock_account_request_route
61
+
62
+ r.post do
63
+ if account_from_login(param(login_param)) && get_unlock_account_key
64
+ transaction do
65
+ before_unlock_account_request
66
+ send_unlock_account_email
67
+ after_unlock_account_request
68
+ end
69
+
70
+ set_notice_flash unlock_account_request_notice_flash
71
+ else
72
+ set_redirect_error_flash no_matching_login_message
73
+ end
74
+
75
+ redirect unlock_account_request_redirect
76
+ end
77
+ end
78
+
79
+ route(:unlock_account) do |r|
80
+ check_already_logged_in
81
+ before_unlock_account_route
82
+
83
+ r.get do
84
+ if account_from_unlock_key(param(unlock_account_key_param))
85
+ unlock_account_view
86
+ else
87
+ set_redirect_error_flash no_matching_unlock_account_key_message
88
+ redirect require_login_redirect
89
+ end
90
+ end
91
+
92
+ r.post do
93
+ key = param(unlock_account_key_param)
94
+ unless account_from_unlock_key(key)
95
+ set_redirect_error_flash no_matching_unlock_account_key_message
96
+ redirect unlock_account_request_redirect
97
+ end
98
+
99
+ if !unlock_account_requires_password? || password_match?(param(password_param))
100
+ transaction do
101
+ before_unlock_account
102
+ unlock_account
103
+ after_unlock_account
104
+ if unlock_account_autologin?
105
+ update_session
106
+ end
107
+ end
108
+
109
+ set_notice_flash unlock_account_notice_flash
110
+ redirect unlock_account_redirect
111
+ else
112
+ set_field_error(password_param, invalid_password_message)
113
+ set_error_flash unlock_account_error_flash
114
+ unlock_account_view
115
+ end
116
+ end
117
+ end
118
+
119
+ def locked_out?
120
+ if t = convert_timestamp(account_lockouts_ds.get(account_lockouts_deadline_column))
121
+ if Time.now < t
122
+ true
123
+ else
124
+ unlock_account
125
+ false
126
+ end
127
+ else
128
+ false
129
+ end
130
+ end
131
+
132
+ def unlock_account
133
+ transaction do
134
+ remove_lockout_metadata
135
+ end
136
+ end
137
+
138
+ def clear_invalid_login_attempts
139
+ unlock_account
140
+ end
141
+
142
+ def invalid_login_attempted
143
+ ds = account_login_failures_ds.
144
+ where(account_login_failures_id_column=>account_id)
145
+
146
+ number = if db.database_type == :postgres
147
+ ds.returning(account_login_failures_number_column).
148
+ with_sql(:update_sql, account_login_failures_number_column=>Sequel.expr(account_login_failures_number_column)+1).
149
+ single_value
150
+ else
151
+ # :nocov:
152
+ if ds.update(account_login_failures_number_column=>Sequel.expr(account_login_failures_number_column)+1) > 0
153
+ ds.get(account_login_failures_number_column)
154
+ end
155
+ # :nocov:
156
+ end
157
+
158
+ unless number
159
+ # Ignoring the violation is safe here. It may allow slightly more than max_invalid_logins invalid logins before
160
+ # lockout, but allowing a few extra is OK if the race is lost.
161
+ ignore_uniqueness_violation{account_login_failures_ds.insert(account_login_failures_id_column=>account_id)}
162
+ number = 1
163
+ end
164
+
165
+ if number >= max_invalid_logins
166
+ @unlock_account_key_value = generate_unlock_account_key
167
+ hash = {account_lockouts_id_column=>account_id, account_lockouts_key_column=>unlock_account_key_value}
168
+ set_deadline_value(hash, account_lockouts_deadline_column, account_lockouts_deadline_interval)
169
+
170
+ if e = raised_uniqueness_violation{account_lockouts_ds.insert(hash)}
171
+ # If inserting into the lockout table raises a violation, we should just be able to pull the already inserted
172
+ # key out of it. If that doesn't return a valid key, we should reraise the error.
173
+ raise e unless @unlock_account_key_value = account_lockouts_ds.get(account_lockouts_key_column)
174
+
175
+ show_lockout_page
176
+ end
177
+ end
178
+ end
179
+
180
+ def get_unlock_account_key
181
+ account_lockouts_ds.get(account_lockouts_key_column)
182
+ end
183
+
184
+ def account_from_unlock_key(key)
185
+ @account = _account_from_unlock_key(key)
186
+ end
187
+
188
+ def send_unlock_account_email
189
+ @unlock_account_key_value = get_unlock_account_key
190
+ create_unlock_account_email.deliver!
191
+ end
192
+
193
+ def unlock_account_email_link
194
+ token_link(unlock_account_route, unlock_account_key_param, unlock_account_key_value)
195
+ end
196
+
197
+ private
198
+
199
+ attr_reader :unlock_account_key_value
200
+
201
+ def before_login_attempt
202
+ if locked_out?
203
+ show_lockout_page
204
+ end
205
+ super
206
+ end
207
+
208
+ def after_login
209
+ clear_invalid_login_attempts
210
+ super
211
+ end
212
+
213
+ def after_login_failure
214
+ invalid_login_attempted
215
+ super
216
+ end
217
+
218
+ def after_close_account
219
+ remove_lockout_metadata
220
+ super if defined?(super)
221
+ end
222
+
223
+ def generate_unlock_account_key
224
+ random_key
225
+ end
226
+
227
+ def remove_lockout_metadata
228
+ account_login_failures_ds.delete
229
+ account_lockouts_ds.delete
230
+ end
231
+
232
+ def show_lockout_page
233
+ set_error_flash login_lockout_error_flash
234
+ response.write unlock_account_request_view
235
+ request.halt
236
+ end
237
+
238
+ def create_unlock_account_email
239
+ create_email(unlock_account_email_subject, unlock_account_email_body)
240
+ end
241
+
242
+ def unlock_account_email_body
243
+ render('unlock-account-email')
244
+ end
245
+
246
+ def use_date_arithmetic?
247
+ db.database_type == :mysql
248
+ end
249
+
250
+ def account_login_failures_ds
251
+ db[account_login_failures_table].where(account_login_failures_id_column=>account_id)
252
+ end
253
+
254
+ def account_lockouts_ds(id=account_id)
255
+ db[account_lockouts_table].where(account_lockouts_id_column=>id)
256
+ end
257
+
258
+ def _account_from_unlock_key(token)
259
+ account_from_key(token){|id| account_lockouts_ds(id).get(account_lockouts_key_column)}
260
+ end
261
+ end
262
+ end