tachiban 2.0.0 → 2.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f726149a2c73b44a9eb4ead5ea276752babfe6e627b71ff059604c1c3a81581
4
- data.tar.gz: a598cc9a3425cfd5b5fc9f27e9abd3b1e32a8fbf9bcc6707e4f93ebb069a9739
3
+ metadata.gz: 7608a00958bd0996ebd4d9f0aee635a3d99a2f997bce0be1fcf25672aeba9d59
4
+ data.tar.gz: 9ba708be4e0d7b9686ef2b00483d695151ccdffa4e39f6ceaabcc6c341bcf9b3
5
5
  SHA512:
6
- metadata.gz: 75a5e0d5e5596859d868a29368fbb228d20badf0f67eca13dbcbf6f00b56c579f22da3b5855fc3f14577dcb61cf75ce000c6e802586639a0c1269a8a3681277a
7
- data.tar.gz: e559a58ebd9907225906166d222da3f95e5071e7d2cb2ae3f00ea2763502911d20aceadb1f89e909565a2ecd71014034000abac46d2086942b2fa56675f281aa
6
+ metadata.gz: 05f96fc3be6acf310936d127f258c59a4adf152828c9269a26b6429e1e5a2db7f2fa91de9981ad9e1959bcfe68eab7328e769330a88a7204a9ab62cc1656b396
7
+ data.tar.gz: dc769a77a390224cf2f76abc28a3a9e1fa132908a049165e3f164fdb9320433e1bddcd6340780d7f42568fb6bdc9890e398e53ba4361fa971de29b1eef2b0dab
data/.gitignore CHANGED
@@ -7,4 +7,5 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
- *.gem
10
+ *.gem
11
+ Makefile
data/README.md CHANGED
@@ -32,7 +32,7 @@ Or install it yourself as:
32
32
  $ gem install tachiban
33
33
 
34
34
 
35
- Tachiban 2.0 needs to be included in the action:
35
+ Tachiban needs to be included in the action:
36
36
 
37
37
  ```ruby
38
38
  # app/action.rb
@@ -121,6 +121,10 @@ login(request, response, user.id) if authenticated?(password, user)
121
121
 
122
122
  To check whether a user is logged in, use the `check_for_logged_in_user(request, response)` method. If the user is not logged in, the `logout(request, response, logout_redirect_url: nil)` method takes over.
123
123
 
124
+ The `authenticated?` method now performs the same amount of work whether or not the email matches a
125
+ user, so response time does not reveal whether an account exists. It returns false for
126
+ a nil password or a user record with no stored hash.
127
+
124
128
 
125
129
  #### 2.2.5 Session handling
126
130
  Tachiban handles session expiration by checking if a session has
@@ -130,26 +134,17 @@ has expired:
130
134
 
131
135
  - setting the `request.session[:current_user]` to `nil`,
132
136
  - a flash message is set: `response.flash[:failed_notice] = "Your session has expired"`,
133
- - redirects to the root path `/`, which can be overwritten.
137
+ - redirects to the root path `/`, which can be overridden.
134
138
 
135
139
 
136
140
  The `session_expired?(request, validity_time: nil)` method compares the session start time
137
141
  increased for the defined `validity_time` (set to 10 minutes
138
- by default, but can be overwritten) with the current time.
142
+ by default, but can be overridden) with the current time.
143
+
144
+ On expiry, `handle_session` clears the current user, sets
145
+ `response.flash[:failed_notice]` and redirects. The flash and the redirect url
146
+ are configurable — see section 3.
139
147
 
140
- `handle_session(request, response, redirect_url: nil)` method:
141
- ```ruby
142
- def handle_session(request, response, redirect_url: nil)
143
- if session_expired?(request)
144
- redirect_url ||= '/'
145
- request.session[:current_user] = nil
146
- response.flash[:failed_notice] ||= "Your session has expired."
147
- response.redirect_to redirect_url
148
- else
149
- restart_session_counter(request)
150
- end
151
- end
152
- ```
153
148
 
154
149
  #### 2.2.6 Session handling in a share code module
155
150
  It is possible to enable session handling in a share code module as provided by Hanami.
@@ -216,74 +211,219 @@ module MyApplication
216
211
  ```
