rodauth 0.10.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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