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 +4 -4
- data/.gitignore +2 -1
- data/README.md +190 -50
- data/lib/tachiban/version.rb +1 -1
- data/lib/tachiban.rb +192 -85
- data/tachiban.gemspec +3 -5
- metadata +9 -51
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7608a00958bd0996ebd4d9f0aee635a3d99a2f997bce0be1fcf25672aeba9d59
|
|
4
|
+
data.tar.gz: 9ba708be4e0d7b9686ef2b00483d695151ccdffa4e39f6ceaabcc6c341bcf9b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 05f96fc3be6acf310936d127f258c59a4adf152828c9269a26b6429e1e5a2db7f2fa91de9981ad9e1959bcfe68eab7328e769330a88a7204a9ab62cc1656b396
|
|
7
|
+
data.tar.gz: dc769a77a390224cf2f76abc28a3a9e1fa132908a049165e3f164fdb9320433e1bddcd6340780d7f42568fb6bdc9890e398e53ba4361fa971de29b1eef2b0dab
|
data/.gitignore
CHANGED
data/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Or install it yourself as:
|
|
|
32
32
|
$ gem install tachiban
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
Tachiban
|
|
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
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
|
234
|
-
* generate a token, email subject and body (text and html part)
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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 # =>
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
|
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
|
-
|
|
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
|
|
data/lib/tachiban/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
end
|
|
91
|
+
return false if input_pass.nil?
|
|
34
92
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
#
|
|
110
|
-
#
|
|
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]
|
|
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
|
-
|
|
124
|
-
# The password reset functionalities include token generation,
|
|
125
|
-
# email body in the text as well as in the html format,
|
|
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
|
-
|
|
249
|
+
HTML
|
|
177
250
|
end
|
|
178
251
|
|
|
179
|
-
#
|
|
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
|
-
|
|
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")
|
|
186
|
-
|
|
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.
|
|
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.
|
|
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-
|
|
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:
|
|
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:
|
|
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:
|
|
118
|
+
version: 3.0.0
|
|
161
119
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
162
120
|
requirements:
|
|
163
121
|
- - ">="
|