217
212
 
218
213
 
219
- **_Disabling the authentication shared module in specific actions_**
214
+ **_Disabling the authentication shared module methods in specific actions_**
220
215
 
221
- Sometimes we might not want to check for authenticated user. For example,
222
- doing so in the `login` action will cause an infinite loop. There we can
223
- disable the module by overwriting the desired methods in the action:
216
+ Any action a logged-out user must be able to reach, should disable the
217
+ authentication. One such example would be the `login` action. If we check
218
+ for an authenticated user there, it will cause an infinite loop. So we
219
+ have to disable the authentication module methods by overriding
220
+ the desired methods in the action. Below is a concrete example for the
221
+ new action for `UserSessions`, which renders the login form when a user
222
+ visits the '/login' url:
224
223
 
225
224
  ```ruby
226
- private
225
+ # frozen_string_literal: true
226
+
227
+ module Myapplication
228
+ module Actions
229
+ module UserSessions
230
+ class New < Myapplication::Action
231
+
232
+ def handle(request, response)
233
+ request.session[:current_user] = nil
234
+ end
235
+
236
+ private
227
237
 
228
- def check_for_logged_in_user; end
229
- def handle_session; end
238
+ def check_for_logged_in_user; end
239
+ def handle_session; end
240
+ end
241
+ end
242
+ end
243
+ end
230
244
  ```
231
245
 
232
246
  #### 2.2.7 Password reset
233
- The password reset feature provides a few simple methods to:
234
- * generate a token, email subject and body (text and html part)
235
- * specify and check the validity of the password reset url and
236
- * set the default application name for the email subject (it can be
237
- overwritten or set as a ENV variable).
247
+ The password reset feature provides methods to:
248
+ * generate a token, build email subject and body (text and html part),
249
+ * verify the reset link and
250
+ * invalidate a used token.
238
251
 
239
252
  The link validity must me specified in seconds. The method compares the
240
253
  current time with the time when the password reset link was sent increased
241
254
  by the link validity: `Time.now > user.password_reset_sent_at + link_validity`.
242
255
 
243
256
 
257
+ **Generating a reset link**
258
+
244
259
  ```ruby
245
- token # => "YbRucc8YUlFJrYYp04eQKQ"
260
+ reset_token = token # => 43-character URL-safe string
261
+ user_repo.update(user.id, token: reset_token, password_reset_sent_at: Time.now)
246
262
  ```
247
263
 
248
264
  ```ruby
249
265
  email_subject("SomeApp") # => "SomeApp -- password reset request"
250
266
  ```
251
267
 
268
+ Provide the reset url, user's name, link validity, time unit and optionally the
269
+ application name when building the body:
270
+
252
271
  ```ruby
253
- password_reset_url_valid?(link_validity)
272
+ html_body = email_body_html(
273
+ reset_url: reset_url,
274
+ user_name: "#{user.name} #{user.surname}",
275
+ link_validity: 2,
276
+ time_unit: "hour",
277
+ app_name: nil
278
+ )
254
279
  ```
255
280
 
281
+ `app_name` falls back to the `APP_NAME` environment variable, then to your Hanami
282
+ app's namespace, then to "Application".
283
+
256
284
 
285
+ **Verifying a reset link**
257
286
 
258
- Provide the following values when building the email body: reset url, user's name,
259
- link validity, time unit and optionally the application name. Below is an example of
260
- html_body in a mailer class:
287
+ Use `verify_reset_token`. It takes the token from the params and a block that looks
288
+ the user up, and returns the user only if the token is present, a user matches, and
289
+ the link is still inside its validity window. Any failure returns nil, so one check
290
+ covers every case:
261
291
 
