composable-pwdless 0.0.13 → 0.0.14
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/CHANGELOG.md +7 -0
- data/README.md +225 -21
- data/app/controllers/composable/pwdless/auth_controller.rb +19 -12
- data/app/controllers/composable/pwdless/base_controller.rb +3 -1
- data/app/mailers/composable/pwdless/mailer.rb +1 -5
- data/app/models/composable/pwdless/secret.rb +38 -43
- data/app/services/composable/pwdless/form/authentication.rb +5 -4
- data/app/services/composable/pwdless/form/verification.rb +21 -8
- data/app/views/composable/pwdless/auth/edit.html.erb +5 -5
- data/app/views/composable/pwdless/auth/new.html.erb +1 -1
- data/config/locales/en.yml +1 -1
- data/db/migrate/{db/migrate/20221211203235_create_composable_pwdless_secrets.rb → 20221211203235_create_composable_pwdless_secrets.rb} +2 -2
- data/lib/composable/pwdless/engine.rb +4 -0
- data/lib/composable/pwdless/gem_version.rb +1 -1
- data/lib/composable/pwdless/router_helpers.rb +3 -2
- data/lib/composable/pwdless.rb +23 -2
- metadata +22 -8
- /data/app/views/composable/pwdless/mailer/{notification_email.html.txt → notification_email.text.erb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8be6e95a72868e29205609ce41c057868b0a3d406c75b090fd28ee8fee9a217
|
|
4
|
+
data.tar.gz: f6f38b1248c802560d711555b4a2d94dea7aac2178d43fb2f25f9466b295b081
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eb8505135ed8a78e7a779bc0f861396430e4d468aec2963c674cba0c45b718a1dea9e96a3433f78de052b2b3ea122ecf52c97ca494d57ac7c61bcddef2a90a24
|
|
7
|
+
data.tar.gz: 6f405c552cafef3d1c35aef7ba08ee20e833a9bb6f26b3d46555ee9b840e7f7023a0e27c84f15ff244a304a2f99ad1b31c72e4deca2df17775a8f293563d4462
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
- Refresh cached verification challenges when inputs change; do not cache failed lookups.
|
|
4
|
+
- Consume purpose-bound codes once with atomic attempt accounting and keyed digests.
|
|
5
|
+
- Add explicit CSRF protection, issuance rate limiting and challenge parameter filtering.
|
|
6
|
+
- Validate code configuration and remove codes from email subjects.
|
|
7
|
+
- Fix direct dependencies, migration placement, routing, sign-out and text email templates.
|
|
8
|
+
- Document application responsibilities and add complete coverage including PostgreSQL concurrency.
|
|
9
|
+
|
|
3
10
|
## [0.1.0] - 2022-12-01
|
|
4
11
|
|
|
5
12
|
- Initial release
|
data/README.md
CHANGED
|
@@ -1,43 +1,247 @@
|
|
|
1
|
-
#
|
|
1
|
+
# composable-pwdless
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Rails engine for email verification challenges and single-use passwordless
|
|
4
|
+
codes. Requires Ruby 3.2+ and Rails 7.2+. It depends on `composable-form` directly.
|
|
5
|
+
Use PostgreSQL for the verified concurrent authentication behavior.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
## Install and configure
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Add `gem "composable-pwdless"` to the application's Gemfile, then run:
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
```sh
|
|
12
|
+
bundle install
|
|
13
|
+
bin/rails composable_pwdless:install:migrations
|
|
14
|
+
bin/rails db:migrate
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Configure the engine in a Rails initializer:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
Composable::Pwdless.setup do |config|
|
|
21
|
+
config.parent_controller = "ApplicationController"
|
|
22
|
+
config.parent_mailer = "ActionMailer::Base"
|
|
23
|
+
config.mailer_sender = "sign-in@example.test"
|
|
24
|
+
config.maximum_attempts = 3
|
|
25
|
+
config.expires_in = 5.minutes
|
|
26
|
+
config.code_length = 6
|
|
27
|
+
config.code_charset = [*"0".."9"]
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Set parent classes before the corresponding controller/mailer is loaded.
|
|
32
|
+
`parent_controller` must support Rails browser controllers, CSRF protection and
|
|
33
|
+
rate limiting. `mailer_template_path` defaults to `"composable/pwdless/mailer"`.
|
|
34
|
+
`mailer_sender` may be a string or Proc; mailer default `from`/`reply_to` values
|
|
35
|
+
(including callable defaults) take precedence.
|
|
36
|
+
|
|
37
|
+
Attempts must be positive; expiry must be a positive ActiveSupport duration. Code length is an integer from 1 to 128;
|
|
38
|
+
charset entries must be distinct single-character strings with at least two
|
|
39
|
+
values. The character set and length must represent at least one million possible
|
|
40
|
+
codes. Invalid configuration raises `ArgumentError` when creating a challenge.
|
|
41
|
+
|
|
42
|
+
## Routes and application session
|
|
43
|
+
|
|
44
|
+
Inside the application's routes, use `composable_pwdless_for :members,
|
|
45
|
+
controller: "member_sessions"`. This creates GET/POST/PUT `members/sign_in` and
|
|
46
|
+
DELETE `members/sign_out`, with `members_sign_in` and `members_sign_out` helpers.
|
|
47
|
+
`as:` changes the helper prefix. The default controller is
|
|
48
|
+
`composable/pwdless/auth`; leading slashes on explicit controller names are accepted.
|
|
10
49
|
|
|
11
50
|
```ruby
|
|
12
|
-
|
|
51
|
+
class MemberSessionsController < Composable::Pwdless::AuthController
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def verification_succeeded(email)
|
|
55
|
+
member = Member.find_by!(email: email) # Apply the application's eligibility rules.
|
|
56
|
+
reset_session
|
|
57
|
+
session[:member_id] = member.id
|
|
58
|
+
redirect_to main_app.root_path
|
|
59
|
+
end
|
|
60
|
+
end
|
|
13
61
|
```
|
|
14
62
|
|
|
15
|
-
|
|
63
|
+
The application supplies `Member`, eligibility checks and its session policy.
|
|
64
|
+
The default success/failure hooks only redirect to the application's root; they
|
|
65
|
+
do not create accounts or sign users in. Default `destroy` resets the session.
|
|
66
|
+
Authentication requires explicit account authorization even when an email code
|
|
67
|
+
is valid. Use one resource name per identity realm: challenges issued for
|
|
68
|
+
`members` cannot be verified through `admins` routes.
|
|
69
|
+
|
|
70
|
+
## Forms and lifecycle
|
|
71
|
+
|
|
72
|
+
`Form::Authentication.call(email:, purpose:)` validates email and creates a
|
|
73
|
+
challenge. Its public `code` and `salt` are used for delivery; its `result` is a
|
|
74
|
+
`Form::Verification` containing salt/data/purpose, never the code. A direct form's
|
|
75
|
+
purpose defaults to `"authentication"`; controllers supply the trusted route's
|
|
76
|
+
resource name, ignoring caller-supplied purpose parameters.
|
|
77
|
+
|
|
78
|
+
`Form::Verification.call(salt:, data:, code:, purpose:)` looks up and verifies the
|
|
79
|
+
challenge. It exposes `invalid_code?`, `has_expired?` and `has_exceeded_attempts?`.
|
|
80
|
+
Status/result/error methods follow the normal form lifecycle.
|
|
81
|
+
Verification caches its challenge lookup only while salt/data/purpose/code remain
|
|
82
|
+
unchanged. Editing those inputs, including mutating a string in place, triggers
|
|
83
|
+
a fresh lookup and binds the submitted code again. Failed database lookups are
|
|
84
|
+
not cached. Use a fresh form for each request.
|
|
85
|
+
|
|
86
|
+
The browser submits email for issuance and salt/data/code for verification under
|
|
87
|
+
`composable_pwdless`. Controller hooks `deliver_authentication`,
|
|
88
|
+
`verification_succeeded`, `verification_failed`, `verification_expired`, and
|
|
89
|
+
`verification_exceeded_attempts` can be overridden. `create` also accepts a block
|
|
90
|
+
receiving the verification form after successful delivery.
|
|
91
|
+
|
|
92
|
+
## Security and operation
|
|
93
|
+
|
|
94
|
+
- Verification locks the challenge row in a transaction. Exactly one valid
|
|
95
|
+
request consumes it; concurrent invalid requests cannot exceed the attempt
|
|
96
|
+
budget. Consumed challenges retain zero attempts and cannot be replayed.
|
|
97
|
+
- Codes come from SecureRandom. The database stores keyed SHA-256 digests, bound
|
|
98
|
+
to purpose/salt/data, rather than plaintext codes or email. Comparisons use
|
|
99
|
+
constant-time helpers. The key is derived from Rails' key generator with a
|
|
100
|
+
dedicated purpose. Changing the application key invalidates existing codes.
|
|
101
|
+
- POST/PUT/DELETE require CSRF protection. Browser forms include Rails tokens.
|
|
102
|
+
Code and salt are added to Rails filtered parameters; applications should also
|
|
103
|
+
filter email/data and sensitive custom fields as appropriate.
|
|
104
|
+
- Issuance is limited to 10 requests per minute per IP using Rails controller rate
|
|
105
|
+
limiting. Configure a shared, persistent Rails cache in a multi-worker deployment;
|
|
106
|
+
NullStore disables effective throttling. Add per-recipient cooldowns and controls
|
|
107
|
+
against distributed email abuse in the application's delivery policy.
|
|
108
|
+
- Configure HTTPS and secure session cookies in the application. Reset the session
|
|
109
|
+
before establishing authenticated identity. Avoid account enumeration, code
|
|
110
|
+
logging, mail-body logging and exposing codes in email subjects.
|
|
111
|
+
- The application owns cleanup of expired/consumed rows, user eligibility,
|
|
112
|
+
account creation, delivery failure handling and session expiry. A mail failure
|
|
113
|
+
may leave an undelivered challenge; it never authenticates the user.
|
|
114
|
+
|
|
115
|
+
Run the PostgreSQL concurrency and controller tests described in
|
|
116
|
+
[testing](../docs/testing.md). SQLite exercises form/model behavior but is not a
|
|
117
|
+
substitute for PostgreSQL row-lock verification.
|
|
118
|
+
|
|
119
|
+
## Declare browser routes
|
|
16
120
|
|
|
17
|
-
|
|
121
|
+
```ruby
|
|
122
|
+
# config/routes.rb
|
|
123
|
+
Rails.application.routes.draw do
|
|
124
|
+
root "home#index"
|
|
125
|
+
composable_pwdless_for :members, controller: "member_sessions"
|
|
126
|
+
end
|
|
127
|
+
```
|
|
18
128
|
|
|
19
|
-
|
|
129
|
+
The application supplies `HomeController#index` and the session controller shown
|
|
130
|
+
above. Link to sign-in and use a form-backed button for sign-out:
|
|
20
131
|
|
|
21
|
-
|
|
132
|
+
```erb
|
|
133
|
+
<%= link_to "Sign in", members_sign_in_path %>
|
|
134
|
+
<%= button_to "Sign out", members_sign_out_path, method: :delete %>
|
|
135
|
+
```
|
|
22
136
|
|
|
23
|
-
|
|
137
|
+
The engine renders its issuance and verification forms. For custom views, retain
|
|
138
|
+
the `composable_pwdless` parameter scope and use POST for issuance and PUT for
|
|
139
|
+
verification. Rails form helpers supply the CSRF token:
|
|
24
140
|
|
|
25
|
-
|
|
141
|
+
```erb
|
|
142
|
+
<%# Custom issuance form %>
|
|
143
|
+
<%= form_with scope: :composable_pwdless, url: members_sign_in_path, method: :post do |f| %>
|
|
144
|
+
<%= f.email_field :email, autocomplete: "email", required: true %>
|
|
145
|
+
<%= f.submit "Send code" %>
|
|
146
|
+
<% end %>
|
|
147
|
+
```
|
|
26
148
|
|
|
27
|
-
|
|
149
|
+
```erb
|
|
150
|
+
<%# Custom verification form; @form is the verification form from create %>
|
|
151
|
+
<%= form_with scope: :composable_pwdless, url: members_sign_in_path, method: :put do |f| %>
|
|
152
|
+
<%= f.hidden_field :salt, value: @form.salt %>
|
|
153
|
+
<%= f.hidden_field :data, value: @form.data %>
|
|
154
|
+
<%= f.text_field :code, autocomplete: "one-time-code", required: true %>
|
|
155
|
+
<%= f.submit "Verify" %>
|
|
156
|
+
<% end %>
|
|
157
|
+
```
|
|
28
158
|
|
|
29
|
-
|
|
159
|
+
Do not put the issued code into a hidden field or return it in an issuance API
|
|
160
|
+
response. The user must obtain it through the separate delivery channel.
|
|
30
161
|
|
|
31
|
-
|
|
162
|
+
## Use the forms without the browser controller
|
|
32
163
|
|
|
33
|
-
|
|
164
|
+
Direct calls require an initialized Rails application and the migrated secret
|
|
165
|
+
table. Your application owns delivery, throttling and session establishment:
|
|
34
166
|
|
|
35
|
-
|
|
167
|
+
```ruby
|
|
168
|
+
authentication = Composable::Pwdless::Form::Authentication.call(
|
|
169
|
+
email: "member@example.test", purpose: "members"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if authentication.success?
|
|
173
|
+
Composable::Pwdless::Mailer.with(authentication: authentication)
|
|
174
|
+
.notification_email.deliver_now
|
|
175
|
+
challenge = authentication.result
|
|
176
|
+
# Keep challenge.salt, challenge.data and challenge.purpose for verification.
|
|
177
|
+
else
|
|
178
|
+
messages = authentication.errors.full_messages
|
|
179
|
+
end
|
|
180
|
+
```
|
|
36
181
|
|
|
37
|
-
|
|
182
|
+
On a later request, use the code entered by the user. `submitted_code` below is
|
|
183
|
+
application-provided input, not the issued code copied from the authentication
|
|
184
|
+
object:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
verification = Composable::Pwdless::Form::Verification.call(
|
|
188
|
+
salt: challenge.salt,
|
|
189
|
+
data: challenge.data,
|
|
190
|
+
purpose: "members", # Choose this in trusted application code.
|
|
191
|
+
code: submitted_code
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if verification.success?
|
|
195
|
+
verified_email = verification.data
|
|
196
|
+
# Apply eligibility rules before establishing a session.
|
|
197
|
+
else
|
|
198
|
+
messages = verification.errors.full_messages
|
|
199
|
+
# Offer a fresh challenge after expiry or exhaustion.
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
A successful verification consumes the challenge. Submit it again and it fails,
|
|
204
|
+
even if the same correct code is supplied. `result` is not a user/session object;
|
|
205
|
+
use the verified data and your application's authentication policy.
|
|
206
|
+
|
|
207
|
+
## Customize delivery and clean up old challenges
|
|
208
|
+
|
|
209
|
+
A session controller can replace the default mail delivery while retaining the
|
|
210
|
+
engine's issuance and verification behavior:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
class MemberSessionsController < Composable::Pwdless::AuthController
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def deliver_authentication(authentication)
|
|
217
|
+
Composable::Pwdless::Mailer.with(authentication: authentication)
|
|
218
|
+
.notification_email.deliver_now
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Also implement verification_succeeded as shown above.
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
For a scheduled cleanup job, choose a retention period appropriate to your
|
|
226
|
+
application. This example removes only challenges expired for more than a day:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
class CleanupPasswordlessChallengesJob < ApplicationJob
|
|
230
|
+
def perform
|
|
231
|
+
Composable::Pwdless::Secret.where("expires_at < ?", 1.day.ago).delete_all
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
```
|
|
38
235
|
|
|
39
|
-
|
|
236
|
+
## Upgrade from unkeyed SHA-256
|
|
40
237
|
|
|
41
|
-
|
|
238
|
+
The HMAC implementation uses the same database columns; no digest-conversion
|
|
239
|
+
migration is required. Old SHA-256 challenges cannot be verified by this version
|
|
240
|
+
and users must request a new code. Existing digests cannot be converted without
|
|
241
|
+
the original values. Update application instances and workers together to avoid
|
|
242
|
+
issuing and verifying challenges with different algorithms.
|
|
42
243
|
|
|
43
|
-
|
|
244
|
+
The HMAC key is derived automatically from the Rails application key generator.
|
|
245
|
+
Keep key configuration consistent across instances of the same environment;
|
|
246
|
+
rotating it invalidates outstanding challenges. The algorithm change does not
|
|
247
|
+
invalidate already established application sessions.
|
|
@@ -6,7 +6,7 @@ module Composable
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def create
|
|
9
|
-
@form = Form::Authentication.call(authentication_params)
|
|
9
|
+
@form = Form::Authentication.call(authentication_params.merge(purpose: resource_name))
|
|
10
10
|
|
|
11
11
|
if @form.success?
|
|
12
12
|
deliver_authentication(@form)
|
|
@@ -18,7 +18,7 @@ module Composable
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def update
|
|
21
|
-
@form = Form::Verification.call(verification_params)
|
|
21
|
+
@form = Form::Verification.call(verification_params.merge(purpose: resource_name))
|
|
22
22
|
|
|
23
23
|
if @form.success?
|
|
24
24
|
verification_succeeded @form.data
|
|
@@ -33,11 +33,16 @@ module Composable
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def destroy
|
|
37
|
+
reset_session
|
|
38
|
+
redirect_to main_app.root_url
|
|
39
|
+
end
|
|
40
|
+
|
|
36
41
|
private
|
|
37
42
|
|
|
38
43
|
# Override with your own logic to deliver a code to the user.
|
|
39
44
|
def deliver_authentication(authentication)
|
|
40
|
-
Pwdless::Mailer.with(authentication: authentication).notification_email.
|
|
45
|
+
Pwdless::Mailer.with(authentication: authentication).notification_email.deliver_now
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
# Override with your own logic to do something with the valid data. For
|
|
@@ -45,33 +50,35 @@ module Composable
|
|
|
45
50
|
#
|
|
46
51
|
# ```
|
|
47
52
|
# def verification_succeeded(email)
|
|
48
|
-
#
|
|
53
|
+
# user = User.active.find_by!(email: email) # Application eligibility scope.
|
|
54
|
+
# reset_session
|
|
55
|
+
# session[:user_id] = user.id
|
|
49
56
|
# redirect_to dashboard_url
|
|
50
57
|
# end
|
|
51
58
|
# ```
|
|
52
|
-
def verification_succeeded(
|
|
53
|
-
redirect_to root_url
|
|
59
|
+
def verification_succeeded(_email)
|
|
60
|
+
redirect_to main_app.root_url
|
|
54
61
|
end
|
|
55
62
|
|
|
56
63
|
# Override with your own logic to do something when verification fails.
|
|
57
|
-
def verification_failed(
|
|
58
|
-
redirect_to root_url
|
|
64
|
+
def verification_failed(_verification)
|
|
65
|
+
redirect_to main_app.root_url
|
|
59
66
|
end
|
|
60
67
|
|
|
61
68
|
# Override with logic for when verification attempts are exceeded. For
|
|
62
69
|
# example, you might want to tweak the flash message that's displayed
|
|
63
70
|
# or redirect them to a page other than the one where they'd re-verify.
|
|
64
|
-
def verification_exceeded_attempts(
|
|
71
|
+
def verification_exceeded_attempts(_verification)
|
|
65
72
|
flash[:composable_pwdless] = Pwdless.t(:attempts_exceeded, scope: "errors.messages")
|
|
66
|
-
redirect_to
|
|
73
|
+
redirect_to request.path
|
|
67
74
|
end
|
|
68
75
|
|
|
69
76
|
# Override with logic for when verification has expired. For
|
|
70
77
|
# example, you might want to tweak the flash message that's displayed
|
|
71
78
|
# or redirect them to a page other than the one where they'd re-verify.
|
|
72
|
-
def verification_expired(
|
|
79
|
+
def verification_expired(_verification)
|
|
73
80
|
flash[:composable_pwdless] = Pwdless.t(:expired, scope: "errors.messages")
|
|
74
|
-
redirect_to
|
|
81
|
+
redirect_to request.path
|
|
75
82
|
end
|
|
76
83
|
|
|
77
84
|
def verification_params
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
module Composable
|
|
2
2
|
module Pwdless
|
|
3
3
|
class BaseController < Pwdless.parent_controller.constantize
|
|
4
|
+
protect_from_forgery with: :exception
|
|
5
|
+
rate_limit to: 10, within: 1.minute, only: :create
|
|
4
6
|
# Find the resource name from the request
|
|
5
7
|
def resource_name
|
|
6
|
-
@resource_name ||= request.env
|
|
8
|
+
@resource_name ||= request.env.fetch("composable_pwdless_resource", "authentication")
|
|
7
9
|
end
|
|
8
10
|
end
|
|
9
11
|
end
|
|
@@ -24,15 +24,11 @@ module Composable
|
|
|
24
24
|
mailer_sender(:reply_to)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def mailer_from
|
|
28
|
-
mailer_sender(:from)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
27
|
def mailer_sender(sender = :from)
|
|
32
28
|
default_sender = default_params[sender]
|
|
33
29
|
|
|
34
30
|
if default_sender.present?
|
|
35
|
-
default_sender.respond_to?(:to_proc) ?
|
|
31
|
+
default_sender.respond_to?(:to_proc) ? instance_exec(&default_sender) : default_sender
|
|
36
32
|
elsif Pwdless.mailer_sender.is_a?(Proc)
|
|
37
33
|
Pwdless.mailer_sender.call
|
|
38
34
|
else
|
|
@@ -1,88 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Composable
|
|
2
4
|
module Pwdless
|
|
3
|
-
class Secret <
|
|
5
|
+
class Secret < ActiveRecord::Base
|
|
4
6
|
self.table_name = "composable_pwdless_secrets"
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
attr_accessor :salt, :data
|
|
9
|
+
attr_writer :purpose
|
|
10
|
+
attr_reader :code
|
|
8
11
|
|
|
12
|
+
after_initialize :assign_defaults, unless: :persisted?
|
|
9
13
|
before_validation :assign_digests, on: :create
|
|
10
|
-
validates :data_digest, presence: true
|
|
11
|
-
validates :
|
|
12
|
-
|
|
13
|
-
# This is used to derive the `data_digest`, which finds the secret.
|
|
14
|
-
attr_accessor :salt
|
|
15
|
-
validates :salt, presence: true
|
|
16
|
-
|
|
17
|
-
validates :expires_at, presence: true
|
|
14
|
+
validates :salt, :data, :data_digest, :code_digest, :expires_at, presence: true
|
|
15
|
+
validates :remaining_attempts, numericality: { only_integer: true, greater_than: 0 }
|
|
16
|
+
validate :code_format, on: :create
|
|
18
17
|
validate :expiration
|
|
18
|
+
validate :data_tampering, on: :update
|
|
19
|
+
validate :code_authenticity, on: :update
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
only_integer: true,
|
|
23
|
-
greater_than: 0
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
attr_reader :code
|
|
27
|
-
validate :code_authenticity
|
|
28
|
-
# Ensure the code is a non-empty string. The nil will
|
|
29
|
-
# trigger validations and blow up the downstream Encryptor.
|
|
30
|
-
def code=(code)
|
|
31
|
-
@code = code.to_s if code.present?
|
|
21
|
+
def purpose
|
|
22
|
+
@purpose ||= "authentication"
|
|
32
23
|
end
|
|
33
24
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
25
|
+
def code=(value)
|
|
26
|
+
@code = value.to_s
|
|
27
|
+
end
|
|
37
28
|
|
|
38
29
|
def has_expired?
|
|
39
|
-
Time.current
|
|
30
|
+
expires_at.nil? || Time.current >= expires_at
|
|
40
31
|
end
|
|
41
32
|
|
|
42
33
|
def has_exceeded_attempts?
|
|
43
|
-
remaining_attempts
|
|
34
|
+
remaining_attempts <= 0
|
|
44
35
|
end
|
|
45
36
|
|
|
46
37
|
def has_tampered_data?
|
|
47
|
-
|
|
38
|
+
!ActiveSupport::SecurityUtils.secure_compare(data_digest, digest_data)
|
|
48
39
|
end
|
|
49
40
|
|
|
50
41
|
def has_authentic_code?
|
|
51
|
-
|
|
42
|
+
ActiveSupport::SecurityUtils.secure_compare(code_digest, digest_code)
|
|
52
43
|
end
|
|
53
44
|
|
|
54
45
|
def decrement_remaining_attempts!
|
|
55
46
|
decrement!(:remaining_attempts)
|
|
56
47
|
end
|
|
57
48
|
|
|
58
|
-
def self.find_by_digest(salt:, data:)
|
|
59
|
-
return if salt.
|
|
49
|
+
def self.find_by_digest(salt:, data:, purpose: "authentication")
|
|
50
|
+
return if salt.blank? || data.blank?
|
|
60
51
|
|
|
61
|
-
find_by(data_digest: digest_data(salt: salt, data: data)).tap do |secret|
|
|
52
|
+
find_by(data_digest: digest_data(salt: salt, data: data, purpose: purpose)).tap do |secret|
|
|
62
53
|
if secret
|
|
63
54
|
secret.salt = salt
|
|
64
55
|
secret.data = data
|
|
56
|
+
secret.purpose = purpose
|
|
65
57
|
end
|
|
66
58
|
end
|
|
67
59
|
end
|
|
68
60
|
|
|
69
|
-
def self.digest_data(salt:, data:)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
Digest::SHA256.hexdigest(salt + data)
|
|
61
|
+
def self.digest_data(salt:, data:, purpose: "authentication")
|
|
62
|
+
OpenSSL::HMAC.hexdigest("SHA256", Pwdless.digest_key, [purpose, salt, data].to_json)
|
|
73
63
|
end
|
|
74
64
|
|
|
75
65
|
def self.digest_code(data_digest:, code:)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
Digest::SHA256.hexdigest(data_digest + code)
|
|
66
|
+
OpenSSL::HMAC.hexdigest("SHA256", Pwdless.digest_key, [data_digest, code].to_json)
|
|
79
67
|
end
|
|
80
68
|
|
|
81
69
|
private
|
|
82
70
|
|
|
83
71
|
def assign_defaults
|
|
84
|
-
|
|
85
|
-
self.
|
|
72
|
+
Pwdless.validate_configuration!
|
|
73
|
+
self.salt = SecureRandom.urlsafe_base64(32)
|
|
74
|
+
self.code ||= Array.new(Pwdless.code_length) { Pwdless.code_charset[SecureRandom.random_number(Pwdless.code_charset.length)] }.join
|
|
86
75
|
self.expires_at ||= Pwdless.expires_in.from_now
|
|
87
76
|
self.remaining_attempts ||= Pwdless.maximum_attempts
|
|
88
77
|
end
|
|
@@ -93,13 +82,19 @@ module Composable
|
|
|
93
82
|
end
|
|
94
83
|
|
|
95
84
|
def digest_data
|
|
96
|
-
self.class.digest_data(salt: salt, data: data)
|
|
85
|
+
self.class.digest_data(salt: salt, data: data, purpose: purpose)
|
|
97
86
|
end
|
|
98
87
|
|
|
99
88
|
def digest_code
|
|
100
89
|
self.class.digest_code(data_digest: data_digest, code: code)
|
|
101
90
|
end
|
|
102
91
|
|
|
92
|
+
def code_format
|
|
93
|
+
unless code.length == Pwdless.code_length && code.each_char.all? { |character| Pwdless.code_charset.include?(character) }
|
|
94
|
+
errors.add(:code, :invalid)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
103
98
|
def expiration
|
|
104
99
|
errors.add(:expires_at, "has been exceeded") if has_expired?
|
|
105
100
|
end
|
|
@@ -4,6 +4,7 @@ module Composable
|
|
|
4
4
|
module Pwdless
|
|
5
5
|
module Form
|
|
6
6
|
class Authentication < Composable::Form::Command
|
|
7
|
+
attribute :purpose, type: :string, default: "authentication"
|
|
7
8
|
attribute :email
|
|
8
9
|
validates :email, presence: true
|
|
9
10
|
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, if: :email?
|
|
@@ -11,15 +12,15 @@ module Composable
|
|
|
11
12
|
def save
|
|
12
13
|
# We don't want the code in the verification, otherwise the user will
|
|
13
14
|
# set it on the subsequent request, which would undermine the whole thing.
|
|
14
|
-
Form::Verification.new(salt: salt, data: email)
|
|
15
|
+
Form::Verification.new(salt: salt, data: email, purpose: purpose)
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
private
|
|
18
|
-
|
|
19
18
|
delegate :code, :salt, to: :secret
|
|
20
19
|
|
|
20
|
+
private
|
|
21
|
+
|
|
21
22
|
def secret
|
|
22
|
-
@secret ||= Pwdless::Secret.create!(data: email)
|
|
23
|
+
@secret ||= Pwdless::Secret.create!(data: email, purpose: purpose)
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
26
|
end
|
|
@@ -1,15 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Composable
|
|
2
4
|
module Pwdless
|
|
3
5
|
module Form
|
|
4
6
|
class Verification < Composable::Form::Command
|
|
5
7
|
attribute :salt, :data, :code
|
|
6
|
-
|
|
7
|
-
validates :
|
|
8
|
+
attribute :purpose, type: :string, default: "authentication"
|
|
9
|
+
validates :salt, :data, :code, presence: true
|
|
10
|
+
validates :secret, presence: true, if: -> { salt? && data? && code? }
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
delegate :has_expired?, :has_exceeded_attempts?, to: :secret, allow_nil: true
|
|
10
13
|
|
|
11
14
|
def save
|
|
12
|
-
|
|
15
|
+
secret.with_lock do
|
|
16
|
+
if secret.has_expired? || secret.has_exceeded_attempts? || secret.has_tampered_data?
|
|
17
|
+
errors.add(:code, :invalid)
|
|
18
|
+
elsif secret.has_authentic_code?
|
|
19
|
+
secret.update_columns(remaining_attempts: 0)
|
|
20
|
+
else
|
|
21
|
+
secret.decrement_remaining_attempts!
|
|
22
|
+
errors.add(:code, :invalid)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
13
25
|
end
|
|
14
26
|
|
|
15
27
|
def invalid_code?
|
|
@@ -18,14 +30,15 @@ module Composable
|
|
|
18
30
|
|
|
19
31
|
private
|
|
20
32
|
|
|
21
|
-
delegate :has_expired?, :has_exceeded_attempts?, to: :secret, allow_nil: true
|
|
22
|
-
|
|
23
33
|
def secret
|
|
24
|
-
|
|
34
|
+
lookup = [salt, data, purpose, code]
|
|
35
|
+
return @secret if defined?(@secret_lookup) && @secret_lookup == lookup
|
|
25
36
|
|
|
26
|
-
@secret = Pwdless::Secret.find_by_digest(salt: salt, data: data).tap do |secret|
|
|
37
|
+
@secret = Pwdless::Secret.find_by_digest(salt: salt, data: data, purpose: purpose).tap do |secret|
|
|
27
38
|
secret.code = code if secret
|
|
28
39
|
end
|
|
40
|
+
@secret_lookup = lookup.deep_dup
|
|
41
|
+
@secret
|
|
29
42
|
end
|
|
30
43
|
end
|
|
31
44
|
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<h1>Verify login code</h1>
|
|
2
2
|
|
|
3
|
-
<p>Look for a
|
|
3
|
+
<p>Look for a <%= Composable::Pwdless.code_length %> character code in the inbox or spam folder.</p>
|
|
4
4
|
|
|
5
|
-
<%= form_for @form, as: :composable_pwdless, url:
|
|
5
|
+
<%= form_for @form, as: :composable_pwdless, url: request.path, data: { turbo: false }, method: :put do |f| %>
|
|
6
6
|
<%= f.hidden_field :salt %>
|
|
7
7
|
<%= f.hidden_field :data %>
|
|
8
8
|
<%= f.label :code %>
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
<p>
|
|
18
18
|
Launch
|
|
19
|
-
<%= link_to "Gmail", "https://gmail.com/", target: "_blank" %>
|
|
19
|
+
<%= link_to "Gmail", "https://gmail.com/", target: "_blank", rel: "noopener noreferrer" %>
|
|
20
20
|
|
|
|
21
|
-
<%= link_to "Outlook", "https://outlook.live.com/", target: "_blank" %>
|
|
21
|
+
<%= link_to "Outlook", "https://outlook.live.com/", target: "_blank", rel: "noopener noreferrer" %>
|
|
22
22
|
|
|
|
23
|
-
<%= link_to "Yahoo Mail", "https://mail.yahoo.com/", target: "_blank" %>
|
|
23
|
+
<%= link_to "Yahoo Mail", "https://mail.yahoo.com/", target: "_blank", rel: "noopener noreferrer" %>
|
|
24
24
|
</p>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<p>We'll email you a login code so we can securely get you to your account</p>
|
|
8
8
|
<% end %>
|
|
9
9
|
|
|
10
|
-
<%= form_for @form, as: :composable_pwdless, url:
|
|
10
|
+
<%= form_for @form, as: :composable_pwdless, url: request.path, data: { turbo: false } do |f| %>
|
|
11
11
|
<%= f.label :email %>
|
|
12
12
|
<%= f.email_field :email, autofocus: true %>
|
|
13
13
|
<%= f.submit "Continue" %>
|
data/config/locales/en.yml
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
class CreateComposablePwdlessSecrets < ActiveRecord::Migration[7.
|
|
1
|
+
class CreateComposablePwdlessSecrets < ActiveRecord::Migration[7.2]
|
|
2
2
|
def change
|
|
3
3
|
create_table :composable_pwdless_secrets do |t|
|
|
4
4
|
t.string :data_digest, null: false, index: { unique: true }
|
|
5
5
|
t.string :code_digest, null: false
|
|
6
6
|
|
|
7
7
|
t.datetime :expires_at, null: false
|
|
8
|
-
t.integer :remaining_attempts, null: false
|
|
8
|
+
t.integer :remaining_attempts, null: false
|
|
9
9
|
|
|
10
10
|
t.timestamps
|
|
11
11
|
end
|
|
@@ -3,6 +3,10 @@ module Composable
|
|
|
3
3
|
class Engine < ::Rails::Engine
|
|
4
4
|
isolate_namespace Composable::Pwdless
|
|
5
5
|
|
|
6
|
+
initializer "composable_pwdless.filter_parameters" do |app|
|
|
7
|
+
app.config.filter_parameters += [:code, :salt]
|
|
8
|
+
end
|
|
9
|
+
|
|
6
10
|
config.to_prepare do
|
|
7
11
|
require "composable/pwdless/router_helpers"
|
|
8
12
|
|
|
@@ -3,10 +3,11 @@ module Composable
|
|
|
3
3
|
module RouterHelpers
|
|
4
4
|
def composable_pwdless_for(resource, controller: nil, as: nil)
|
|
5
5
|
as ||= resource.to_s
|
|
6
|
-
controller ||= "
|
|
6
|
+
controller ||= "composable/pwdless/auth"
|
|
7
|
+
controller = controller.delete_prefix("/")
|
|
7
8
|
|
|
8
9
|
constraints(->(req) { (req.env["composable_pwdless_resource"] = resource.to_s).present? }) do
|
|
9
|
-
scope resource.to_s, as: as do
|
|
10
|
+
scope resource.to_s, as: as, module: nil do
|
|
10
11
|
get "/sign_in", to: "#{controller}#new", as: :sign_in
|
|
11
12
|
post "/sign_in", to: "#{controller}#create"
|
|
12
13
|
put "/sign_in", to: "#{controller}#update"
|
data/lib/composable/pwdless.rb
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "rails"
|
|
4
|
+
require "rails/engine"
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "action_mailer"
|
|
7
|
+
require "active_support/core_ext/numeric/time"
|
|
8
|
+
require "openssl"
|
|
9
|
+
require "securerandom"
|
|
10
|
+
require "composable/form"
|
|
4
11
|
require_relative "pwdless/version"
|
|
5
12
|
require "composable/pwdless/engine"
|
|
6
13
|
|
|
7
14
|
module Composable
|
|
8
15
|
module Pwdless
|
|
9
16
|
class Error < StandardError; end
|
|
10
|
-
|
|
17
|
+
def self.validate_configuration!
|
|
18
|
+
unless maximum_attempts.is_a?(Integer) && maximum_attempts.positive? &&
|
|
19
|
+
code_length.is_a?(Integer) && code_length.between?(1, 128) &&
|
|
20
|
+
expires_in.is_a?(ActiveSupport::Duration) && expires_in.positive? &&
|
|
21
|
+
code_charset.is_a?(Array) && code_charset.uniq.length >= 2 && code_charset.uniq.length == code_charset.length &&
|
|
22
|
+
code_charset.uniq.length**code_length >= 1_000_000 &&
|
|
23
|
+
code_charset.all? { |character| character.is_a?(String) && character.length == 1 }
|
|
24
|
+
raise ArgumentError, "invalid passwordless configuration"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Derive a separate key; never store this key alongside challenges.
|
|
29
|
+
def self.digest_key
|
|
30
|
+
Rails.application.key_generator.generate_key("composable-pwdless/v1", 32)
|
|
31
|
+
end
|
|
11
32
|
|
|
12
33
|
# The parent controller all Composable::Pwdless controllers inherits from.
|
|
13
34
|
# Defaults to ApplicationController. This should be set early
|
metadata
CHANGED
|
@@ -1,28 +1,42 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: composable-pwdless
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.14
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jairo Vazquez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: composable-
|
|
13
|
+
name: composable-form
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - '='
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.0.
|
|
18
|
+
version: 0.0.14
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - '='
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.0.
|
|
25
|
+
version: 0.0.14
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rails
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.2'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.2'
|
|
26
40
|
description: Authentication composable objects to perform passwordless authentication
|
|
27
41
|
email:
|
|
28
42
|
- jairovm20@gmail.com
|
|
@@ -42,9 +56,9 @@ files:
|
|
|
42
56
|
- app/views/composable/pwdless/auth/edit.html.erb
|
|
43
57
|
- app/views/composable/pwdless/auth/new.html.erb
|
|
44
58
|
- app/views/composable/pwdless/mailer/notification_email.html.erb
|
|
45
|
-
- app/views/composable/pwdless/mailer/notification_email.
|
|
59
|
+
- app/views/composable/pwdless/mailer/notification_email.text.erb
|
|
46
60
|
- config/locales/en.yml
|
|
47
|
-
- db/migrate/
|
|
61
|
+
- db/migrate/20221211203235_create_composable_pwdless_secrets.rb
|
|
48
62
|
- lib/composable/pwdless.rb
|
|
49
63
|
- lib/composable/pwdless/engine.rb
|
|
50
64
|
- lib/composable/pwdless/gem_version.rb
|
|
@@ -64,7 +78,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
64
78
|
requirements:
|
|
65
79
|
- - ">="
|
|
66
80
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: 2.
|
|
81
|
+
version: 3.2.0
|
|
68
82
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
83
|
requirements:
|
|
70
84
|
- - ">="
|
|
File without changes
|