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.
- checksums.yaml +4 -4
- data/README.md +431 -52
- data/Rakefile +0 -2
- data/app/controllers/aikotoba/accounts_controller.rb +8 -3
- data/app/controllers/aikotoba/application_controller.rb +8 -1
- data/app/controllers/aikotoba/confirms_controller.rb +16 -16
- data/app/controllers/aikotoba/magic_links_controller.rb +103 -0
- data/app/controllers/aikotoba/recoveries_controller.rb +16 -16
- data/app/controllers/aikotoba/sessions_controller.rb +3 -3
- data/app/controllers/aikotoba/unlocks_controller.rb +16 -16
- data/app/controllers/concerns/aikotoba/authenticatable.rb +72 -17
- data/app/controllers/concerns/aikotoba/protection/rate_limiting.rb +23 -0
- data/app/controllers/concerns/aikotoba/request_backable.rb +53 -0
- data/app/controllers/concerns/aikotoba/scopable.rb +36 -0
- data/app/mailers/aikotoba/account_mailer.rb +35 -3
- data/app/models/aikotoba/account/{service/confirmation.rb → confirmation.rb} +1 -1
- data/app/models/aikotoba/account/confirmation_token.rb +8 -2
- data/app/models/aikotoba/account/{service/lock.rb → lock.rb} +1 -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 +57 -0
- data/app/models/aikotoba/account/recovery_token.rb +8 -2
- data/app/models/aikotoba/account/session.rb +42 -0
- data/app/models/aikotoba/account/token.rb +2 -2
- data/app/models/aikotoba/account/unlock_token.rb +8 -2
- data/app/models/aikotoba/account.rb +106 -28
- 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 +2 -12
- data/app/views/aikotoba/account_mailer/magic_link.html.erb +3 -0
- data/app/views/aikotoba/accounts/new.html.erb +4 -4
- data/app/views/aikotoba/confirms/new.html.erb +3 -3
- data/app/views/aikotoba/magic_links/new.html.erb +14 -0
- data/app/views/aikotoba/recoveries/edit.html.erb +3 -3
- data/app/views/aikotoba/recoveries/new.html.erb +3 -3
- data/app/views/aikotoba/sessions/_links.html.erb +10 -5
- data/app/views/aikotoba/sessions/new.html.erb +3 -3
- data/app/views/aikotoba/unlocks/new.html.erb +3 -3
- data/config/locales/en.yml +15 -6
- data/config/routes.rb +39 -22
- data/db/migrate/20211204121532_create_aikotoba_accounts.rb +77 -22
- 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 +43 -37
- metadata +24 -17
- data/app/controllers/concerns/aikotoba/protection/timing_atack.rb +0 -23
- data/app/models/aikotoba/account/password.rb +0 -34
- data/app/models/aikotoba/account/service/authentication.rb +0 -66
- data/app/models/aikotoba/account/service/recovery.rb +0 -31
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b0b72d1586573f955e4f2b143b377daa261589c8f9066484c7483c43bcfea13
|
|
4
|
+
data.tar.gz: f9f064a15674f5edb527b6f02dd69a82eec60908fe95a0095a40481eb6c9979d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 12177b7c45f2ceda5e314ee2011012a706de1be4d7660d695bb91a74504fbd82fddc1b3c571f65a227385466a86cc00cf54b76225d7aff168f4de026aa43b041
|
|
7
|
+
data.tar.gz: 21161c313981b55908274231a9335d1c53df6cc77ed8cfeab43f3770f809006939324c5c58680422b0ca660232a6f3b18293aa91fff2b97948d1028a3fa7ad9c
|
data/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
[](https://github.com/madogiwa0124/aikotoba/actions/workflows/ci.yml)
|
|
2
|
+
[](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 `
|
|
149
|
+
To enable it, set `config.registerable` to `true` in `Aikotoba.configure`. (It is enabled by default.)
|
|
92
150
|
|
|
93
151
|
```ruby
|
|
94
|
-
Aikotoba.
|
|
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
|
|
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 `
|
|
195
|
+
To enable it, set `config.confirmable` to `true` in `Aikotoba.configure`.
|
|
109
196
|
|
|
110
197
|
```ruby
|
|
111
|
-
Aikotoba.
|
|
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 `
|
|
213
|
+
To enable it, set `config.lockable` to `true` in `Aikotoba.configure`.
|
|
125
214
|
|
|
126
215
|
```ruby
|
|
127
|
-
Aikotoba.
|
|
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 `
|
|
231
|
+
To enable it, set `config.recoverable` to `true` in `Aikotoba.configure`.
|
|
141
232
|
|
|
142
233
|
```ruby
|
|
143
|
-
Aikotoba.
|
|
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
|
-
|
|
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.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
# for confirmable
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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.
|
|
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
|
@@ -27,15 +27,20 @@ module Aikotoba
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def build_account(params)
|
|
30
|
-
|
|
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
|
-
|
|
39
|
+
account.register!
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
def after_sign_up_path
|
|
38
|
-
|
|
43
|
+
aikotoba_scoped_path(:new_session_path)
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
def successed_message
|