262
292
  ```ruby
263
- html_body = email_body_html(
264
- reset_url: reset_url,
265
- user_name: "#{user.name} #{user.surname}",
266
- link_validity: 2,
267
- time_unit: "hour",
268
- app_name: nil
269
- )
293
+ def handle(request, response)
294
+ user = verify_reset_token(request.params[:token]) { |t| user_repo.find_by_token(t) }
295
+
296
+ unless user
297
+ response.flash[:failed_notice] = "This link is invalid or has expired."
298
+ response.redirect_to "/passwordreset/new"
299
+ return
300
+ end
301
+
302
+ end
303
+ ```
304
+
305
+ The block must return nil when no user matches. With ROM, use `.one` rather than
306
+ `.one!` — `.one!` raises on no match and the exception escapes before
307
+ `verify_reset_token` can return nil.
308
+
309
+ Validity defaults to 3600 seconds. Override per call with
310
+ `link_validity_seconds:`, or globally with a `custom_link_validity_seconds` method.
311
+
312
+ `password_reset_url_valid?(link_validity, user)` remains available for direct use
313
+ to support backward compatibility, but `verify_reset_token` is preferred because
314
+ it performs the checks in the correct order.
315
+
316
+
317
+ **Invalidating the token after a reset — required**
318
+
319
+ Tachiban does not clear tokens after use. It's best you clear them in
320
+ the same update as the new password and you can use the `reset_token_attributes` for that.
321
+ Otherwise the token is valid until its window expires and can be reused.
322
+
323
+ ```ruby
324
+ user_repo.update(
325
+ user.id,
326
+ hashed_pass: hashed_password(new_password),
327
+ **reset_token_attributes
328
+ )
270
329
  ```
271
330
 
272
- ### 3. Default values
273
- There are a few default values set which can be overwritten. See the table below.
274
331
 
275
- |Required by method |Variable |Default value |
276
- |--- |--- |--- |
277
- |login |flash_message |'You have been successfully logged in.'|
278
- |login |login_redirect_url |'/' |
279
- |logout |logout_redirect_url |'/login' |
280
- |session_expired? |validity_time |600 |
281
- |handle_session |redirect_url |'/' |
332
+ ## 3. Default values and custom overrides
333
+ There are a few default values set which can be overridden in two ways: by passing an argument at the call site,
334
+ or by defining a private custom method on your action. See the table below.
335
+
336
+ Precedence: **explicit argument → custom method → built-in default.**
337
+
338
+ |Method |Argument |Custom method |Default |
339
+ |--- |--- |--- |--- |
340
+ |`login` |`flash_message:` |`custom_login_flash_message` |'You have been successfully logged in.'|
341
+ |`login` |`login_redirect_url:`|`custom_login_redirect_url` |'/' |
342
+ |`logout` |`logout_redirect_url:`|`custom_logout_redirect_url` |'/login' |
343
+ |`session_expired?`|`validity_time:` |`custom_session_validity_time` |600 |
344
+ |`handle_session` |`redirect_url:` |`custom_handle_session_redirect_url`|'/' |
345
+ |`handle_session` |— |`custom_session_expired_message` |'Your session has expired.' |
346
+ |`verify_reset_token`|`link_validity_seconds:`|`custom_link_validity_seconds`|3600 |
347
+
348
+
349
+ **All time values are in seconds.** `validity_time` and `custom_session_validity_time`
350
+ are the same value under different names — 600 means ten minutes in both.
351
+
352
+ Custom methods can be private — Tachiban looks up private methods too, so you don't
353
+ need to expose them on your action's public interface.
354
+
355
+ Define them on your base action to apply everywhere, or on a single action to
356
+ override just that one:
357
+
358
+ ```ruby
359
+ # app/action.rb — applies to every action
360
+ module MyApplication
361
+ class Action < Hanami::Action
362
+ include Hanami::Tachiban
363
+
364
+ private
365
+
366
+ def custom_session_validity_time
367
+ 1800
368
+ end
369
+ end
370
+ end
371
+
372
+ # app/actions/admin/dashboard.rb — tighter window for this action only
373
+ module MyApplication
374
+ module Actions
375
+ module Admin
376
+ class Dashboard < MyApplication::Action
377
+ private
378
+
379
+ def custom_session_validity_time
380
+ 300
381
+ end
382
+ end
383
+ end
384
+ end
385
+ end
386
+ ```
282
387
 
283
388
 
284
389
 
285
390
 
