tachiban 1.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: 2b2e100df48cb7de260705114b0c7d282bf2e67f8073442d987cb0295e985d48
4
- data.tar.gz: 810a6b68cef7fd9177ef3083b05b0cc083eaecbc26e02b0621194c7f8c582db3
3
+ metadata.gz: 7608a00958bd0996ebd4d9f0aee635a3d99a2f997bce0be1fcf25672aeba9d59
4
+ data.tar.gz: 9ba708be4e0d7b9686ef2b00483d695151ccdffa4e39f6ceaabcc6c341bcf9b3
5
5
  SHA512:
6
- metadata.gz: b884dd3638bc74fb54ccd11a1957d5fa6d4b32bf5a20aff39cfc300ce794069d4812b1decef520a2f31db2b91e2c4d41424e1747b4892b8321b67c0680dbe4e5
7
- data.tar.gz: 64b5a67347b6cfc4c62ef3df0afb8e21305ef140807334ebbf3748fba2d8d9445d014b385735434fa238fb1b441df00b4e341156e78e9650b3df16e962779a91
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
@@ -1,8 +1,8 @@
1
1
  # Tachiban
2
2
 
3
- [![Join the chat at https://gitter.im/sebastjan-hribar/tachiban](https://badges.gitter.im/sebastjan-hribar/tachiban.svg)](https://gitter.im/sebastjan-hribar/tachiban?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Gem Version](https://badge.fury.io/rb/tachiban.svg)](https://badge.fury.io/rb/tachiban) [![Build Status](https://travis-ci.org/sebastjan-hribar/tachiban.svg?branch=master)](https://travis-ci.org/sebastjan-hribar/tachiban)
3
+ [![Join the chat at https://gitter.im/sebastjan-hribar/tachiban](https://badges.gitter.im/sebastjan-hribar/tachiban.svg)](https://gitter.im/sebastjan-hribar/tachiban?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Gem Version](https://badge.fury.io/rb/tachiban.svg?kill_cache=1)](https://badge.fury.io/rb/tachiban)
4
4
 
5
- Tachiban (立ち番 - standing watch) provides simple authentication system for [Hanami web applications](http://hanamirb.org/) by using Argon2 for password hashing and
5
+ Tachiban (立ち番 - standing watch) provides simple authentication system for [Hanami 2.x web applications](http://hanamirb.org/) by using Argon2 for password hashing and
6
6
  offers the following functionalities (with methods listed below
7
7
  under Methods by features):
8
8
  - Signup
@@ -12,7 +12,10 @@ offers the following functionalities (with methods listed below
12
12
  - Password reset
13
13
  - Authorization has been moved to [Rokku](https://github.com/sebastjan-hribar/rokku)
14
14
 
15
- ## Installation
15
+ **Note:** For Hanami 1.3 support, see the [1.0.0 branch](https://github.com/sebastjan-hribar/tachiban/tree/1.0.0) or install Tachiban 1.0.
16
+
17
+
18
+ ## 1. Installation
16
19
 
17
20
  Add this line to your application's Gemfile:
18
21
 
@@ -28,21 +31,43 @@ Or install it yourself as:
28
31
 
29
32
  $ gem install tachiban
30
33
 
31
- Tachiban is already setup to be included by your Hanami application:
34
+
35
+ Tachiban needs to be included in the action:
32
36
 
33
37
  ```ruby
34
- ::Hanami::Controller.configure do
35
- prepare do
38
+ # app/action.rb
39
+ # auto_register: false
40
+ # frozen_string_literal: true
41
+
42
+ require "hanami/action"
43
+ require "dry/monads"
44
+ require "tachiban"
45
+
46
+ module MyApplication
47
+ class Action < Hanami::Action
48
+ # Provide `Success` and `Failure` for pattern matching on operation results
49
+ include Dry::Monads[:result]
36
50
  include Hanami::Tachiban
51
+
52
+ handle_exception "ROM::TupleCountMismatchError" => :handle_not_found
53
+
54
+ private
55
+
56
+ def handle_not_found(request, response, exception)
57
+ response.status = 404
58
+ response.format = :html
59
+ response.body = "Not found"
60
+ end
37
61
  end
38
62
  end
39
63
  ```
40
64
 
41
- ## Usage
65
+ ## 2. Usage
42
66
 
43
- ### Prerequisites
67
+ ### 2.1 Prerequisites
44
68
  Prior to logging in or authenticating the user, retrieve the entity from the
45
- database and assign it to the instance variable of `@user`.
69
+ database and assign it to a variable (e.g. `user`), which you then pass to
70
+ the methods as required.
46
71
 
47
72
  In addition to that, the user entity must have the following attributes:
48
73
 
@@ -51,136 +76,365 @@ In addition to that, the user entity must have the following attributes:
51
76
  * **hashed_pass** (to hold the generated hashed password)
52
77
 
53
78
 
54
- ### Usage
79
+ ### 2.2 Usage
55
80
 
56
- #### Signup
81
+ #### 2.2.3 Signup
57
82
  To create a user with a hashed password use the `hashed_password(password)`
58
83
  method for the password and store it as the user's attribute `hashed_pass`.
59
84
 
60
85
  *Example*
61
86
 
62
87
  ```ruby
63
- # Create action for an entity
64
- def call(params)
65
- password = params[:newuser][:password]
88
+ # Create action for the user
89
+ def handle(request, response)
90
+ password = request.params[:newuser][:password]
66
91
  hashed_pass = hashed_password(password)
67
- repository = UserRepository.new
68
92
 
69
- @user = repository.create(name: name, surname: surname, email: email,
70
- hashed_pass: hashed_pass))
93
+ user = user_repo.create(name: name, surname: surname, email: email,
94
+ hashed_pass: hashed_pass)
71
95
  end
72
96
  ```
73
97
 
74
- #### Authentication and login
75
- To authenticate a user use the `authenticated?(input_password)` method and log
76
- them in with the `login` method. Authentication is successful if the user exists and passwords match.
98
+ #### 2.2.4 Authentication and login
99
+ To authenticate a user use the `authenticated?(input_password, user)` method and log
100
+ them in with the `login(request, response, user_id, flash_message: nil, login_redirect_url: nil)` method.
101
+
102
+ Authentication is successful if the user exists and passwords match. It's possible to provide your own flash message and / or redirect url. Otherwise, the **default values** will be used (see the table below).
77
103
 
78
- The user is logged in by setting the user object ID as the `session[:current_user]`.
79
- After the user is logged in the session start time is defined as
80
- `session[:session_start_time] = Time.now`. A default flash message is also
104
+ The user is logged in by setting the user object ID as the `request.session[:current_user]`.
105
+ After the user is logged in, the session start time is defined as
106
+ `request.session[:session_start_time] = Time.now`. A default flash message is also
81
107
  assigned as 'You have been successfully logged in.'.
82
108
 
83
- The `session[:session_start_time]` is then used by the `session_expired?`
84
- method to determine whether the session has expired or not.
109
+ The `request.session[:session_start_time]` is then used by the `session_expired?(request, response)` method to determine whether the session has expired or not.
85
110
 
86
- *Example of session creation for an entity*
111
+ **_Example of session creation for an entity_**
87
112
 
88
113
  ```ruby
89
- # Create action for an entity session
90
- email = params[:entity_session][:email]
91
- password = params[:entity_session][:password]
114
+ # Create action for the user session
115
+ email = request.params[:entity_session][:email]
116
+ password = request.params[:entity_session][:password]
92
117
 
93
- @user = EntityRepository.new.find_by_email(email)
94
- login if authenticated?(password)
118
+ user = user_repo.find_by_email(email) #required by login
119
+ login(request, response, user.id) if authenticated?(password, user)
95
120
  ```
96
121
 
97
- To check whether the user is logged in use the `check_for_logged_in_user` method.
98
- If the user is not logged in the `logout` method takes over.
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.
99
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.
100
127
 
101
- #### Session handling
128
+
129
+ #### 2.2.5 Session handling
102
130
  Tachiban handles session expiration by checking if a session has
103
131
  expired and then restarts the session start time if the session
104
132
  is still valid or proceeds with the following if the session
105
133
  has expired:
106
134
 
107
- - setting the `session[:current_user]` to `nil`,
108
- - a flash message is set: `flash[:failed_notice] = "Your session has expired"`,
109
- - redirects to the `routes.root_path` which can be overwritten by assigning
110
- a different url to @redirect_url.
135
+ - setting the `request.session[:current_user]` to `nil`,
136
+ - a flash message is set: `response.flash[:failed_notice] = "Your session has expired"`,
137
+ - redirects to the root path `/`, which can be overridden.
138
+
111
139
 
140
+ The `session_expired?(request, validity_time: nil)` method compares the session start time
141
+ increased for the defined `validity_time` (set to 10 minutes
142
+ by default, but can be overridden) with the current time.
112
143
 
113
- The `session_expired?` method compares the session start time
114
- increased for the defined `@validity_time` (set to 10 minutes
115
- by default, but can be overwritten) with the current time.
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.
147
+
148
+
149
+ #### 2.2.6 Session handling in a share code module
150
+ It is possible to enable session handling in a share code module as provided by Hanami.
151
+ To do this, create an authentication module in **app/actions/authentication.rb**.
152
+ The example below shows also how to custom values to replace default values in
153
+ actions.
116
154
 
117
- `handle_session` method:
118
155
  ```ruby
119
- def handle_session
120
- if session_expired?
121
- @redirect_url ||= routes.root_path
122
- session[:current_user] = nil
123
- flash[:failed_notice] = "Your session has expired."
124
- redirect_to @redirect_url
125
- else
126
- restart_session_counter
156
+ module MyApplication
157
+ module Actions
158
+ module Authentication
159
+ def self.included(action_class)
160
+ action_class.class_eval do
161
+ before :check_for_logged_in_user
162
+ before :handle_session
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ def custom_handle_session_redirect_url
169
+ '/login'
170
+ end
171
+
172
+ def custom_logout_redirect_url
173
+ '/login'
174
+ end
175
+
176
+ def custom_login_redirect_url
177
+ '/'
178
+ end
179
+
180
+ def custom_session_validity_time
181
+ if ENV['HANAMI_ENV'] == 'test'
182
+ 600
183
+ else
184
+ 1800
185
+ end
186
+ end
127
187
  end
128
188
  end
189
+ end
190
+ ```
191
+ We can then simply include the `Authentication` module in actions, where required.
192
+
193
+ However, if we include this in the base action class, it will be available in all
194
+ actions and there is no need for separate includes in actions:
195
+
196
+ ```ruby
197
+ #
198
+ #
199
+ module MyApplication
200
+ class Action < Hanami::Action
201
+ # Provide `Success` and `Failure` for pattern matching on operation results
202
+ include Dry::Monads[:result]
203
+ include Hanami::Tachiban
204
+ include MyApplication::Actions::Authentication
205
+
206
+ handle_exception "ROM::TupleCountMismatchError" => :handle_not_found
207
+
208
+ private
209
+ #
210
+ #
129
211
  ```
130
212
 
131
- *Example of session handling in a share code module*
213
+
214
+ **_Disabling the authentication shared module methods in specific actions_**
215
+
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:
132
223
 
133
224
  ```ruby
134
- module Web
135
- module HandleSession
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
136
235
 
137
- def self.included(action)
138
- action.class_eval do
139
- before :handle_session
140
- end
141
- end
236
+ private
142
237
 
238
+ def check_for_logged_in_user; end
239
+ def handle_session; end
240
+ end
241
+ end
143
242
  end
144
243
  end
145
244
  ```
146
245
 
246
+ #### 2.2.7 Password reset
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.
251
+
252
+ The link validity must me specified in seconds. The method compares the
253
+ current time with the time when the password reset link was sent increased
254
+ by the link validity: `Time.now > user.password_reset_sent_at + link_validity`.
255
+
147
256
 
148
- #### Password reset
149
- The password reset feature provides a few simple methods to generate a
150
- token, email subject and body. It is also possible to specify and
151
- check the validity of the password reset url.
257
+ **Generating a reset link**
152
258
 
153
259
  ```ruby
154
- 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)
155
262
  ```
156
263
 
157
264
  ```ruby
158
- email_subject(SomeApp) # => "SomeApp -- password reset request"
265
+ email_subject("SomeApp") # => "SomeApp -- password reset request"
266
+ ```
267
+
268
+ Provide the reset url, user's name, link validity, time unit and optionally the
269
+ application name when building the body:
270
+
271
+ ```ruby
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
+ )
279
+ ```
280
+
281
+ `app_name` falls back to the `APP_NAME` environment variable, then to your Hanami
282
+ app's namespace, then to "Application".
283
+
284
+
285
+ **Verifying a reset link**
286
+
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:
291
+
292
+ ```ruby
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
159
303
  ```
160
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**
161
318
 
162
- Provide the base url, the token and the number and type of the time units
163
- for the validity of the link.
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.
164
322
 
165
323
  ```ruby
166
- body = email_body(base_url, url_token, 2, "hour")
167
- # => "Visit this url to reset your password: http://localhost:2300/passwordupdate/asdasdasdaerwrw.
168
- # The url will be valid for 2 hour(s).")
324
+ user_repo.update(
325
+ user.id,
326
+ hashed_pass: hashed_password(new_password),
327
+ **reset_token_attributes
328
+ )
169
329
  ```
170
330
 
171
- The link validity must me specified in seconds. The method compares the
172
- current time with the time when the password reset link was sent increased
173
- by the link validity: `Time.now > @user.password_reset_sent_at + link_validity`
331
+
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:
174
357
 
175
358
  ```ruby
176
- password_reset_url_valid?(link_validity)
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
177
386
  ```
178
387
 
179
- ### Example of use in an application
180
- [Using Tachiban with a Hanami app](https://sebastjan-hribar.github.io/programming/2021/09/03/tachiban-with-hanami.html)
181
388
 
182
389
 
183
- ### Changelog
390
+
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.
427
+
428
+ #### 2.0.0
429
+
430
+ **Breaking Changes:**
431
+ - Supports Hanami ~> 2.0 applications only.
432
+ - Method signatures updated: `login`, `logout`, `check_for_logged_in_user`, and `handle_session` now require `(request, response)` parameters.
433
+ - Methods `session_expired?` and `restart_session_counter` now require `(request)` parameter.
434
+ - Tachiban must be explicitly included in the base action: `include Hanami::Tachiban`.
435
+ - Tachiban 2.0.0 doesn't rely on instance variable like `@user` anymore. Instead, a `user` variable must be passed as an argument to a method.
436
+
437
+ For Hanami 1.3 support, use Tachiban 1.0.
184
438
 
185
439
  #### 1.0.0
186
440
 
data/Rakefile CHANGED
@@ -1,7 +1,5 @@
1
1
  require "bundler/gem_tasks"
2
2
  require "rake/testtask"
3
- require "hanami/controller"
4
- require 'hanami/action/session'
5
3
 
6
4
  Rake::TestTask.new(:test) do |t|
7
5
  t.libs << "test"
@@ -1,3 +1,3 @@
1
1
  module Tachiban
2
- VERSION = "1.0.0"
2
+ VERSION = "2.1.0"
3
3
  end
data/lib/tachiban.rb CHANGED
@@ -1,148 +1,298 @@
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 ###
10
9
 
11
- # ### Signup ###
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.
12
21
 
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.
22
+ DUMMY_HASH = Argon2::Password.create(SecureRandom.hex(32)).freeze
19
23
 
20
- def hashed_password(password)
21
- Argon2::Password.create(password)
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
22
67
  end
23
68
 
24
- # ### Login ###
25
69
 
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
70
+ # ### Signup ###
30
71
 
31
- def authenticated?(input_pass)
32
- @user && Argon2::Password.verify_password(input_pass, @user.hashed_pass)
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.
78
+ def hashed_password(password)
79
+ Argon2::Password.create(password)
33
80
  end
34
81
 
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.
41
-
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.
82
+ # ### Login ###
45
83
 
46
- # Example:
47
- # login if authenticated?(input_pass)
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.
90
+ def authenticated?(input_pass, user)
91
+ return false if input_pass.nil?
48
92
 
49
- def login
50
- session[:current_user] = @user.id
51
- session[:session_start_time] = Time.now
52
- @flash_message ||= 'You have been successfully logged in.'
53
- flash[:success_notice] = @flash_message
54
- @login_redirect_url ||= routes.root_path
55
- redirect_to @login_redirect_url
56
- end
93
+ if user.nil? || user.hashed_pass.nil?
94
+ Argon2::Password.verify_password(input_pass, DUMMY_HASH)
95
+ return false
96
+ end
57
97
 
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.
98
+ Argon2::Password.verify_password(input_pass, user.hashed_pass)
62
99
 
63
- def logout
64
- session[:current_user] = nil
65
- session.clear
66
- @logout_redirect_url ||= '/login'
67
- redirect_to @logout_redirect_url
100
+ rescue Argon2::ArgonHashFail
101
+ false
68
102
  end
69
103
 
70
- # ### Authentication ###
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.
71
110
 
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.
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.
75
114
 
76
- def check_for_logged_in_user
77
- logout unless session[:current_user]
115
+ # Example:
116
+ # login(request, response, user.id) if authenticated?(input_pass)
117
+ def login(request, response, user_id, flash_message: nil, login_redirect_url: nil)
118
+ request.session[:current_user] = user_id
119
+ request.session[:session_start_time] = Time.now
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, '/'))
78
123
  end
79
124
 
80
- # ### Session handling ###
81
-
82
- # Session handling includes methods session_expired?,
83
- # restart_session_counter and handle session.
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.
129
+ def logout(request, response, logout_redirect_url: nil)
130
+ request.session[:current_user] = nil
131
+ request.session.clear
132
+ response.redirect_to(logout_redirect_url || tachiban_custom(:custom_logout_redirect_url, "/login"))
133
+ end
84
134
 
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.
135
+ # ### Authentication ###
88
136
 
89
- def session_expired?
90
- if session[:current_user]
91
- @validity_time ||= 600
92
- session[:session_start_time] + @validity_time.to_i < Time.now
93
- end
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.
140
+ def check_for_logged_in_user(request, response)
141
+ logout(request, response) unless request.session[:current_user]
94
142
  end
95
143
 
96
- # The restart_session_counter method resets the session start time to
97
- # Time.now. It's used in the handle session method.
144
+ # ### Session handling ###
98
145
 
99
- def restart_session_counter
100
- session[:session_start_time] = Time.now
101
- end
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.
153
+ def session_expired?(request, validity_time: nil)
154
+ return false unless request.session[:current_user]
155
+ return true unless request.session[:session_start_time]
102
156
 
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.
157
+ validity_time ||= tachiban_custom(:custom_session_validity_time, 600)
158
+ request.session[:session_start_time] + validity_time.to_i < Time.now
159
+ end
108
160
 
109
- # If the session hasn't expired the restart_session_counter method is
110
- # called to reset the session start time.
161
+ # The `restart_session_counter` method resets the session start time to
162
+ # `Time.now`. It's used in the `handle_session` method.
163
+ def restart_session_counter(request)
164
+ request.session[:session_start_time] = Time.now
165
+ end
111
166
 
112
- def handle_session
113
- if session_expired?
114
- @redirect_url ||= routes.root_path
115
- session[:current_user] = nil
116
- flash[:failed_notice] = 'Your session has expired.'
117
- redirect_to @redirect_url
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.
176
+ def handle_session(request, response, redirect_url: nil)
177
+ if session_expired?(request)
178
+ redirect_url ||= tachiban_custom(:custom_handle_session_redirect_url, "/")
179
+ request.session[:current_user] = nil
180
+ response.flash[:failed_notice] = tachiban_custom(:custom_session_expired_message,
181
+ 'Your session has expired.')
182
+ response.redirect_to redirect_url
118
183
  else
119
- restart_session_counter
184
+ restart_session_counter(request)
120
185
  end
121
186
  end
122
187
 
123
- # ### Password reset ###
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.
124
192
  def token
125
- 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}
126
203
  end
127
204
 
128
205
  def email_subject(app_name)
206
+ app_name ||= default_app_name
129
207
  "#{app_name} -- password reset request"
130
208
  end
131
209
 
132
- def email_body(url, token, link_validity, time_unit)
133
- "Visit this url to reset your password: #{url}#{token}. \n
134
- The url will be valid for #{link_validity} #{time_unit}(s)."
210
+ def email_body_text(reset_url:, user_name:, link_validity:, time_unit:, app_name: nil)
211
+ app_name ||= default_app_name
212
+
213
+ <<~TEXT
214
+ Hello #{user_name},
215
+
216
+ Click the link below to reset your password:
217
+
218
+ #{reset_url}
219
+
220
+ This link will expire in #{link_validity} #{time_unit}(s).
221
+
222
+ Kind regards,
223
+ The #{app_name} Team
224
+ TEXT
135
225
  end
136
226
 
137
- # State the link_validity in seconds.
138
- def password_reset_url_valid?(link_validity)
139
- Time.now < @user.password_reset_sent_at + link_validity
227
+ def email_body_html(reset_url:, user_name:, link_validity:, time_unit:, app_name: nil)
228
+ app_name ||= default_app_name
229
+
230
+ <<~HTML
231
+ <!DOCTYPE html>
232
+ <html>
233
+ <body style="font-family: Arial, sans-serif;">
234
+ <h2>Password reset request</h2>
235
+ <br>
236
+ <p>Hello #{user_name},</p>
237
+ <br>
238
+ <p>Click the button below to reset your password:</p>
239
+ <p>
240
+ <a href="#{reset_url}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;">
241
+ Reset Password
242
+ </a>
243
+ </p>
244
+ <br>
245
+ <p>Or copy this link: #{reset_url}</p>
246
+ <p style="color: #666; font-size: 12px;">This link expires in #{link_validity} #{time_unit}(s).</p>
247
+ </body>
248
+ </html>
249
+ HTML
140
250
  end
141
- end
142
- end
143
251
 
144
- ::Hanami::Controller.configure do
145
- prepare do
146
- include Hanami::Tachiban
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.
260
+ def password_reset_url_valid?(link_validity, user)
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
288
+ end
289
+
290
+ def default_app_name
291
+ ENV.fetch("APP_NAME") do
292
+ Hanami.respond_to?(:app) ? Hanami.app.namespace.to_s : "Application"
293
+ end
294
+ rescue StandardError
295
+ "Application"
296
+ end
147
297
  end
148
- end
298
+ end
data/tachiban.gemspec CHANGED
@@ -17,16 +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 "bundler", "~> 2.0"
21
- spec.add_development_dependency "rake", "~> 12.3", ">= 12.3.3"
20
+ spec.add_development_dependency "rake", "~> 13.0"
22
21
  spec.add_development_dependency "minitest", "~> 5.0"
23
- spec.add_development_dependency "hanami-model", "~> 1.0"
24
- spec.add_development_dependency "timecop", "0.8.1"
25
- spec.add_development_dependency 'hanami-controller', "~> 1.0"
26
- spec.add_development_dependency 'hanami-router', "~> 1.0"
27
- spec.add_development_dependency 'pry', "~> 0"
22
+ spec.add_development_dependency "timecop", "~> 0.9.0"
23
+ spec.add_development_dependency 'pry', "~> 0.16.0"
28
24
 
29
25
  spec.add_runtime_dependency "argon2", "~> 2.3"
30
- spec.add_runtime_dependency 'hanami-controller', "~> 1.0"
31
- spec.add_runtime_dependency 'hanami-router', "~> 1.0"
32
- end
26
+ spec.required_ruby_version = ">= 3.0.0"
27
+ end
metadata CHANGED
@@ -1,49 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tachiban
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastjan Hribar
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-10-09 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: bundler
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '2.0'
20
- type: :development
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: '2.0'
27
13
  - !ruby/object:Gem::Dependency
28
14
  name: rake
29
15
  requirement: !ruby/object:Gem::Requirement
30
16
  requirements:
31
17
  - - "~>"
32
18
  - !ruby/object:Gem::Version
33
- version: '12.3'
34
- - - ">="
35
- - !ruby/object:Gem::Version
36
- version: 12.3.3
19
+ version: '13.0'
37
20
  type: :development
38
21
  prerelease: false
39
22
  version_requirements: !ruby/object:Gem::Requirement
40
23
  requirements:
41
24
  - - "~>"
42
25
  - !ruby/object:Gem::Version
43
- version: '12.3'
44
- - - ">="
45
- - !ruby/object:Gem::Version
46
- version: 12.3.3
26
+ version: '13.0'
47
27
  - !ruby/object:Gem::Dependency
48
28
  name: minitest
49
29
  requirement: !ruby/object:Gem::Requirement
@@ -58,76 +38,34 @@ dependencies:
58
38
  - - "~>"
59
39
  - !ruby/object:Gem::Version
60
40
  version: '5.0'
61
- - !ruby/object:Gem::Dependency
62
- name: hanami-model
63
- requirement: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '1.0'
68
- type: :development
69
- prerelease: false
70
- version_requirements: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - "~>"
73
- - !ruby/object:Gem::Version
74
- version: '1.0'
75
41
  - !ruby/object:Gem::Dependency
76
42
  name: timecop
77
- requirement: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - '='
80
- - !ruby/object:Gem::Version
81
- version: 0.8.1
82
- type: :development
83
- prerelease: false
84
- version_requirements: !ruby/object:Gem::Requirement
85
- requirements:
86
- - - '='
87
- - !ruby/object:Gem::Version
88
- version: 0.8.1
89
- - !ruby/object:Gem::Dependency
90
- name: hanami-controller
91
43
  requirement: !ruby/object:Gem::Requirement
92
44
  requirements:
93
45
  - - "~>"
94
46
  - !ruby/object:Gem::Version
95
- version: '1.0'
47
+ version: 0.9.0
96
48
  type: :development
97
49
  prerelease: false
98
50
  version_requirements: !ruby/object:Gem::Requirement
99
51
  requirements:
100
52
  - - "~>"
101
53
  - !ruby/object:Gem::Version
102
- version: '1.0'
103
- - !ruby/object:Gem::Dependency
104
- name: hanami-router
105
- requirement: !ruby/object:Gem::Requirement
106
- requirements:
107
- - - "~>"
108
- - !ruby/object:Gem::Version
109
- version: '1.0'
110
- type: :development
111
- prerelease: false
112
- version_requirements: !ruby/object:Gem::Requirement
113
- requirements:
114
- - - "~>"
115
- - !ruby/object:Gem::Version
116
- version: '1.0'
54
+ version: 0.9.0
117
55
  - !ruby/object:Gem::Dependency
118
56
  name: pry
119
57
  requirement: !ruby/object:Gem::Requirement
120
58
  requirements:
121
59
  - - "~>"
122
60
  - !ruby/object:Gem::Version
123
- version: '0'
61
+ version: 0.16.0
124
62
  type: :development
125
63
  prerelease: false
126
64
  version_requirements: !ruby/object:Gem::Requirement
127
65
  requirements:
128
66
  - - "~>"
129
67
  - !ruby/object:Gem::Version
130
- version: '0'
68
+ version: 0.16.0
131
69
  - !ruby/object:Gem::Dependency
132
70
  name: argon2
133
71
  requirement: !ruby/object:Gem::Requirement
@@ -142,35 +80,7 @@ dependencies:
142
80
  - - "~>"
143
81
  - !ruby/object:Gem::Version
144
82
  version: '2.3'
145
- - !ruby/object:Gem::Dependency
146
- name: hanami-controller
147
- requirement: !ruby/object:Gem::Requirement
148
- requirements:
149
- - - "~>"
150
- - !ruby/object:Gem::Version
151
- version: '1.0'
152
- type: :runtime
153
- prerelease: false
154
- version_requirements: !ruby/object:Gem::Requirement
155
- requirements:
156
- - - "~>"
157
- - !ruby/object:Gem::Version
158
- version: '1.0'
159
- - !ruby/object:Gem::Dependency
160
- name: hanami-router
161
- requirement: !ruby/object:Gem::Requirement
162
- requirements:
163
- - - "~>"
164
- - !ruby/object:Gem::Version
165
- version: '1.0'
166
- type: :runtime
167
- prerelease: false
168
- version_requirements: !ruby/object:Gem::Requirement
169
- requirements:
170
- - - "~>"
171
- - !ruby/object:Gem::Version
172
- version: '1.0'
173
- description:
83
+ description:
174
84
  email:
175
85
  - sebastjan.hribar@gmail.com
176
86
  executables:
@@ -197,7 +107,7 @@ homepage: https://github.com/sebastjan-hribar/tachiban
197
107
  licenses:
198
108
  - MIT
199
109
  metadata: {}
200
- post_install_message:
110
+ post_install_message:
201
111
  rdoc_options: []
202
112
  require_paths:
203
113
  - lib
@@ -205,15 +115,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
205
115
  requirements:
206
116
  - - ">="
207
117
  - !ruby/object:Gem::Version
208
- version: '0'
118
+ version: 3.0.0
209
119
  required_rubygems_version: !ruby/object:Gem::Requirement
210
120
  requirements:
211
121
  - - ">="
212
122
  - !ruby/object:Gem::Version
213
123
  version: '0'
214
124
  requirements: []
215
- rubygems_version: 3.1.6
216
- signing_key:
125
+ rubygems_version: 3.5.9
126
+ signing_key:
217
127
  specification_version: 4
218
128
  summary: Tachiban provides simple password hashing for user authentication with Argon2
219
129
  for Hanami web applications.