aikotoba 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +431 -52
  3. data/Rakefile +0 -2
  4. data/app/controllers/aikotoba/accounts_controller.rb +8 -3
  5. data/app/controllers/aikotoba/application_controller.rb +8 -1
  6. data/app/controllers/aikotoba/confirms_controller.rb +16 -16
  7. data/app/controllers/aikotoba/magic_links_controller.rb +103 -0
  8. data/app/controllers/aikotoba/recoveries_controller.rb +16 -16
  9. data/app/controllers/aikotoba/sessions_controller.rb +3 -3
  10. data/app/controllers/aikotoba/unlocks_controller.rb +16 -16
  11. data/app/controllers/concerns/aikotoba/authenticatable.rb +72 -17
  12. data/app/controllers/concerns/aikotoba/protection/rate_limiting.rb +23 -0
  13. data/app/controllers/concerns/aikotoba/request_backable.rb +53 -0
  14. data/app/controllers/concerns/aikotoba/scopable.rb +36 -0
  15. data/app/mailers/aikotoba/account_mailer.rb +35 -3
  16. data/app/models/aikotoba/account/{service/confirmation.rb → confirmation.rb} +1 -1
  17. data/app/models/aikotoba/account/confirmation_token.rb +8 -2
  18. data/app/models/aikotoba/account/{service/lock.rb → lock.rb} +1 -1
  19. data/app/models/aikotoba/account/magic_link.rb +38 -0
  20. data/app/models/aikotoba/account/magic_link_token.rb +27 -0
  21. data/app/models/aikotoba/account/{password → password_hash}/argon2.rb +1 -1
  22. data/app/models/aikotoba/account/password_hash.rb +97 -0
  23. data/app/models/aikotoba/account/recovery.rb +57 -0
  24. data/app/models/aikotoba/account/recovery_token.rb +8 -2
  25. data/app/models/aikotoba/account/session.rb +42 -0
  26. data/app/models/aikotoba/account/token.rb +2 -2
  27. data/app/models/aikotoba/account/unlock_token.rb +8 -2
  28. data/app/models/aikotoba/account.rb +106 -28
  29. data/app/models/concerns/aikotoba/enabled_feature_checkable.rb +8 -0
  30. data/app/models/concerns/aikotoba/optional_association.rb +82 -0
  31. data/app/models/concerns/aikotoba/token_encryptable.rb +2 -12
  32. data/app/views/aikotoba/account_mailer/magic_link.html.erb +3 -0
  33. data/app/views/aikotoba/accounts/new.html.erb +4 -4
  34. data/app/views/aikotoba/confirms/new.html.erb +3 -3
  35. data/app/views/aikotoba/magic_links/new.html.erb +14 -0
  36. data/app/views/aikotoba/recoveries/edit.html.erb +3 -3
  37. data/app/views/aikotoba/recoveries/new.html.erb +3 -3
  38. data/app/views/aikotoba/sessions/_links.html.erb +10 -5
  39. data/app/views/aikotoba/sessions/new.html.erb +3 -3
  40. data/app/views/aikotoba/unlocks/new.html.erb +3 -3
  41. data/config/locales/en.yml +15 -6
  42. data/config/routes.rb +39 -22
  43. data/db/migrate/20211204121532_create_aikotoba_accounts.rb +77 -22
  44. data/lib/aikotoba/configuration.rb +99 -0
  45. data/lib/aikotoba/constraints/magic_link_authenticatable_constraint.rb +7 -0
  46. data/lib/aikotoba/engine.rb +13 -0
  47. data/lib/aikotoba/errors.rb +2 -0
  48. data/lib/aikotoba/scope.rb +64 -0
  49. data/lib/aikotoba/scopes.rb +102 -0
  50. data/lib/aikotoba/test/authentication_helper.rb +4 -4
  51. data/lib/aikotoba/version.rb +1 -1
  52. data/lib/aikotoba.rb +43 -37
  53. metadata +24 -17
  54. data/app/controllers/concerns/aikotoba/protection/timing_atack.rb +0 -23
  55. data/app/models/aikotoba/account/password.rb +0 -34
  56. data/app/models/aikotoba/account/service/authentication.rb +0 -66
  57. data/app/models/aikotoba/account/service/recovery.rb +0 -31
  58. data/app/models/aikotoba/account/service/registration.rb +0 -30
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 173aca2780a3086a9276f56c4bd59a1c4aedb6aa2a8f2d3e29068f00e8a5f417
4
- data.tar.gz: d96bea3b841912e7586c07a984e58c6b0daea49109a3b73bc3cc90090cf7434f
3
+ metadata.gz: 6b0b72d1586573f955e4f2b143b377daa261589c8f9066484c7483c43bcfea13
4
+ data.tar.gz: f9f064a15674f5edb527b6f02dd69a82eec60908fe95a0095a40481eb6c9979d
5
5
  SHA512:
6
- metadata.gz: 050d2f984527212f1a0426fd830c0b0ae1dfff51ea3e75fda1a5596cc702175996f70c3bbf1ca9acf6b21ea2099863ee5dfa705654db8ad338053ce384aeb635
7
- data.tar.gz: 7efcedd94cb09d06f9d846d6d43931ded9eb243dd7625f5a6ab74a41682082ca2d2b4c00feb27a1c282006ab04e44b70086d7024554ac4fe7e73cae273369f93
6
+ metadata.gz: 12177b7c45f2ceda5e314ee2011012a706de1be4d7660d695bb91a74504fbd82fddc1b3c571f65a227385466a86cc00cf54b76225d7aff168f4de026aa43b041
7
+ data.tar.gz: 21161c313981b55908274231a9335d1c53df6cc77ed8cfeab43f3770f809006939324c5c58680422b0ca660232a6f3b18293aa91fff2b97948d1028a3fa7ad9c
data/README.md CHANGED
@@ -1,4 +1,5 @@
1
1
  [![CI](https://github.com/madogiwa0124/aikotoba/actions/workflows/ci.yml/badge.svg)](https://github.com/madogiwa0124/aikotoba/actions/workflows/ci.yml)
2
+ [![Gem Version](https://badge.fury.io/rb/aikotoba.svg)](https://badge.fury.io/rb/aikotoba)
2
3
 
3
4
  # Aikotoba
4
5
 
@@ -21,6 +22,7 @@ Aikotoba is a Rails engine that makes it easy to implement simple email and pass
21
22
  - Confirmable(optional) : After registration, send an email with a token to confirm account.
22
23
  - Lockable(optional) : Lock account if make a mistake with password more than a certain number of times.
23
24
  - Recoverable(optional) : Recover account by resetting password.
25
+ - MagicLinkAuthenticatable(optional) : (experimental) Authenticate account by clicking a one-time sign-in link sent by email, no password required.
24
26
 
25
27
  [For more information](#features)
26
28
 
@@ -32,6 +34,10 @@ Add this line to your application's Gemfile:
32
34
  gem 'aikotoba'
33
35
  ```
34
36
 
37
+ Aikotoba requires Rails 8.1 or later.
38
+
39
+ Emails (confirmation, unlock, password recovery, magic link) are sent with `deliver_later` after the surrounding transaction commits. Configure a persistent Active Job backend in production: the default `:async` adapter loses queued emails when the process restarts.
40
+
35
41
  ## Usage
36
42
 
37
43
  ### Getting Start
@@ -42,6 +48,30 @@ Aikotoba use `Aikotoba::Account` for authentication. Add it to the migration for
42
48
  $ bin/rails aikotoba:install:migrations
43
49
  ```
44
50
 
51
+ The copied migration creates the always-required tables (accounts / password hashes /
52
+ sessions) and carries one **commented-out** block per optional feature: Confirmable,
53
+ Lockable, Recoverable and MagicLinkAuthenticatable. All four
54
+ default to off (see [Configuration](#configuration)), so nothing is created for them
55
+ unless you ask for it. Uncomment the block for each feature you enable, then migrate:
56
+
57
+ ```sh
58
+ $ bin/rails db:migrate
59
+ ```
60
+
61
+ If you enable a feature *later*, after this migration has already run, uncommenting it is
62
+ not enough -- add the same `create_table` in a new migration of your own instead.
63
+
64
+ Either way, keeping "flag is on" and "its table exists" in sync is the host app's
65
+ responsibility. If a feature's flag is on without its table, you'll hit real database
66
+ errors (e.g. `PG::UndefinedTable`) the first time that feature's code path runs --
67
+ Aikotoba does not, and cannot, guard against that direction.
68
+
69
+ The reverse direction *is* safe: if a feature's table was never created, Aikotoba never
70
+ queries it on `Account#destroy!` or session revocation, so it's fine for that table to
71
+ not exist at all. Destroying accounts and revoking sessions also keeps working if you
72
+ enable a feature, use it, and later turn its flag back off -- the leftover token rows are
73
+ still cleaned up rather than left behind as orphans.
74
+
45
75
  Mount `Aikotoba::Engine` your application.
46
76
 
47
77
  ```ruby
@@ -86,12 +116,42 @@ Aikotoba enable helper methods for authentication. The method name can be change
86
116
 
87
117
  - `aikotoba_current_account` : Returns the logged in instance of `Aikotoba::Account`.
88
118
 
119
+ #### Redirecting back to the originally requested page after sign in
120
+
121
+ By default, a successful sign in always redirects to `after_sign_in_path`. To
122
+ instead send the account back to the page it was trying to reach before being
123
+ redirected to sign in, replace the `redirect_to` in your `authenticate_account!`
124
+ (see [Getting Start](#getting-start)) with `aikotoba_request_authentication`,
125
+ which takes the same options as `redirect_to` (modeled on the Rails
126
+ authentication generator's `request_authentication`):
127
+
128
+ ```ruby
129
+ def authenticate_account!
130
+ return if current_account
131
+ aikotoba_request_authentication flash: {alert: "Oops. You need to Signed up or Signed in." }
132
+ end
133
+ ```
134
+
135
+ It stores the current `request.fullpath` in the session, keyed per scope,
136
+ redirects to the scope's sign in page, and after the next successful sign in
137
+ redirects back there, falling back to `after_sign_in_path` when nothing was
138
+ stored. Only a plain page visit (a non-XHR HTML `GET`) is stored, a
139
+ protocol-relative path (`//host/...`) is never stored, so it can't become an
140
+ open redirect, and it's used at most once. It also applies to [MagicLinkAuthenticatable](#experimental-magiclinkauthenticatable)
141
+ sign in.
142
+
143
+ Aikotoba deliberately doesn't capture this from the `Referer` of `GET /sign_in`:
144
+ browsers keep the original `Referer` across a redirect, so it points at the
145
+ page that *linked to* the protected page rather than the protected page itself.
146
+
89
147
  ### Registrable
90
148
 
91
- To enable it, set `Aikotoba.registerable` to `true`. (It is enabled by default.)
149
+ To enable it, set `config.registerable` to `true` in `Aikotoba.configure`. (It is enabled by default.)
92
150
 
93
151
  ```ruby
94
- Aikotoba.registerable = true
152
+ Aikotoba.configure do |config|
153
+ config.registerable = true
154
+ end
95
155
  ```
96
156
 
97
157
  Register an account using email and password.
@@ -101,14 +161,43 @@ Register an account using email and password.
101
161
  | GET | /sign_up | Display sign up page. |
102
162
  | POST | /sign_up | Create an account. |
103
163
 
104
- The password is stored as a hash in [Argon2](https://github.com/technion/ruby-argon2).
164
+ The built-in sign up is meant for the simple case: an email and password account in a scope without `authenticate_for`. For anything more involved, such as extra form fields, creating a profile model together with the account, or creating accounts for a scope that sets `authenticate_for` (see [Filtering by authenticate target type](#filtering-by-authenticate-target-type)), write your own controller on top of the model methods instead of extending the built-in one:
165
+
166
+ ```ruby
167
+ class SignUpsController < ApplicationController
168
+ def create
169
+ @account = Aikotoba::Account.build_by(attributes: {email: sign_up_params[:email], password: sign_up_params[:password].to_s})
170
+ ActiveRecord::Base.transaction do
171
+ @account.authenticate_target = Profile.create!(nickname: sign_up_params[:nickname])
172
+ # Saves the account and, when confirmable, issues a confirmation token.
173
+ # The email is enqueued only after this transaction commits.
174
+ @account.register!
175
+ end
176
+ redirect_to aikotoba.new_session_path
177
+ rescue ActiveRecord::RecordInvalid
178
+ render :new, status: :unprocessable_entity
179
+ end
180
+
181
+ private
182
+
183
+ def sign_up_params
184
+ params.require(:account).permit(:email, :password, :nickname)
185
+ end
186
+ end
187
+ ```
188
+
189
+ Pass `password` even when it is blank (as above): `build_by` only builds a password when the key is present, so dropping it would save an account no one can sign in to. If the controller creates accounts for a restricted scope, such as Admins, put it behind your own authorization instead of exposing it publicly.
190
+
191
+ The password is stored as a hash in [Argon2](https://github.com/technion/ruby-argon2), in a separate `Aikotoba::Account::PasswordHash` record (table `aikotoba_account_password_hashes`) rather than on `Aikotoba::Account` itself, so `Account` does not depend on password-based authentication.
105
192
 
106
193
  ### Confirmable
107
194
 
108
- To enable it, set `Aikotoba.confirmable` to `true`.
195
+ To enable it, set `config.confirmable` to `true` in `Aikotoba.configure`.
109
196
 
110
197
  ```ruby
111
- Aikotoba.confirmable = true
198
+ Aikotoba.configure do |config|
199
+ config.confirmable = true
200
+ end
112
201
  ```
113
202
 
114
203
  Aikotoba enable routes for confirmation account. Also, when account registers, a confirmation email is sent to the email address. Only accounts that are confirmed will be authenticated.
@@ -121,10 +210,12 @@ Aikotoba enable routes for confirmation account. Also, when account registers, a
121
210
 
122
211
  ### Lockable
123
212
 
124
- To enable it, set `Aikotoba.lockable` to `true`.
213
+ To enable it, set `config.lockable` to `true` in `Aikotoba.configure`.
125
214
 
126
215
  ```ruby
127
- Aikotoba.lockable = true
216
+ Aikotoba.configure do |config|
217
+ config.lockable = true
218
+ end
128
219
  ```
129
220
 
130
221
  Aikotoba enables a route to unlock an account. Also, if the authentication fails a certain number of times, the account will be locked. Only accounts that are not locked will be authenticated.
@@ -137,10 +228,12 @@ Aikotoba enables a route to unlock an account. Also, if the authentication fails
137
228
 
138
229
  ### Recoverable
139
230
 
140
- To enable it, set `Aikotoba.recoverable` to `true`.
231
+ To enable it, set `config.recoverable` to `true` in `Aikotoba.configure`.
141
232
 
142
233
  ```ruby
143
- Aikotoba.recoverable = true
234
+ Aikotoba.configure do |config|
235
+ config.recoverable = true
236
+ end
144
237
  ```
145
238
 
146
239
  Aikotoba enables a route to recover an account by password reset.
@@ -152,46 +245,311 @@ Aikotoba enables a route to recover an account by password reset.
152
245
  | GET | /recover/:token | Display page for recover account by password reset. |
153
246
  | PATCH | /recover/:token | Recover account by password reset. |
154
247
 
248
+ ### (Experimental) MagicLinkAuthenticatable
249
+
250
+ To enable it, set `config.magic_link_authenticatable` to `true` in `Aikotoba.configure`.
251
+
252
+ ```ruby
253
+ Aikotoba.configure do |config|
254
+ config.magic_link_authenticatable = true
255
+ end
256
+ ```
257
+
258
+ Aikotoba enables a route to sign in via a one-time link sent by email —
259
+ no password needed. This works for accounts that were never given a
260
+ password at all (`Aikotoba::Account.create!(email: "sample@example.com")`),
261
+ since password storage is owned entirely by `Aikotoba::Account::PasswordHash`
262
+ and `Account` itself has no password of its own. Signing in works exactly
263
+ like `Authenticatable`, just entered by clicking the link instead of
264
+ submitting the sign-in form.
265
+
266
+ | HTTP Verb | Path | Overview |
267
+ | --------- | ------------------ | ----------------------------------------------- |
268
+ | GET | /magic_link | Display page for requesting a sign in link. |
269
+ | POST | /magic_link | Send a one-time sign in link to the account. |
270
+ | GET | /magic_link/:token | Verify the link and sign in. |
271
+
272
+ ### Rate Limiting
273
+
274
+ Aikotoba provides built-in rate limiting for email-sending endpoints to prevent email bombing attacks.
275
+
276
+ Rate limiting is available for:
277
+
278
+ - **Confirmation token requests** (`/confirm` POST endpoint)
279
+ - **Unlock token requests** (`/unlock` POST endpoint)
280
+ - **Password recovery token requests** (`/recover` POST endpoint)
281
+
282
+ By default, rate limiting is disabled (empty configuration). To enable it, configure the respective options:
283
+
284
+ ```ruby
285
+ Aikotoba.configure do |config|
286
+ config.confirmation_rate_limit_options = {
287
+ to: 10,
288
+ within: 1.hour,
289
+ by: -> { request.params.dig(:account, :email).presence || request.remote_ip },
290
+ only: :create
291
+ }
292
+ end
293
+ ```
294
+
295
+ When rate limiting is triggered, requests that exceed the limit will receive a 429 (Too Many Requests) response.
296
+
297
+ For detailed configuration examples and options, see the [Configuration](#configuration) section below.
298
+
299
+ ### Multiple Scopes
300
+
301
+ Aikotoba supports multiple scopes.
302
+
303
+ You can add a scope with `config.add_scope` in `Aikotoba.configure`. For example, the following code adds an `admin` scope. Unspecified values will be copied from the `default` scope.
304
+
305
+ ```ruby
306
+ Aikotoba.configure do |config|
307
+ config.add_scope(:admin, {
308
+ session_key: "aikotoba_admin_session_token",
309
+ root_path: "/admin",
310
+ sign_in_path: "/admin/sign_in",
311
+ sign_up_path: "/admin/sign_up",
312
+ after_sign_in_path: "/admin/sensitives",
313
+ after_sign_out_path: "/admin/sign_in",
314
+ sign_out_path: "/admin/sign_out",
315
+ confirm_path: "/admin/confirm",
316
+ unlock_path: "/admin/unlock",
317
+ recover_path: "/admin/recover",
318
+ magic_link_path: "/admin/magic_link"
319
+ })
320
+ end
321
+ ```
322
+
323
+ As shown below, you can perform separate authentication with `/sign_in` and `/admin/sign_in`.
324
+
325
+ ```sh
326
+ $ bin/rails routes
327
+ Routes for Aikotoba::Engine:
328
+ Prefix Verb URI Pattern Controller#Action
329
+ new_session GET /sign_in(.:format) aikotoba/sessions#new
330
+ admin_new_session GET /admin/sign_in(.:format) aikotoba/sessions#new
331
+ ```
332
+
333
+ The scope is determined dynamically by the root path. For example, when accessing `/admin/sign_in`, the `admin` scope is selected.
334
+
335
+ To automatically switch scopes and get paths, use the `Aikotoba::Scopable#aikotoba_scoped_path` helper method.
336
+
337
+ ```ruby
338
+ include Aikotoba::Scopable
339
+
340
+ helper_method :aikotoba_scoped_path
341
+
342
+ aikotoba_scoped_path(:new_session)
343
+ #=> "/sign_in" (if current scope is :default)
344
+ #=> "/admin/sign_in" (if current scope is :admin)
345
+ ```
346
+
155
347
  ## Configuration
156
348
 
157
- The following configuration parameters are supported. You can override it. (ex. `initializers/aikotoba.rb`)
349
+ Configure Aikotoba in one `Aikotoba.configure` block, in an initializer such as `config/initializers/aikotoba.rb`. Once the block has run, Aikotoba checks the scopes against every setting it made (see [Scope Configuration Details](#scope-configuration-details)). Read a setting with `Aikotoba.xxx`, e.g. `Aikotoba.confirmable`.
350
+
351
+ Write `authenticate_for` as a string (`"Admin"`, not `Admin`): your app's classes can't be autoloaded while initializers run.
352
+
353
+ The `Aikotoba.xxx = ...`, `Aikotoba.add_scope` and `Aikotoba.default_scope=` writers used before `configure` existed still work, but are deprecated and will be removed in 1.0: each call logs a deprecation warning through `Aikotoba.deprecator`, which is registered in `Rails.application.deprecators`. Move them into `Aikotoba.configure`.
354
+
355
+ The following configuration parameters are supported:
158
356
 
159
357
  ```ruby
160
358
  require 'aikotoba'
161
359
 
162
- Aikotoba.parent_controller = "ApplicationController"
163
- Aikotoba.parent_mailer = "ActionMailer::Base"
164
- Aikotoba.mailer_sender = "from@example.com"
165
- Aikotoba.email_format = /\A[^\s]+@[^\s]+\z/
166
- Aikotoba.prevent_timing_atack = true
167
- Aikotoba.password_pepper = "aikotoba-default-pepper"
168
- Aikotoba.password_length_range = 8..100
169
- Aikotoba.sign_in_path = "/sign_in"
170
- Aikotoba.sign_out_path = "/sign_out"
171
- Aikotoba.after_sign_in_path = "/"
172
- Aikotoba.after_sign_out_path = "/sign_in"
173
-
174
- # for registerable
175
- Aikotoba.registerable = true
176
- Aikotoba.sign_up_path = "/sign_up"
177
-
178
- # for confirmable
179
- Aikotoba.confirmable = false
180
- Aikotoba.confirm_path = "/confirm"
181
- Aikotoba.confirmation_token_expiry = 1.day
182
-
183
- # for lockable
184
- Aikotoba.lockable = false
185
- Aikotoba.unlock_path = "/unlock"
186
- Aikotoba.max_failed_attempts = 10
187
- Aikotoba.unlock_token_expiry = 1.day
188
-
189
- # for Recoverable
190
- Aikotoba.recoverable = false
191
- Aikotoba.recover_path = "/recover"
192
- Aikotoba.recovery_token_expiry = 4.hours
360
+ Aikotoba.configure do |config|
361
+ # ============================================
362
+ # Global settings
363
+ # ============================================
364
+
365
+ config.parent_controller = "ApplicationController"
366
+ config.parent_mailer = "ActionMailer::Base"
367
+ config.mailer_sender = "from@example.com"
368
+ config.email_format = /\A[^\s]+@[^\s]+\z/
369
+ config.password_pepper = "aikotoba-default-pepper"
370
+ config.password_length_range = 8..100
371
+ config.session_expiry = 7.days
372
+
373
+ # for registerable
374
+ config.registerable = true
375
+
376
+ # for confirmable
377
+ config.confirmable = false
378
+ config.confirmation_token_expiry = 1.day
379
+
380
+ # for lockable
381
+ config.lockable = false
382
+ config.max_failed_attempts = 10
383
+ config.unlock_token_expiry = 1.day
384
+
385
+ # for Recoverable
386
+ config.recoverable = false
387
+ config.recovery_token_expiry = 4.hours
388
+
389
+ # for (Experimental) MagicLinkAuthenticatable
390
+ config.magic_link_authenticatable = false
391
+ config.magic_link_token_expiry = 15.minutes
392
+
393
+ # ============================================
394
+ # Rate Limiting
395
+ # ============================================
396
+ # Rate limiting protects email-sending endpoints (confirm, unlock, recover) from email bombing attacks.
397
+ # Default (empty hash): no rate limiting
398
+
399
+ # Requires Rails 8.0+ to use the built-in rate_limit feature.
400
+ #
401
+ # Configuration format: { to: <max_requests>, within: <time_duration>, by: <identifier_proc>, only: <action> }
402
+ #
403
+ # SECURITY RECOMMENDATION:
404
+ # Use .dig() with fallback to prevent nil errors and ensure rate limiting always works:
405
+ # by: -> { request.params.dig(:account, :email).presence || request.remote_ip }
406
+ #
407
+ # This ensures:
408
+ # - Invalid or missing email params don't bypass rate limiting
409
+ # - Fallback to IP address when email is not provided
410
+ # - Repeated emails to the same address are throttled (this keys on the email, so it does not limit
411
+ # requests spread across many different addresses)
412
+ #
413
+ # Examples:
414
+
415
+ # Limit confirmation token requests to 10 per hour, per email address (with IP fallback)
416
+ config.confirmation_rate_limit_options = {
417
+ to: 10,
418
+ within: 1.hour,
419
+ by: -> { request.params.dig(:account, :email).presence || request.remote_ip },
420
+ only: :create
421
+ }
422
+
423
+ # Limit unlock token requests to 5 per hour, per email address (stricter for security)
424
+ config.unlock_rate_limit_options = {
425
+ to: 5,
426
+ within: 1.hour,
427
+ by: -> { request.params.dig(:account, :email).presence || request.remote_ip },
428
+ only: :create
429
+ }
430
+
431
+ # Limit recovery token requests to 3 per hour, per email address (most strict for password recovery)
432
+ config.recovery_rate_limit_options = {
433
+ to: 3,
434
+ within: 1.hour,
435
+ by: -> { request.params.dig(:account, :email).presence || request.remote_ip },
436
+ only: :create
437
+ }
438
+
439
+ # Limit magic link requests to 5 per hour, per email address
440
+ config.magic_link_rate_limit_options = {
441
+ to: 5,
442
+ within: 1.hour,
443
+ by: -> { request.params.dig(:account, :email).presence || request.remote_ip },
444
+ only: :create
445
+ }
446
+
447
+ # Rate limiting by IP address only (simpler, but less precise)
448
+ # config.confirmation_rate_limit_options = {
449
+ # to: 20,
450
+ # within: 1.hour,
451
+ # by: -> { request.remote_ip },
452
+ # only: :create
453
+ # }
454
+
455
+ # ============================================
456
+ # Scope settings
457
+ # ============================================
458
+
459
+ # for Default Scope
460
+ # You can override only the necessary keys.
461
+ config.default_scope = {
462
+ authenticate_for: nil, # No restriction for default scope
463
+ session_key: "aikotoba_session_token",
464
+ root_path: "/",
465
+ sign_in_path: "/sign_in",
466
+ sign_out_path: "/sign_out",
467
+ sign_up_path: "/sign_up",
468
+ confirm_path: "/confirm",
469
+ unlock_path: "/unlock",
470
+ recover_path: "/recover",
471
+ magic_link_path: "/magic_link",
472
+ after_sign_in_path: "/sensitives",
473
+ after_sign_out_path: "/sign_in"
474
+ }
475
+
476
+ # for Additional Scopes
477
+ config.add_scope(:admin, {
478
+ authenticate_for: "Admin", # Restrict authentication to Admin accounts
479
+ session_key: "aikotoba_admin_session_token",
480
+ root_path: "/admin",
481
+ sign_in_path: "/admin/sign_in",
482
+ sign_out_path: "/admin/sign_out",
483
+ confirm_path: "/admin/confirm",
484
+ unlock_path: "/admin/unlock",
485
+ recover_path: "/admin/recover",
486
+ magic_link_path: "/admin/magic_link",
487
+ after_sign_in_path: "/admin/sensitives",
488
+ after_sign_out_path: "/admin/sign_in"
489
+ })
490
+ end
491
+ ```
492
+
493
+ ### Scope Configuration Details
494
+
495
+ `config.default_scope=` **merges** the provided hash into the existing default scope (does not replace):
496
+
497
+ ```ruby
498
+ Aikotoba.configure do |config|
499
+ # Single key update
500
+ config.default_scope[:sign_in_path] = "/custom"
501
+
502
+ # Multiple keys update (recommended for bulk changes)
503
+ config.default_scope = {
504
+ sign_in_path: "/custom_sign_in",
505
+ after_sign_in_path: "/dashboard"
506
+ }
507
+ # Other keys (root_path, session_key, etc.) remain unchanged
508
+ end
509
+ ```
510
+
511
+ Both approaches are valid. Use direct key assignment for single changes, or use `default_scope=` for updating multiple keys at once.
512
+
513
+ #### Filtering by authenticate target type
514
+
515
+ You can restrict authentication to specific target types by setting `authenticate_for`:
516
+
517
+ ```ruby
518
+ Aikotoba.configure do |config|
519
+ config.add_scope(:admin, {
520
+ authenticate_for: "Admin", # Only Admin accounts can sign in to this scope
521
+ root_path: "/admin",
522
+ sign_in_path: "/admin/sign_in",
523
+ # ...
524
+ })
525
+ end
526
+ ```
527
+
528
+ When `authenticate_for` is set, only accounts with matching `authenticate_target_type` will authenticate successfully in that scope.
529
+ This is useful for separating authentication between different user types (e.g., Admin vs User).
530
+
531
+ The same rule applies to the scope's confirm, unlock, password reset and magic link endpoints: they only send emails to, and accept tokens from, accounts with a matching `authenticate_target_type`. A scope without `authenticate_for` handles every account, just as it lets every account sign in.
532
+
533
+ The links in those emails point to the account's own scope: the scope whose `authenticate_for` matches the account's `authenticate_target_type`, or the default scope if none does. This holds wherever the email was requested from, since it is rendered in a background job with no request to tell.
534
+
535
+ An added scope must set its own `root_path`: an absolute path other than `"/"`, such as `"/admin"`. Aikotoba treats every request under a scope's `root_path` as that scope's, so a scope at `"/"` would take every request, your app's own pages included, away from the default scope, and a malformed one such as `"admin"` would match none, not even its own pages. `add_scope` copies `root_path: "/"` from the default scope, so leaving it unset raises `Aikotoba::Errors::InvalidScopeConfiguration` too.
536
+
537
+ Each `authenticate_for` value may be set on only one scope, so that an account's type maps to exactly one scope. Otherwise `Aikotoba::Errors::InvalidScopeConfiguration` is raised when the scope is added, or at boot once your initializers have run. Keep in mind that `add_scope` copies unspecified keys from the default scope, including `authenticate_for`. If the default scope sets one, give every added scope its own `authenticate_for` (`nil` for an unrestricted scope).
538
+
539
+ The same copying applies to paths, but they need no extra care. Aikotoba decides which scope a request belongs to by its `root_path`, so it draws each route path of a scope under that scope's `root_path`. A path already under `root_path` (`/admin/sign_in` in an `/admin` scope) is used as is. Any other path is prefixed with `root_path`: an unset `magic_link_path` inherits `/magic_link` from the default scope and is drawn as `/admin/magic_link`, and `/magic` becomes `/admin/magic`. Redirect targets such as `after_sign_in_path` are not prefixed. If you set an absolute path outside `root_path` on the scope itself (say `magic_link_path: "/magic"` in an `/admin` scope), it is still drawn as `/admin/magic`, and Aikotoba logs a warning at boot, since you may have meant `/magic` as is. Write `/admin/magic` or the relative `magic` to silence it.
540
+
541
+ **Note:** A scope with `authenticate_for` has no sign up, even when `Aikotoba.registerable` is enabled: no route is generated for its `sign_up_path`, and its sign in page shows no sign up link. Its accounts need an `authenticate_target` of that type, which the generic sign up form can't create, and a public form that could would let anyone create, say, an Admin account. Create these accounts yourself (from the console, a seed, an invitation flow, or your own controller as described in [Registrable](#registrable)):
542
+
543
+ ```ruby
544
+ admin = Admin.create!(nickname: "admin")
545
+ Aikotoba::Account.create_by!(attributes: {email: "admin@example.com", password: "password", authenticate_target: admin})
193
546
  ```
194
547
 
548
+ `create_by!` only saves the account. With Confirmable enabled it starts unconfirmed and can't sign in yet:
549
+
550
+ - To send the confirmation email, build it and call `register!` instead: `Aikotoba::Account.build_by(attributes: {email: "admin@example.com", password: "password", authenticate_target: admin}).register!`
551
+ - If you create the account yourself and trust the address, mark it confirmed with `account.confirm!`.
552
+
195
553
  ## Tips
196
554
 
197
555
  ### Customize Message
@@ -203,14 +561,20 @@ All Messages are managed by `i18n` and can be freely overridden.
203
561
  By running the following script, you can hash and store passwords.
204
562
 
205
563
  ```ruby
206
- Aikotoba::Account.create!(email: "sample@example.com", password: "password")
207
- Aikotoba::Account.authenticate_by(attributes: {email: "sample@example.com", password: "password"})
564
+ Aikotoba::Account.create_by!(attributes: {email: "sample@example.com", password: "password"})
565
+ Aikotoba::Account::PasswordHash.authenticate_by(attributes: {email: "sample@example.com", password: "password"})
208
566
  # => created account instance.
209
567
  ```
210
568
 
569
+ To update an existing account's email and/or password together in one validated save, use `#update_by!` (or the non-bang `#update_by`), the update-side counterpart to `.create_by!`/`.build_by`:
570
+
571
+ ```ruby
572
+ account.update_by!(attributes: {email: "new@example.com", password: "new_password"})
573
+ ```
574
+
211
575
  ### Create other model with `Aikotoba::Account`.
212
576
 
213
- You can override `Aikotoba::AccountsController#after_create_account_process` to create the other models together.
577
+ You can override `Aikotoba::AccountsController#after_create_account_process` to create the other models together. Sign up only exists in scopes without `authenticate_for` (see [Filtering by authenticate target type](#filtering-by-authenticate-target-type)).
214
578
 
215
579
  ```ruby
216
580
  require 'aikotoba'
@@ -218,9 +582,10 @@ require 'aikotoba'
218
582
  Rails.application.config.to_prepare do
219
583
  Aikotoba::AccountsController.class_eval do
220
584
  def after_create_account_process
221
- profile = Profile.new(nickname: "foo")
222
- profile.save!
223
- @account.update!(authenticate_target: profile)
585
+ user = User.new(nickname: "foo")
586
+ @account.authenticate_target = user
587
+ user.save!
588
+ @account.save!
224
589
  end
225
590
  end
226
591
  end
@@ -229,8 +594,20 @@ class Profile < ApplicationRecord
229
594
  has_one :account, class_name: 'Aikotoba::Account', as: :authenticate_target
230
595
  end
231
596
 
597
+ class Admin < ApplicationRecord
598
+ has_one :account, class_name: 'Aikotoba::Account', as: :authenticate_target
599
+ end
600
+ ```
601
+
602
+ Then, you can get the associated model from `Aikotoba::Account` instance.
603
+
604
+ ```ruby
605
+
232
606
  current_account.profile #=> Profile instance
233
607
  profile.account #=> Aikotoba::Account instance
608
+
609
+ current_account.admin #=> Admin instance
610
+ admin.account #=> Aikotoba::Account instance
234
611
  ```
235
612
 
236
613
  ### Do something on before, after, failure.
@@ -257,7 +634,9 @@ Tokens can be encrypted using Active Record Encryption, introduced in Active Rec
257
634
  To use it, enable Aikotoba.encipted_token in the initializer.
258
635
 
259
636
  ```ruby
260
- Aikotoba.encypted_token = true
637
+ Aikotoba.configure do |config|
638
+ config.encrypted_token = true
639
+ end
261
640
  ```
262
641
 
263
642
  ### How to identify the controller provided.
@@ -301,13 +680,13 @@ class HelperTest < ActionDispatch::SystemTestCase
301
680
  driven_by :rack_test
302
681
 
303
682
  def setup
304
- email, password = ["email@example.com", "password"]
305
- @account = ::Aikotoba::Account.build_by(attributes: {email: email, password: password})
683
+ email, @password = ["email@example.com", "password"]
684
+ @account = ::Aikotoba::Account.build_by(attributes: {email: email, password: @password})
306
685
  @account.save
307
686
  end
308
687
 
309
688
  test "sign_in by helper" do
310
- aikotoba_sign_in(@account)
689
+ aikotoba_sign_in(@account, password: @password)
311
690
  visit "/sensitives"
312
691
  assert_selector "h1", text: "Sensitive Page"
313
692
  click_on "Sign out"
@@ -315,7 +694,7 @@ class HelperTest < ActionDispatch::SystemTestCase
315
694
  end
316
695
 
317
696
  test "sign_out by helper" do
318
- aikotoba_sign_in(@account)
697
+ aikotoba_sign_in(@account, password: @password)
319
698
  visit "/sensitives"
320
699
  aikotoba_sign_out
321
700
  visit "/sensitives"
data/Rakefile CHANGED
@@ -3,8 +3,6 @@ require "bundler/setup"
3
3
  APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
4
  load "rails/tasks/engine.rake"
5
5
 
6
- load "rails/tasks/statistics.rake"
7
-
8
6
  require "bundler/gem_tasks"
9
7
 
10
8
  require "rake/testtask"
@@ -27,15 +27,20 @@ module Aikotoba
27
27
  end
28
28
 
29
29
  def build_account(params)
30
- Account.build_by(attributes: params)
30
+ # NOTE: This is the password sign-up form, so it must always attempt to build a
31
+ # password credential — otherwise an omitted :password key (as opposed to a
32
+ # blank one) makes Account.build_by skip it entirely, silently registering an
33
+ # account no auth method can ever sign into. Defaulting the key to "" ensures
34
+ # Account::PasswordHash's own presence validation is what actually rejects it.
35
+ Account.build_by(attributes: {password: ""}.merge(params))
31
36
  end
32
37
 
33
38
  def save_with_callbacks!(account)
34
- Account::Service::Registration.call!(account: account)
39
+ account.register!
35
40
  end
36
41
 
37
42
  def after_sign_up_path
38
- aikotoba.new_session_path
43
+ aikotoba_scoped_path(:new_session_path)
39
44
  end
40
45
 
41
46
  def successed_message