286
- ### 4. Changelog
391
+ ## 4. Changelog
392
+
393
+ #### 2.1.0
394
+ Backward compatible with 2.0.0. All existing method signatures are unchanged.
395
+
396
+ **Security fixes:**
397
+ - `authenticated?` no longer returns early when no user is found. It verifies the
398
+ submitted password against a precomputed dummy hash instead, so existing and
399
+ non-existing accounts take the same time to respond. This closes an account
400
+ enumeration vulnerability where a valid email could be identified
401
+ by a slower response.
402
+ - `authenticated?` also handles a user record with a nil `hashed_pass`, and returns
403
+ false for a nil password instead of raising.
404
+ - `password_reset_url_valid?` now fails closed. It returns false for a nil user or a
405
+ user with no `password_reset_sent_at`, where it previously raised NoMethodError.
406
+ - `session_expired?` now returns true when `session_start_time` is missing, rather
407
+ than raising. An undateable session is treated as expired.
408
+ - Reset tokens are now 32 bytes (`SecureRandom.urlsafe_base64(32)`).
409
+
410
+ **New methods:**
411
+ - `verify_reset_token(token, link_validity_seconds: nil)` — performs the
412
+ blank-token check, the user lookup and the expiry check in the correct order and
413
+ returns the user or nil. Recommended over calling `password_reset_url_valid?`
414
+ directly.
415
+ - `reset_token_attributes` — returns `{token: nil, password_reset_sent_at: nil}` for
416
+ passing to the repo's update method after a successful reset. **Tachiban does not
417
+ invalidate tokens by itself.** Failing to clear them leaves reset links replayable
418
+ indefinitely.
419
+
420
+ **New feature — custom method hooks:**
421
+ Default values can now be overridden by defining private methods on your action
422
+ instead of passing arguments. This makes defaults configurable for `before` callbacks
423
+ like `handle_session`, which Hanami calls with a fixed argument list. See section 3.
424
+
425
+ **Other:**
426
+ - Requires for `hanami-controller` and `hanami-action` were dropped.
287
427
 
288
428
  #### 2.0.0
289
429
 
@@ -1,3 +1,3 @@
1
1
  module Tachiban
2
- VERSION = "2.0.0"
2
+ VERSION = "2.1.0"
3
3
  end
data/lib/tachiban.rb CHANGED
@@ -1,132 +1,205 @@
1
1
  require 'tachiban/version'
2
- require 'hanami/controller'
3
- require 'hanami/action/session'
4
2
  require 'argon2'
3
+ require 'securerandom'
5
4
 
6
5
  module Hanami
7
6
  module Tachiban
8
- private
9
7
 
8
+ # ### Account Enumeration Vulnerability Mitigation ###
9
+
10
+ # To close the account enumeration vulnerability via response timing,
11
+ # a dummy hash is computed once when Tachiban loads. When no user is found,
12
+ # the provided password is verified against that dummy hash instead of
13
+ # returning immediately. Both the existing-account and missing-account paths
14
+ # then perform one Argon2 verification, so the response time no longer tells
15
+ # an attacker whether an account exists.
16
+ #
17
+ # The dummy hash is precomputed rather than generated per request on purpose:
18
+ # Argon2 hash creation is more expensive than verification, so generating one
19
+ # per miss would make the missing-account path measurably slower and reopen
20
+ # the leak in the other direction.
21
+
22
+ DUMMY_HASH = Argon2::Password.create(SecureRandom.hex(32)).freeze
23
+
24
+ private
25
+
26
+ # The `tachiban_custom` helper looks up an optional customization method
27
+ # on the including action. If the action defines `name`, calls it and
28
+ # returns its result; otherwise returns `fallback`.
29
+ # The second argument to `respond_to?` includes private methods, so actions
30
+ # can keep their overrides private.
31
+ #
32
+ # Custom methods can be used in the base action, a specific action or both.
33
+ #
34
+ # # app/action.rb
35
+ # module MyApp
36
+ # class Action < Hanami::Action
37
+ # include Hanami::Tachiban
38
+ #
39
+ # private
40
+ #
41
+ # def custom_session_validity_time
42
+ # 1800
43
+ # end
44
+ #
45
+ # def custom_handle_session_redirect_url
46
+ # "/login"
47
+ # end
48
+ # end
49
+ # end
50
+ #
51
+ # # app/actions/admin/dashboard.rb
52
+ # module MyApp
53
+ # module Actions
54
+ # module Admin
55
+ # class Dashboard < MyApp::Action
56
+ # private
57
+ #
58
+ # def custom_session_validity_time
59
+ # 300
60
+ # end
61
+ # end
62
+ # end
63
+ # end
64
+ # end
65
+ def tachiban_custom(name, fallback)
66
+ respond_to?(name, true) ? send(name) : fallback
67
+ end
10
68
 
