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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 681fd4262974ff32148dbc44f68f3b868b83afce96085128178558ec896f9274
4
- data.tar.gz: e0a03f7bd16cb8afcb3d9318892c33f20d30ed909467615e3c646ef3e4cb0f91
3
+ metadata.gz: d8be6e95a72868e29205609ce41c057868b0a3d406c75b090fd28ee8fee9a217
4
+ data.tar.gz: f6f38b1248c802560d711555b4a2d94dea7aac2178d43fb2f25f9466b295b081
5
5
  SHA512:
6
- metadata.gz: 05bd6d0d086ae17efed6f09668429b17e7815e01aace561b4bccf42bd9a363095d26f612e0f2b398622cca72997cc0c5a4de5691fb45d6fcb27713c0c58dd4d5
7
- data.tar.gz: 05caa185a6e0cff9295204b1b1af361c141c6de55f23c7283762e808b73aac450b7de3405d37e93f18433f60a4de038d6fd51c000d97b55b4f353ee53c3f1484
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
- # Composable::Pwdless
1
+ # composable-pwdless
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/composable/pwdless`. To experiment with that code, run `bin/console` for an interactive prompt.
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
- TODO: Delete this and the text above, and describe your gem
7
+ ## Install and configure
6
8
 
7
- ## Installation
9
+ Add `gem "composable-pwdless"` to the application's Gemfile, then run:
8
10
 
9
- Add this line to your application's Gemfile:
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
- gem 'composable-pwdless'
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
- And then execute:
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
- $ bundle install
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
- Or install it yourself as:
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
- $ gem install composable-pwdless
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
- ## Usage
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
- TODO: Write usage instructions here
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
- ## Development
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
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
162
+ ## Use the forms without the browser controller
32
163
 
33
- ## Contributing
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
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/composable-pwdless. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/composable-pwdless/blob/master/CODE_OF_CONDUCT.md).
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
- ## License
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
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
236
+ ## Upgrade from unkeyed SHA-256
40
237
 
41
- ## Code of Conduct
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
- Everyone interacting in the Composable::Pwdless project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/composable-pwdless/blob/master/CODE_OF_CONDUCT.md).
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.deliver
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
- # session[:user_id] = User.find_or_create_by(email: email)
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(email)
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(verification)
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(verification)
71
+ def verification_exceeded_attempts(_verification)
65
72
  flash[:composable_pwdless] = Pwdless.t(:attempts_exceeded, scope: "errors.messages")
66
- redirect_to url_for(action: :new)
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(verification)
79
+ def verification_expired(_verification)
73
80
  flash[:composable_pwdless] = Pwdless.t(:expired, scope: "errors.messages")
74
- redirect_to url_for(action: :new)
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["composable_pwdless_resource"]
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) ? instance_eval(&default_sender) : default_sender
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 < ApplicationRecord
5
+ class Secret < ActiveRecord::Base
4
6
  self.table_name = "composable_pwdless_secrets"
5
7
 
6
- # Initialize new models with all the stuff needed to encrypt and store the data.
7
- after_initialize :assign_defaults, unless: :persisted?
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 :code_digest, presence: true
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
- validates :remaining_attempts, presence: true,
21
- numericality: {
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
- attr_accessor :data
35
- validates :data, presence: true
36
- validate :data_tampering, on: :update
25
+ def code=(value)
26
+ @code = value.to_s
27
+ end
37
28
 
38
29
  def has_expired?
39
- Time.current > expires_at
30
+ expires_at.nil? || Time.current >= expires_at
40
31
  end
41
32
 
42
33
  def has_exceeded_attempts?
43
- remaining_attempts.zero?
34
+ remaining_attempts <= 0
44
35
  end
45
36
 
46
37
  def has_tampered_data?
47
- self.data_digest != digest_data if persisted?
38
+ !ActiveSupport::SecurityUtils.secure_compare(data_digest, digest_data)
48
39
  end
49
40
 
50
41
  def has_authentic_code?
51
- self.code_digest == digest_code
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.nil? || data.nil?
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
- return if salt.nil? || data.nil?
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
- return if code.nil? || data_digest.nil?
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
- self.salt = SecureRandom.urlsafe_base64(ActiveSupport::MessageEncryptor.key_len)
85
- self.code ||= SecureRandom.send(:choose, Pwdless.code_charset, Pwdless.code_length)
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
- validates :data, :code, presence: true
7
- validates :secret, presence: true, if: -> { data? && code? }
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
- after_save { secret.decrement_remaining_attempts! }
12
+ delegate :has_expired?, :has_exceeded_attempts?, to: :secret, allow_nil: true
10
13
 
11
14
  def save
12
- errors.add(:code, :invalid) unless secret.valid?
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
- return @secret if defined?(@secret)
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 6 digit code in the inbox or spam folder.</p>
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: url_for(action: :update), data: { turbo: false }, method: :put do |f| %>
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: url_for(action: :create), data: { turbo: false } do |f| %>
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" %>
@@ -2,7 +2,7 @@ en:
2
2
  composable_pwdless:
3
3
  mailer:
4
4
  notification_email:
5
- default_subject: "Verification code: %{code}"
5
+ default_subject: "Your verification code"
6
6
  errors:
7
7
  messages:
8
8
  default_expired: "The code has expired"
@@ -1,11 +1,11 @@
1
- class CreateComposablePwdlessSecrets < ActiveRecord::Migration[7.0]
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, unsigned: true
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
 
@@ -10,7 +10,7 @@ module Composable
10
10
  module VERSION
11
11
  MAJOR = 0
12
12
  MINOR = 0
13
- TINY = 13
13
+ TINY = 14
14
14
  PRE = nil
15
15
 
16
16
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
@@ -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 ||= "/composable/pwdless/auth"
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"
@@ -1,13 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "composable/core"
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
- # Your code goes here...
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.13
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: 2025-08-09 00:00:00.000000000 Z
10
+ date: 2026-10-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: composable-core
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.13
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.13
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.html.txt
59
+ - app/views/composable/pwdless/mailer/notification_email.text.erb
46
60
  - config/locales/en.yml
47
- - db/migrate/db/migrate/20221211203235_create_composable_pwdless_secrets.rb
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.7.0
81
+ version: 3.2.0
68
82
  required_rubygems_version: !ruby/object:Gem::Requirement
69
83
  requirements:
70
84
  - - ">="