aikotoba 0.2.0 → 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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +346 -198
  3. data/app/controllers/aikotoba/accounts_controller.rb +6 -1
  4. data/app/controllers/aikotoba/application_controller.rb +7 -1
  5. data/app/controllers/aikotoba/confirms_controller.rb +2 -2
  6. data/app/controllers/aikotoba/magic_links_controller.rb +103 -0
  7. data/app/controllers/aikotoba/recoveries_controller.rb +2 -2
  8. data/app/controllers/aikotoba/sessions_controller.rb +2 -2
  9. data/app/controllers/aikotoba/unlocks_controller.rb +2 -2
  10. data/app/controllers/concerns/aikotoba/authenticatable.rb +1 -22
  11. data/app/controllers/concerns/aikotoba/protection/rate_limiting.rb +2 -11
  12. data/app/controllers/concerns/aikotoba/request_backable.rb +53 -0
  13. data/app/controllers/concerns/aikotoba/scopable.rb +5 -5
  14. data/app/mailers/aikotoba/account_mailer.rb +35 -3
  15. data/app/models/aikotoba/account/confirmation_token.rb +6 -1
  16. data/app/models/aikotoba/account/magic_link.rb +38 -0
  17. data/app/models/aikotoba/account/magic_link_token.rb +27 -0
  18. data/app/models/aikotoba/account/{password → password_hash}/argon2.rb +1 -1
  19. data/app/models/aikotoba/account/password_hash.rb +97 -0
  20. data/app/models/aikotoba/account/recovery.rb +27 -1
  21. data/app/models/aikotoba/account/recovery_token.rb +6 -1
  22. data/app/models/aikotoba/account/session.rb +5 -3
  23. data/app/models/aikotoba/account/unlock_token.rb +6 -1
  24. data/app/models/aikotoba/account.rb +80 -60
  25. data/app/models/concerns/aikotoba/enabled_feature_checkable.rb +8 -0
  26. data/app/models/concerns/aikotoba/optional_association.rb +82 -0
  27. data/app/models/concerns/aikotoba/token_encryptable.rb +1 -11
  28. data/app/views/aikotoba/account_mailer/magic_link.html.erb +3 -0
  29. data/app/views/aikotoba/magic_links/new.html.erb +14 -0
  30. data/app/views/aikotoba/sessions/_links.html.erb +6 -1
  31. data/config/locales/en.yml +10 -0
  32. data/config/routes.rb +25 -16
  33. data/db/migrate/20211204121532_create_aikotoba_accounts.rb +77 -33
  34. data/lib/aikotoba/configuration.rb +99 -0
  35. data/lib/aikotoba/constraints/magic_link_authenticatable_constraint.rb +7 -0
  36. data/lib/aikotoba/engine.rb +13 -0
  37. data/lib/aikotoba/errors.rb +2 -0
  38. data/lib/aikotoba/scope.rb +64 -0
  39. data/lib/aikotoba/scopes.rb +102 -0
  40. data/lib/aikotoba/test/authentication_helper.rb +4 -4
  41. data/lib/aikotoba/version.rb +1 -1
  42. data/lib/aikotoba.rb +33 -67
  43. metadata +16 -5
  44. data/app/models/aikotoba/account/password.rb +0 -34
data/README.md CHANGED
@@ -22,6 +22,7 @@ Aikotoba is a Rails engine that makes it easy to implement simple email and pass
22
22
  - Confirmable(optional) : After registration, send an email with a token to confirm account.
23
23
  - Lockable(optional) : Lock account if make a mistake with password more than a certain number of times.
24
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.
25
26
 