11
- # ### Signup ###
12
69
 
13
- # The hashed_password method generates a hashed version of the user's
14
- # password. Password hashing is provided by Argon2. Hashed password
15
- # by default includes a salt and the default cost factorr.
16
- #
17
- # Hashed password should be stored in the database as an user's
18
- # attribute so it can be retrieved during the login process.
70
+ # ### Signup ###
19
71
 
72
+ # The `hashed_password` method generates a hashed version of the user's
73
+ # password. Password hashing is provided by Argon2. Hashed password
74
+ # by default includes a salt and the default cost factor.
75
+ #
76
+ # Hashed password should be stored in the database as a user's
77
+ # attribute so it can be retrieved during the login process.
20
78
  def hashed_password(password)
21
79
  Argon2::Password.create(password)
22
80
  end
23
81
 
24
- # ### Login ###
25
-
26
- # The authenticated? method returns true if the the following criteria
27
- # are true:
28
- # - a user exists
29
- # - a user's hashed password from the database matches the input password
82
+ # ### Login ###
30
83
 
84
+ # The `authenticated?` method returns true if the following criteria
85
+ # are true:
86
+ # - a user exists
87
+ # - a user's hashed password from the database matches the input password
88
+ #
89
+ # The Account Enumeration Vulnerability Mitigation is in place.
31
90
  def authenticated?(input_pass, user)
32
- user && Argon2::Password.verify_password(input_pass, user.hashed_pass)
33
- end
91
+ return false if input_pass.nil?
34
92
 
35
- # The login method can be used in combination with the authenticated? method to
36
- # log the user in if the authenticated? method returns true. The user is
37
- # logged in by setting the user object id as the session[:current_user].
38
- # After the user is logged in the session start time is defined, which is then used
39
- # by the session_expired? method to determine whether the session has
40
- # expired or not.
93
+ if user.nil? || user.hashed_pass.nil?
94
+ Argon2::Password.verify_password(input_pass, DUMMY_HASH)
95
+ return false
96
+ end
97
+
98
+ Argon2::Password.verify_password(input_pass, user.hashed_pass)
99
+
100
+ rescue Argon2::ArgonHashFail
101
+ false
102
+ end
41
103
 
42
- # There are two defualt values set: one for flash message and
43
- # the other for redirect url. Both can be overwritten by assigning
44
- # new values for flash_message and login_redirect_url arguments.
104
+ # The `login` method can be used in combination with the `authenticated?` method to
105
+ # log the user in if the `authenticated?` method returns true. The user is
106
+ # logged in by setting the user object id as the `session[:current_user]`.
107
+ # After the user is logged in the session start time is defined, which is then used
108
+ # by the `session_expired?` method to determine whether the session has
109
+ # expired or not.
45
110
 
46
- # Example:
47
- # login if authenticated?(input_pass)
111
+ # There are two default values set: one for flash message and
112
+ # the other for redirect url. Both can be overwritten by using
113
+ # custom method helper.
48
114
 
115
+ # Example:
116
+ # login(request, response, user.id) if authenticated?(input_pass)
49
117
  def login(request, response, user_id, flash_message: nil, login_redirect_url: nil)
50
118
  request.session[:current_user] = user_id
51
119
  request.session[:session_start_time] = Time.now
52
- flash_message ||= 'You have been successfully logged in.'
53
- response.flash[:success_notice] = flash_message
54
- login_redirect_url ||= "/"
55
- response.redirect_to login_redirect_url
120
+ response.flash[:success_notice] = flash_message || tachiban_custom(:custom_login_flash_message,
121
+ 'You have been successfully logged in.')
122
+ response.redirect_to(login_redirect_url || tachiban_custom(:custom_login_redirect_url, '/'))
56
123
  end
