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.
- checksums.yaml +4 -4
- data/README.md +346 -198
- data/app/controllers/aikotoba/accounts_controller.rb +6 -1
- data/app/controllers/aikotoba/application_controller.rb +7 -1
- data/app/controllers/aikotoba/confirms_controller.rb +2 -2
- data/app/controllers/aikotoba/magic_links_controller.rb +103 -0
- data/app/controllers/aikotoba/recoveries_controller.rb +2 -2
- data/app/controllers/aikotoba/sessions_controller.rb +2 -2
- data/app/controllers/aikotoba/unlocks_controller.rb +2 -2
- data/app/controllers/concerns/aikotoba/authenticatable.rb +1 -22
- data/app/controllers/concerns/aikotoba/protection/rate_limiting.rb +2 -11
- data/app/controllers/concerns/aikotoba/request_backable.rb +53 -0
- data/app/controllers/concerns/aikotoba/scopable.rb +5 -5
- data/app/mailers/aikotoba/account_mailer.rb +35 -3
- data/app/models/aikotoba/account/confirmation_token.rb +6 -1
- data/app/models/aikotoba/account/magic_link.rb +38 -0
- data/app/models/aikotoba/account/magic_link_token.rb +27 -0
- data/app/models/aikotoba/account/{password → password_hash}/argon2.rb +1 -1
- data/app/models/aikotoba/account/password_hash.rb +97 -0
- data/app/models/aikotoba/account/recovery.rb +27 -1
- data/app/models/aikotoba/account/recovery_token.rb +6 -1
- data/app/models/aikotoba/account/session.rb +5 -3
- data/app/models/aikotoba/account/unlock_token.rb +6 -1
- data/app/models/aikotoba/account.rb +80 -60
- data/app/models/concerns/aikotoba/enabled_feature_checkable.rb +8 -0
- data/app/models/concerns/aikotoba/optional_association.rb +82 -0
- data/app/models/concerns/aikotoba/token_encryptable.rb +1 -11
- data/app/views/aikotoba/account_mailer/magic_link.html.erb +3 -0
- data/app/views/aikotoba/magic_links/new.html.erb +14 -0
- data/app/views/aikotoba/sessions/_links.html.erb +6 -1
- data/config/locales/en.yml +10 -0
- data/config/routes.rb +25 -16
- data/db/migrate/20211204121532_create_aikotoba_accounts.rb +77 -33
- data/lib/aikotoba/configuration.rb +99 -0
- data/lib/aikotoba/constraints/magic_link_authenticatable_constraint.rb +7 -0
- data/lib/aikotoba/engine.rb +13 -0
- data/lib/aikotoba/errors.rb +2 -0
- data/lib/aikotoba/scope.rb +64 -0
- data/lib/aikotoba/scopes.rb +102 -0
- data/lib/aikotoba/test/authentication_helper.rb +4 -4
- data/lib/aikotoba/version.rb +1 -1
- data/lib/aikotoba.rb +33 -67
- metadata +16 -5
- 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 `
|
|
149
|
+
To enable it, set `config.registerable` to `true` in `Aikotoba.configure`. (It is enabled by default.)
|
|
93
150
|
|
|
94
151
|
```ruby
|
|
95
|
-
Aikotoba.
|
|
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
|
|
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 `
|
|
195
|
+
To enable it, set `config.confirmable` to `true` in `Aikotoba.configure`.
|
|
110
196
|
|
|
111
197
|
```ruby
|
|
112
|
-
Aikotoba.
|
|
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 `
|
|
213
|
+
To enable it, set `config.lockable` to `true` in `Aikotoba.configure`.
|
|
126
214
|
|
|
127
215
|
```ruby
|
|
128
|
-
Aikotoba.
|
|
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 `
|
|
231
|
+
To enable it, set `config.recoverable` to `true` in `Aikotoba.configure`.
|
|
142
232
|
|
|
143
233
|
```ruby
|
|
144
|
-
Aikotoba.
|
|
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
|
-
###
|
|
248
|
+
### (Experimental) MagicLinkAuthenticatable
|
|
249
|
+
|
|
250
|
+
To enable it, set `config.magic_link_authenticatable` to `true` in `Aikotoba.configure`.
|
|
157
251
|
|
|
158
|
-
|
|
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.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
# for registerable
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
# for confirmable
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
# for lockable
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
# for Recoverable
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
#
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
#
|
|
267
|
-
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
#
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
#
|
|
278
|
-
#
|
|
279
|
-
#
|
|
280
|
-
#
|
|
281
|
-
#
|
|
282
|
-
|
|
283
|
-
#
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
#
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
#
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
# for
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
`
|
|
495
|
+
`config.default_scope=` **merges** the provided hash into the existing default scope (does not replace):
|
|
354
496
|
|
|
355
497
|
```ruby
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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.
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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.
|
|
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"
|