26
27
  [For more information](#features)
27
28
 
@@ -33,6 +34,10 @@ Add this line to your application's Gemfile:
33
34
  gem 'aikotoba'
34
35
  ```
35
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
+
36
41
  ## Usage
37
42
 
38
43
  ### Getting Start
@@ -43,6 +48,30 @@ Aikotoba use `Aikotoba::Account` for authentication. Add it to the migration for
43
48
  $ bin/rails aikotoba:install:migrations
44
49
  ```
45
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
+
46
75
  Mount `Aikotoba::Engine` your application.
47
76
 
48
77
  ```ruby
@@ -87,12 +116,42 @@ Aikotoba enable helper methods for authentication. The method name can be change
87
116
 
88
117
  - `aikotoba_current_account` : Returns the logged in instance of `Aikotoba::Account`.
89
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
+
90
147
  ### Registrable
91
148
 
92
- 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.)
93
150
 
94
151
  ```ruby
95
- Aikotoba.registerable = true
152
+ Aikotoba.configure do |config|
153
+ config.registerable = true
154
+ end
96
155
  ```
97
156
 
98
157
  Register an account using email and password.
@@ -102,14 +161,43 @@ Register an account using email and password.
102
161
  | GET | /sign_up | Display sign up page. |
103
162
  | POST | /sign_up | Create an account. |
104
163
 
105
- 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.
106
192
 
107
193
  ### Confirmable
108
194
 
109
- To enable it, set `Aikotoba.confirmable` to `true`.
195
+ To enable it, set `config.confirmable` to `true` in `Aikotoba.configure`.
110
196
 
111
197
  ```ruby
112
- Aikotoba.confirmable = true
198
+ Aikotoba.configure do |config|
199
+ config.confirmable = true
200
+ end
113
201
  ```
114
202
 
115
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.
@@ -122,10 +210,12 @@ Aikotoba enable routes for confirmation account. Also, when account registers, a
122
210
 
123
211
  ### Lockable
124
212
 
125
- To enable it, set `Aikotoba.lockable` to `true`.
213
+ To enable it, set `config.lockable` to `true` in `Aikotoba.configure`.
126
214
 
127
215
  ```ruby
128
- Aikotoba.lockable = true
216
+ Aikotoba.configure do |config|
217
+ config.lockable = true
218
+ end
129
219
  ```
130
220
 
131
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.
@@ -138,10 +228,12 @@ Aikotoba enables a route to unlock an account. Also, if the authentication fails
138
228
 
139
229
  ### Recoverable
140
230
 
141
- To enable it, set `Aikotoba.recoverable` to `true`.
231
+ To enable it, set `config.recoverable` to `true` in `Aikotoba.configure`.
142
232
 
143
233
  ```ruby
144
- Aikotoba.recoverable = true
234
+ Aikotoba.configure do |config|
235
+ config.recoverable = true
236
+ end
145
237
  ```
146
238
 
147
239
  Aikotoba enables a route to recover an account by password reset.
@@ -153,9 +245,33 @@ Aikotoba enables a route to recover an account by password reset.
153
245
  | GET | /recover/:token | Display page for recover account by password reset. |
154
246
  | PATCH | /recover/:token | Recover account by password reset. |
155
247
 
156
- ### Rate Limiting (Rails 8+ only)
248
+ ### (Experimental) MagicLinkAuthenticatable
249
+
250
+ To enable it, set `config.magic_link_authenticatable` to `true` in `Aikotoba.configure`.
157
251
 
158
- Aikotoba provides built-in rate limiting for email-sending endpoints to prevent email bombing attacks. This feature requires **Rails 8.0 or later**.
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.
159
275
 
160
276
  Rate limiting is available for:
161
277
 
@@ -166,12 +282,14 @@ Rate limiting is available for:
166
282
  By default, rate limiting is disabled (empty configuration). To enable it, configure the respective options:
167
283
 
168
284
  ```ruby
169
- Aikotoba.confirmation_rate_limit_options = {
170
- to: 10,
171
- within: 1.hour,
172
- by: -> { request.params.dig(:account, :email).presence || request.remote_ip },
173
- only: :create
174
- }
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
175
293
  ```
176
294
 
177
295
  When rate limiting is triggered, requests that exceed the limit will receive a 429 (Too Many Requests) response.
@@ -182,21 +300,24 @@ For detailed configuration examples and options, see the [Configuration](#config
182
300
 
183
301
  Aikotoba supports multiple scopes.
184
302
 
185
- You can add a scope by `Aikotoba.add_scope` method. For example, the following code adds an `admin` scope. Unspecified values will be copied from the `default` scope.
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.
186
304
 
187
305
  ```ruby
188
- Aikotoba.add_scope(:admin, {
189
- session_key: "aikotoba_admin_session_token",
190
- root_path: "/admin",
191
- sign_in_path: "/admin/sign_in",
192
- sign_up_path: "/admin/sign_up",
193
- after_sign_in_path: "/admin/sensitives",
194
- after_sign_out_path: "/admin/sign_in",
195
- sign_out_path: "/admin/sign_out",
196
- confirm_path: "/admin/confirm",
197
- unlock_path: "/admin/unlock",
198
- recover_path: "/admin/recover"
199
- })
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
200
321
  ```
201
322
 
202
323
  As shown below, you can perform separate authentication with `/sign_in` and `/admin/sign_in`.
@@ -225,143 +346,166 @@ aikotoba_scoped_path(:new_session)
225
346
 
226
347
  ## Configuration
227
348
 
228
- 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:
229
356
 
230
357
  ```ruby
231
358
  require 'aikotoba'
232
359
 
233
- # ============================================
234
- # Global settings
235
- # ============================================
236
-
237
- Aikotoba.parent_controller = "ApplicationController"
238
- Aikotoba.parent_mailer = "ActionMailer::Base"
239
- Aikotoba.mailer_sender = "from@example.com"
240
- Aikotoba.email_format = /\A[^\s]+@[^\s]+\z/
241
- Aikotoba.password_pepper = "aikotoba-default-pepper"
242
- Aikotoba.password_length_range = 8..100
243
- Aikotoba.session_expiry = 7.days
244
-
245
-
246
- # for registerable
247
- Aikotoba.registerable = true
248
-
249
- # for confirmable
250
- Aikotoba.confirmable = false
251
- Aikotoba.confirmation_token_expiry = 1.day
252
-
253
- # for lockable
254
- Aikotoba.lockable = false
255
- Aikotoba.max_failed_attempts = 10
256
- Aikotoba.unlock_token_expiry = 1.day
257
-
258
- # for Recoverable
259
- Aikotoba.recoverable = false
260
- Aikotoba.recovery_token_expiry = 4.hours
261
-
262
- # ============================================
263
- # Rate Limiting (Rails 8+ required)
264
- # ============================================
265
- # Rate limiting protects email-sending endpoints (confirm, unlock, recover) from email bombing attacks.
266
- # Default (empty hash): no rate limiting
267
-
268
- # Requires Rails 8.0+ to use the built-in rate_limit feature.
269
- #
270
- # Configuration format: { to: <max_requests>, within: <time_duration>, by: <identifier_proc>, only: <action> }
271
- #
272
- # SECURITY RECOMMENDATION:
273
- # Use .dig() with fallback to prevent nil errors and ensure rate limiting always works:
274
- # by: -> { request.params.dig(:account, :email).presence || request.remote_ip }
275
- #
276
- # This ensures:
277
- # - Invalid or missing email params don't bypass rate limiting
278
- # - Fallback to IP address when email is not provided
279
- # - Protection against email enumeration attacks
280
- #
281
- # Examples:
282
-
283
- # Limit confirmation token requests to 10 per hour, per email address (with IP fallback)
284
- Aikotoba.confirmation_rate_limit_options = {
285
- to: 10,
286
- within: 1.hour,
287
- by: -> { request.params.dig(:account, :email).presence || request.remote_ip },
288
- only: :create
289
- }
290
-
291
- # Limit unlock token requests to 5 per hour, per email address (stricter for security)
292
- Aikotoba.unlock_rate_limit_options = {
293
- to: 5,
294
- within: 1.hour,
295
- by: -> { request.params.dig(:account, :email).presence || request.remote_ip },
296
- only: :create
297
- }
298
-
299
- # Limit recovery token requests to 3 per hour, per email address (most strict for password recovery)
300
- Aikotoba.recovery_rate_limit_options = {
301
- to: 3,
302
- within: 1.hour,
303
- by: -> { request.params.dig(:account, :email).presence || request.remote_ip },
304
- only: :create
305
- }
306
-
307
- # Rate limiting by IP address only (simpler, but less precise)
308
- # Aikotoba.confirmation_rate_limit_options = {
309
- # to: 20,
310
- # within: 1.hour,
311
- # by: -> { request.remote_ip },
312
- # only: :create
313
- # }
314
-
315
- # ============================================
316
- # Scope settings
317
- # ============================================
318
-
319
- # for Default Scope
320
- # You can override only the necessary keys.
321
- Aikotoba.default_scope = {
322
- authenticate_for: nil, # No restriction for default scope
323
- session_key: "aikotoba_session_token",
324
- root_path: "/",
325
- sign_in_path: "/sign_in",
326
- sign_out_path: "/sign_out",
327
- sign_up_path: "/sign_up",
328
- confirm_path: "/confirm",
329
- unlock_path: "/unlock",
330
- recover_path: "/recover",
331
- after_sign_in_path: "/sensitives",
332
- after_sign_out_path: "/sign_in"
333
- }
334
-
335
- # for Additional Scopes
336
- Aikotoba.add_scope(:admin, {
337
- authenticate_for: "Admin", # Restrict authentication to Admin accounts
338
- session_key: "aikotoba_admin_session_token",
339
- root_path: "/admin",
340
- sign_in_path: "/admin/sign_in",
341
- sign_out_path: "/admin/sign_out",
342
- sign_up_path: "/admin/sign_up",
343
- confirm_path: "/admin/confirm",
344
- unlock_path: "/admin/unlock",
345
- recover_path: "/admin/recover",
346
- after_sign_in_path: "/admin/sensitives",
347
- after_sign_out_path: "/admin/sign_in"
348
- })
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
349
491
  ```
350
492
 
351
493
  ### Scope Configuration Details
352
494
 
353
- `Aikotoba.default_scope=` **merges** the provided hash into the existing default scope (does not replace):
495
+ `config.default_scope=` **merges** the provided hash into the existing default scope (does not replace):
354
496
 
355
497
  ```ruby
356
- # Single key update
357
- Aikotoba.default_scope[:sign_in_path] = "/custom"
358
-
359
- # Multiple keys update (recommended for bulk changes)
360
- Aikotoba.default_scope = {
361
- sign_in_path: "/custom_sign_in",
362
- after_sign_in_path: "/dashboard"
363
- }
364
- # Other keys (root_path, session_key, etc.) remain unchanged
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
365
509
  ```
366
510
 
367
511
  Both approaches are valid. Use direct key assignment for single changes, or use `default_scope=` for updating multiple keys at once.
@@ -371,38 +515,41 @@ Both approaches are valid. Use direct key assignment for single changes, or use
371
515
  You can restrict authentication to specific target types by setting `authenticate_for`:
372
516
 
373
517
  ```ruby
374
- Aikotoba.add_scope(:admin, {
375
- authenticate_for: "Admin", # Only Admin accounts can sign in to this scope
376
- root_path: "/admin",
377
- sign_in_path: "/admin/sign_in",
378
- # ...
379
- })
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
380
526
  ```
381
527
 
382
528
  When `authenticate_for` is set, only accounts with matching `authenticate_target_type` will authenticate successfully in that scope.
383
529
  This is useful for separating authentication between different user types (e.g., Admin vs User).
384
530
 
385
- **Note:** The account type is determined by the model associated via `authenticate_target`. Set up your associations in `after_create_account_process`:
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)):
386
542
 
387
543
  ```ruby
388
- Rails.application.config.to_prepare do
389
- Aikotoba::AccountsController.class_eval do
390
- def after_create_account_process
391
- if aikotoba_scope.admin?
392
- admin = Admin.new(nickname: "admin_foo")
393
- @account.authenticate_target = admin
394
- admin.save!
395
- else
396
- user = User.new(nickname: "foo")
397
- @account.authenticate_target = user
398
- user.save!
399
- end
400
- @account.save!
401
- end
402
- end
403
- end
544
+ admin = Admin.create!(nickname: "admin")
545
+ Aikotoba::Account.create_by!(attributes: {email: "admin@example.com", password: "password", authenticate_target: admin})
404
546
  ```
405
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
+
406
553
  ## Tips
407
554
 
408
555
  ### Customize Message
@@ -414,14 +561,20 @@ All Messages are managed by `i18n` and can be freely overridden.
414
561
  By running the following script, you can hash and store passwords.
415
562
 
416
563
  ```ruby
417
- Aikotoba::Account.create!(email: "sample@example.com", password: "password")
418
- 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"})
419
566
  # => created account instance.
420
567
  ```
421
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
+
422
575
  ### Create other model with `Aikotoba::Account`.
423
576
 
424
- 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)).
425
578
 
426
579
  ```ruby
427
580
  require 'aikotoba'
@@ -429,16 +582,9 @@ require 'aikotoba'
429
582
  Rails.application.config.to_prepare do
430
583
  Aikotoba::AccountsController.class_eval do
431
584
  def after_create_account_process
432
- # You can get the scope name by `aikotoba_scope` method.
433
- if aikotoba_scope.admin?
434
- admin = Admin.new(nickname: "admin_foo")
435
- @account.authenticate_target = admin
436
- admin.save!
437
- else
438
- user = User.new(nickname: "foo")
439
- @account.authenticate_target = user
440
- user.save!
441
- end
585
+ user = User.new(nickname: "foo")
586
+ @account.authenticate_target = user
587
+ user.save!
442
588
  @account.save!
443
589
  end
444
590
  end
@@ -488,7 +634,9 @@ Tokens can be encrypted using Active Record Encryption, introduced in Active Rec
488
634
  To use it, enable Aikotoba.encipted_token in the initializer.
489
635
 
490
636
  ```ruby
491
- Aikotoba.encrypted_token = true
637
+ Aikotoba.configure do |config|
638
+ config.encrypted_token = true
639
+ end
492
640
  ```
493
641
 
494
642
  ### How to identify the controller provided.
@@ -532,13 +680,13 @@ class HelperTest < ActionDispatch::SystemTestCase
532
680
  driven_by :rack_test
533
681
 
534
682
  def setup
535
- email, password = ["email@example.com", "password"]
536
- @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})
537
685
  @account.save
538
686
  end
539
687
 
540
688
  test "sign_in by helper" do
541
- aikotoba_sign_in(@account)
689
+ aikotoba_sign_in(@account, password: @password)
542
690
  visit "/sensitives"
543
691
  assert_selector "h1", text: "Sensitive Page"
544
692
  click_on "Sign out"
@@ -546,7 +694,7 @@ class HelperTest < ActionDispatch::SystemTestCase
546
694
  end
547
695
 
548
696
  test "sign_out by helper" do
549
- aikotoba_sign_in(@account)
697
+ aikotoba_sign_in(@account, password: @password)
550
698
  visit "/sensitives"
551
699
  aikotoba_sign_out
552
700
  visit "/sensitives"