57
124
 
58
- # The logout method sets the current user in the session to nil
59
- # and performs a redirect to the redirect_url which is set to
60
- # /login, but can be overwritten as needed with a specific url
61
- # by setting a new value for logout_redirect_url.
62
-
125
+ # The `logout` method sets the current user in the session to nil
126
+ # and performs a redirect to the `logout_redirect_url` which is set to
127
+ # `'/login'`, but can be overwritten as needed with a specific url
128
+ # by using custom method helper.
63
129
  def logout(request, response, logout_redirect_url: nil)
64
130
  request.session[:current_user] = nil
65
131
  request.session.clear
66
- logout_redirect_url ||= '/login'
67
- response.redirect_to logout_redirect_url
132
+ response.redirect_to(logout_redirect_url || tachiban_custom(:custom_logout_redirect_url, "/login"))
68
133
  end
69
134
 
70
- # ### Authentication ###
71
-
72
- # The check_for_logged_in_user method can be used to check for each
73
- # request whether the user is logged in. If the user is not logged in
74
- # the logout method takes over.
135
+ # ### Authentication ###
75
136
 
137
+ # The `check_for_logged_in_user` method can be used to check for each
138
+ # request whether the user is logged in. If the user is not logged in
139
+ # the logout method takes over.
76
140
  def check_for_logged_in_user(request, response)
77
141
  logout(request, response) unless request.session[:current_user]
78
142
  end
79
143
 
80
- # ### Session handling ###
81
-
82
- # Session handling includes methods session_expired?,
83
- # restart_session_counter and handle session.
84
-
85
- # The session_expired? method compares the session start time
86
- # increased for the defined validity time (set to 10 minutes
87
- # by default and can be overwritten) with the current time.
144
+ # ### Session handling ###
88
145
 
146
+ # Session handling includes methods `session_expired?`,
147
+ # `restart_session_counter` and `handle_session`.
148
+ #
149
+ # The `session_expired?` method compares the session start time
150
+ # increased for the defined validity time in seconds with the current time.
151
+ # The default validity of 600 seconds (10 minutes) can be overwritten by using the
152
+ # custom method helper.
89
153
  def session_expired?(request, validity_time: nil)
90
- if request.session[:current_user]
91
- validity_time ||= 600
92
- request.session[:session_start_time] + validity_time.to_i < Time.now
93
- end
94
- end
154
+ return false unless request.session[:current_user]
155
+ return true unless request.session[:session_start_time]
95
156
 
96
- # The restart_session_counter method resets the session start time to
97
- # Time.now. It's used in the handle session method.
157
+ validity_time ||= tachiban_custom(:custom_session_validity_time, 600)
158
+ request.session[:session_start_time] + validity_time.to_i < Time.now
159
+ end
98
160
 
161
+ # The `restart_session_counter` method resets the session start time to
162
+ # `Time.now`. It's used in the `handle_session` method.
99
163
  def restart_session_counter(request)
100
164
  request.session[:session_start_time] = Time.now
101
165
  end
102
166
 
103
- # The handle_session method is used to handle the incoming requests
104
- # based on the the session expiration. If the session has expired the
105
- # session user is set to nil, a flash message of "Your session has expired"
106
- # is provided and a redirect to a default url of routes.root_path
107
- # is triggered.
108
-
109
- # If the session hasn't expired the restart_session_counter method is
110
- # called to reset the session start time.
111
-
167
+ # The `handle_session` method is used to handle the incoming requests
168
+ # based on the session expiration. If the session has expired the
169
+ # session user is set to nil, a flash message of "Your session has expired"
170
+ # is provided and a redirect to a default url of "/" is triggered.
171
+ #
172
+ # Both default values can be overwritten by using the custom method helper.
173
+ #
174
+ # If the session hasn't expired the `restart_session_counter` method is
175
+ # called to reset the session start time.
112
176
  def handle_session(request, response, redirect_url: nil)
113
177
  if session_expired?(request)
114
- redirect_url ||= "/"
178
+ redirect_url ||= tachiban_custom(:custom_handle_session_redirect_url, "/")
115
179
  request.session[:current_user] = nil
116
- response.flash[:failed_notice] ||= 'Your session has expired.'
180
+ response.flash[:failed_notice] = tachiban_custom(:custom_session_expired_message,
181
+ 'Your session has expired.')
117
182
  response.redirect_to redirect_url
118
183
  else
119
184
  restart_session_counter(request)
120
185
  end
121
186
  end
122
187
 
123
- # ### Password reset ###
124
- # The password reset functionalities include token generation, email subject,
125
- # email body in the text as well as in the html format, checking the reset link
126
- # validity and getting the app name.
127
-
188
+ # ### Password reset ###
189
+ # The password reset functionalities include token generation, token invalidation,
190
+ # email subject, email body in the text as well as in the html format,
191
+ # checking the reset link validity and getting the app name.
128
192
  def token
129
- SecureRandom.urlsafe_base64
193
+ SecureRandom.urlsafe_base64(32)
194
+ end
195
+
196
+ # After a successful reset the token and the timestamp of the password reset link
197
+ # must be reset. The attributes are prepared to be passed to the update repo method.
198
+ #
199
+ # Usage:
200
+ # user_repo.update(user.id, hashed_pass: hashed_password(new_password), **reset_token_attributes)
201
+ def reset_token_attributes
202
+ {token: nil, password_reset_sent_at: nil}
130
203
  end
131
204
 
132
205
  def email_subject(app_name)
@@ -136,7 +209,7 @@ private
136
209
 
137
210
  def email_body_text(reset_url:, user_name:, link_validity:, time_unit:, app_name: nil)
138
211
  app_name ||= default_app_name
139
-
212
+
140
213
  <<~TEXT
141
214
  Hello #{user_name},
142
215
 
@@ -153,7 +226,7 @@ private
153
226
 
154
227
  def email_body_html(reset_url:, user_name:, link_validity:, time_unit:, app_name: nil)
155
228
  app_name ||= default_app_name
156
-
229
+
157
230
  <<~HTML
158
231
  <!DOCTYPE html>
159
232
  <html>
@@ -173,19 +246,53 @@ private
173
246
  <p style="color: #666; font-size: 12px;">This link expires in #{link_validity} #{time_unit}(s).</p>
174
247
  </body>
175
248
  </html>
176
- HTML
249
+ HTML
177
250
  end
178
251
 
179
- # State the link_validity in seconds.
252
+ # The `password_reset_url_valid?` method checks whether a user's password
253
+ # reset link is still within its validity window.
254
+ # State the link validity in seconds. The method returns false
255
+ # for a nil user or a user with no reset timestamp, so it fails closed.
256
+ #
257
+ # This method can still be called directly, but it is advised to use the
258
+ # `verify_reset_token` method to cover all link and token validity
259
+ # checks at the same time.
180
260
  def password_reset_url_valid?(link_validity, user)
181
- Time.now < user.password_reset_sent_at + link_validity
261
+ return false unless user
262
+ return false unless user.password_reset_sent_at
263
+
264
+ Time.now < user.password_reset_sent_at + link_validity.to_i
265
+ end
266
+
267
+ # Verifies the validity of a password reset token by checking:
268
+ # - presence of the token,
269
+ # - finding the matching user if token is present and
270
+ # - checking the link validity in seconds.
271
+ #
272
+ # If all checks pass, it returns a valid matching user, otherwise it returns nil.
273
+ # The token is passed to the block when the method is called.
274
+ #
275
+ # The block must return nil when no user matches — use ROM's `.one`
276
+ # rather than `.one!`, since `.one!` raises and the exception would
277
+ # escape before this method can return nil.
278
+ #
279
+ # In a handle method it can be used like:
280
+ #
281
+ # user = verify_reset_token(request.params[:token]) { |t| user_repo.find_by_token(t) }
282
+ def verify_reset_token(token, link_validity_seconds: nil)
283
+ return nil if token.nil? || token.to_s.strip.empty?
284
+
285
+ user = yield(token)
286
+ link_validity_seconds ||= tachiban_custom(:custom_link_validity_seconds, 3600)
287
+ password_reset_url_valid?(link_validity_seconds, user) ? user : nil
182
288
  end
183
289
 
184
290
  def default_app_name
185
- ENV.fetch("APP_NAME") { Hanami.app.namespace.to_s }
186
- rescue
291
+ ENV.fetch("APP_NAME") do
292
+ Hanami.respond_to?(:app) ? Hanami.app.namespace.to_s : "Application"
293
+ end
294
+ rescue StandardError
187
295
  "Application"
188
296
  end
189
-
190
297
  end
191
298
  end
data/tachiban.gemspec CHANGED
@@ -17,13 +17,11 @@ Gem::Specification.new do |spec|
17
17
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
18
  spec.require_paths = ["lib"]
19
19
 
20
- spec.add_development_dependency "rake"
20
+ spec.add_development_dependency "rake", "~> 13.0"
21
21
  spec.add_development_dependency "minitest", "~> 5.0"
22
- spec.add_development_dependency "timecop", "0.9.0"
23
- spec.add_development_dependency 'hanami', "~> 2.0"
22
+ spec.add_development_dependency "timecop", "~> 0.9.0"
24
23
  spec.add_development_dependency 'pry', "~> 0.16.0"
25
24
 
26
25
  spec.add_runtime_dependency "argon2", "~> 2.3"
27
- spec.add_runtime_dependency 'hanami', "~> 2.0"
28
- spec.add_runtime_dependency "hanami-controller", "~> 2.0"
26
+ spec.required_ruby_version = ">= 3.0.0"
29
27
  end
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tachiban
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastjan Hribar
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-06 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: '13.0'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: '13.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: minitest
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -40,32 +40,18 @@ dependencies:
40
40
  version: '5.0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: timecop
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - '='
46
- - !ruby/object:Gem::Version
47
- version: 0.9.0
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - '='
53
- - !ruby/object:Gem::Version
54
- version: 0.9.0
55
- - !ruby/object:Gem::Dependency
56
- name: hanami
57
43
  requirement: !ruby/object:Gem::Requirement
58
44
  requirements:
59
45
  - - "~>"
60
46
  - !ruby/object:Gem::Version
61
- version: '2.0'
47
+ version: 0.9.0
62
48
  type: :development
63
49
  prerelease: false
64
50
  version_requirements: !ruby/object:Gem::Requirement
65
51
  requirements:
66
52
  - - "~>"
67
53
  - !ruby/object:Gem::Version
68
- version: '2.0'
54
+ version: 0.9.0
69
55
  - !ruby/object:Gem::Dependency
70
56
  name: pry
71
57
  requirement: !ruby/object:Gem::Requirement
@@ -94,34 +80,6 @@ dependencies:
94
80
  - - "~>"
95
81
  - !ruby/object:Gem::Version
96
82
  version: '2.3'
97
- - !ruby/object:Gem::Dependency
98
- name: hanami
99
- requirement: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - "~>"
102
- - !ruby/object:Gem::Version
103
- version: '2.0'
104
- type: :runtime
105
- prerelease: false
106
- version_requirements: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - "~>"
109
- - !ruby/object:Gem::Version
110
- version: '2.0'
111
- - !ruby/object:Gem::Dependency
112
- name: hanami-controller
113
- requirement: !ruby/object:Gem::Requirement
114
- requirements:
115
- - - "~>"
116
- - !ruby/object:Gem::Version
117
- version: '2.0'
118
- type: :runtime
119
- prerelease: false
120
- version_requirements: !ruby/object:Gem::Requirement
121
- requirements:
122
- - - "~>"
123
- - !ruby/object:Gem::Version
124
- version: '2.0'
125
83
  description:
126
84
  email:
127
85
  - sebastjan.hribar@gmail.com
@@ -157,7 +115,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
157
115
  requirements:
158
116
  - - ">="
159
117
  - !ruby/object:Gem::Version
160
- version: '0'
118
+ version: 3.0.0
161
119
  required_rubygems_version: !ruby/object:Gem::Requirement
162
120
  requirements:
163
121
  - - ">="