rails_authentication 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 295b9ad4f9523b3ebf770bc6a57f0c85d22d16ee648ed673f7cc82fbf36f671b
4
- data.tar.gz: 1a2506b6aa09b8fffb852018be8fd5d6a483d0841f206b7c1dda57c03e7ab7e7
3
+ metadata.gz: c71c1a30969abed6c581c67e3447c1968176e6e25c218894e64d5317472b873e
4
+ data.tar.gz: 1f65cac490fae4c9f575d907bf70d4d059afdb1bcb5e8a56474b741b4eac4f3e
5
5
  SHA512:
6
- metadata.gz: 77f7e36463e7e20a7cbed9ebe00148c4dc5ec8c72d3390a4af62edeae30ced60c5beb01b50f4af047bf47e4a91d18a2b528166f92abb521058be4a4d91d2df7f
7
- data.tar.gz: 86401354eaa5584a234a7dc46ed005e7c9e7daebcc3811008938b5472aa1b4055bbc831acac13ded99ff7f55f8a16fe69591e59fffb8c8b6b42adfab551e9dba
6
+ metadata.gz: 5469da8db1ff529c5dd079b37a3499b1bc08b657560bdcd037607c407d6be292a306bcd146bcc99f30243435824a6fa9a58cb965a80edc0d7fb30435dcdc1cc3
7
+ data.tar.gz: '0693170a1f4bdebe7ec41bfe6088ddbbc436225aa3a09b8d3216af9cf800bd36d461a9618fd022e0b45b1662c82382b13a0bf4cc10e715a9e7247fcd1d77fb18'
data/README.md CHANGED
@@ -17,6 +17,7 @@ gem extends that same command to also install:
17
17
  | **Validatable** | Email format/uniqueness, password length, and password complexity validations |
18
18
  | **Lockable** | Locks the account after 5 failed attempts; unlock via email or automatically after 1 hour |
19
19
  | **Invitable** | Invite users by email; blocks sign-in until they accept by choosing their own password |
20
+ | **MagicLink** (opt-in) | Passwordless magic link sign-in via email — DB-backed, single-use, expiring tokens |
20
21
 
21
22
  Everything is **generated into your app** as plain, readable code — controllers, views, mailers,
22
23
  migrations, and one model concern per feature. There is no runtime dependency on this gem: after
@@ -27,7 +28,7 @@ This gem is meant to be installed temporarily. Install it long enough to run the
27
28
  ## Installation
28
29
 
29
30
  Requires Rails >= 8.0 and, for the email-driven features (Confirmable, Recoverable, Lockable,
30
- Invitable), Action Mailer.
31
+ Invitable, MagicLink), Action Mailer.
31
32
 
32
33
  ```ruby
33
34
  # Gemfile
@@ -55,6 +56,18 @@ Available flags: `--skip-confirmable`, `--skip-recoverable`, `--skip-registerabl
55
56
  `--skip-lockable`, `--skip-invitable`, and `--reconfirmable` (Confirmable: postpone email address
56
57
  changes until the new address is confirmed, via an `unconfirmed_email` column).
57
58
 
59
+ MagicLink is the one **opt-in** feature — it changes the sign-in UX, so you have to ask for it:
60
+
61
+ ```sh
62
+ bin/rails generate authentication --magic-link
63
+ ```
64
+
65
+ It adds a "Sign in with magic link" link to the sign-in page, leading to an email-only form. The
66
+ emailed link signs the user in directly; tokens are DB-backed, single-use, and expire after
67
+ 20 minutes (`MagicLinkConcern::MAGIC_LINK_EXPIRES_IN`). The magic link flow honors the other
68
+ enabled features: locked, unconfirmed, or invitation-pending accounts still can't sign in, and
69
+ Trackable records the attempt.
70
+
58
71
  Each feature adds a single `include <Feature>Concern` line to `app/models/user.rb`; all of its
59
72
  model behavior lives in `app/models/concerns/<feature>_concern.rb`. Tunables are plain constants in
60
73
  the generated concerns — e.g. `TimeoutableConcern::TIMEOUT_IN`, `LockableConcern::MAXIMUM_ATTEMPTS`,
@@ -69,6 +82,7 @@ resource :registration, only: %i[ new create edit update destroy ]
69
82
  resources :confirmations, only: %i[ new create show ], param: :token
70
83
  resources :unlocks, only: %i[ new create show ], param: :token
71
84
  resources :invitations, only: %i[ new create edit update ], param: :token
85
+ resources :magic_links, only: %i[ new create show ], param: :token # with --magic-link
72
86
  resource :session # from the base generator
73
87
  resources :passwords, param: :token # from the base generator
74
88
  ```
@@ -87,8 +101,8 @@ resources :passwords, param: :token # from the base generator
87
101
 
88
102
  ## Future Plans
89
103
 
90
- - OTP (Google Authenticator)
91
- - Passwordless (code emailed)
104
+ - OTT (Email code)
105
+ - Passkey
92
106
 
93
107
  ## Development
94
108
 
@@ -11,6 +11,7 @@ require_relative "features/timeoutable"
11
11
  require_relative "features/validatable"
12
12
  require_relative "features/lockable"
13
13
  require_relative "features/invitable"
14
+ require_relative "features/magic_link"
14
15
 
15
16
  module RailsAuthentication
16
17
  module Generators
@@ -42,6 +43,7 @@ module RailsAuthentication
42
43
  include Features::Validatable
43
44
  include Features::Lockable
44
45
  include Features::Invitable
46
+ include Features::MagicLink
45
47
 
46
48
  source_root File.expand_path("templates", __dir__)
47
49
 
@@ -53,6 +55,11 @@ module RailsAuthentication
53
55
  class_option :reconfirmable, type: :boolean, default: false,
54
56
  desc: "Confirmable: postpone email address changes until reconfirmed (adds unconfirmed_email column)"
55
57
 
58
+ # Unlike the FEATURES above (default-on, opt-out via --skip-<feature>),
59
+ # magic link is opt-in: it changes the sign-in UX, so it must be requested.
60
+ class_option :magic_link, type: :boolean, default: false,
61
+ desc: "Add passwordless magic link sign-in (opt-in)"
62
+
56
63
  def install_base_authentication
57
64
  say "Running Rails' built-in authentication generator", :green
58
65
  Rails::Generators.invoke("rails:authentication", [], behavior: behavior, destination_root: destination_root)
@@ -94,6 +101,10 @@ module RailsAuthentication
94
101
  generate_invitable if invitable?
95
102
  end
96
103
 
104
+ def install_magic_link
105
+ generate_magic_link if magic_link?
106
+ end
107
+
97
108
  # Runs after every feature install so the blank line separates the concern
98
109
  # includes (if any) from the rest of the class body, no matter which features
99
110
  # are enabled.
@@ -120,6 +131,10 @@ module RailsAuthentication
120
131
  confirmable? && options[:reconfirmable]
121
132
  end
122
133
 
134
+ def magic_link?
135
+ options[:magic_link]
136
+ end
137
+
123
138
  def include_concern_in_user(concern)
124
139
  inject_into_class "app/models/user.rb", "User", " include #{concern}\n"
125
140
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAuthentication
4
+ module Generators
5
+ module Features
6
+ # Opt-in (--magic-link): magic link sign-in alongside password sign-in.
7
+ # Tokens are DB-backed and single-use, consistent with this gem's
8
+ # Recoverable rework.
9
+ module MagicLink
10
+ private
11
+ def generate_magic_link
12
+ template "app/models/concerns/magic_link_concern.rb"
13
+ include_concern_in_user "MagicLinkConcern"
14
+ migration_template "db/migrate/add_magic_link_to_users.rb", "db/migrate/add_magic_link_to_users.rb"
15
+ template "app/controllers/magic_links_controller.rb"
16
+ template "app/views/magic_links/new.html.erb"
17
+ route "resources :magic_links, only: %i[ new create show ], param: :token"
18
+
19
+ if defined?(ActionMailer::Railtie)
20
+ template "app/mailers/magic_links_mailer.rb"
21
+ template "app/views/magic_links_mailer/magic_link.html.erb"
22
+ template "app/views/magic_links_mailer/magic_link.text.erb"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,67 @@
1
+ class MagicLinksController < ApplicationController
2
+ allow_unauthenticated_access
3
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_magic_link_path, alert: "Try again later." }
4
+
5
+ def new
6
+ end
7
+
8
+ def create
9
+ user = User.find_by(email_address: params[:email_address])
10
+ user&.send_magic_link
11
+
12
+ redirect_to new_session_path, notice: "Sign-in link sent (if a user with that email address exists)."
13
+ end
14
+
15
+ def show
16
+ user = User.find_by_valid_magic_link_token(params[:token])
17
+
18
+ if user.nil?
19
+ redirect_to new_magic_link_path, alert: "Sign-in link is invalid or has expired."
20
+ <% if lockable? -%>
21
+ elsif user.locked?
22
+ <% if trackable? -%>
23
+ record_authentication_attempt(user, success: false, failure_reason: "locked")
24
+ <% end -%>
25
+ redirect_to new_session_path, alert: "Your account is locked. Check your email for unlock instructions."
26
+ <% end -%>
27
+ <% if confirmable? -%>
28
+ elsif !user.confirmed?
29
+ <% if trackable? -%>
30
+ record_authentication_attempt(user, success: false, failure_reason: "unconfirmed")
31
+ <% end -%>
32
+ redirect_to new_session_path, alert: "You must confirm your email address before signing in."
33
+ <% end -%>
34
+ <% if invitable? -%>
35
+ elsif user.invitation_pending?
36
+ <% if trackable? -%>
37
+ record_authentication_attempt(user, success: false, failure_reason: "invitation_pending")
38
+ <% end -%>
39
+ redirect_to new_session_path, alert: "You must accept your invitation before signing in."
40
+ <% end -%>
41
+ # Add your own authentication restrictions specific to your application
42
+ # elsif user.deleted_at.present?
43
+ <% if trackable? -%>
44
+ # record_authentication_attempt(user, success: false, failure_reason: "soft_deleted")
45
+ <% end -%>
46
+ # redirect_to new_session_path, alert: "Your account is not available."
47
+ else
48
+ user.consume_magic_link_token!
49
+ <% if lockable? -%>
50
+ user.reset_failed_attempts!
51
+ <% end -%>
52
+ <% if trackable? -%>
53
+ record_authentication_attempt(user, success: true)
54
+ <% end -%>
55
+ start_new_session_for user
56
+ redirect_to after_authentication_url
57
+ end
58
+ end
59
+ <% if trackable? -%>
60
+
61
+ private
62
+
63
+ def record_authentication_attempt(user, success:, failure_reason: nil)
64
+ UserAuth.record(user, request, success: success, failure_reason: failure_reason)
65
+ end
66
+ <% end -%>
67
+ end
@@ -0,0 +1,6 @@
1
+ class MagicLinksMailer < ApplicationMailer
2
+ def magic_link(user)
3
+ @user = user
4
+ mail subject: "Your sign-in link", to: user.email_address
5
+ end
6
+ end
@@ -0,0 +1,34 @@
1
+ module MagicLinkConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ MAGIC_LINK_EXPIRES_IN = 20.minutes
5
+
6
+ class_methods do
7
+ def find_by_valid_magic_link_token(token)
8
+ return if token.blank?
9
+
10
+ user = find_by(magic_link_token: token)
11
+ user if user&.magic_link_sent_at&.after?(MAGIC_LINK_EXPIRES_IN.ago)
12
+ end
13
+ end
14
+
15
+ def send_magic_link
16
+ generate_magic_link_token!
17
+ MagicLinksMailer.magic_link(self).deliver_later
18
+ end
19
+
20
+ def generate_magic_link_token!
21
+ update!(magic_link_token: build_magic_link_token, magic_link_sent_at: Time.current)
22
+ end
23
+
24
+ # Tokens are single-use: consumed on successful sign-in.
25
+ def consume_magic_link_token!
26
+ update!(magic_link_token: nil, magic_link_sent_at: nil)
27
+ end
28
+
29
+ protected
30
+
31
+ def build_magic_link_token
32
+ SecureRandom.urlsafe_base64(32)
33
+ end
34
+ end
@@ -0,0 +1,12 @@
1
+ <h1>Sign in with magic link</h1>
2
+
3
+ <%%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
4
+ <%%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
5
+
6
+ <%%= form_with url: magic_links_path do |form| %>
7
+ <%%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
8
+ <%%= form.submit "Email me a sign-in link" %>
9
+ <%% end %>
10
+ <br>
11
+
12
+ <%%= link_to "Sign in with password", new_session_path %>
@@ -0,0 +1,6 @@
1
+ <p>
2
+ You can sign in to your account by following
3
+ <%%= link_to "this sign-in link", magic_link_url(@user.magic_link_token) %>.
4
+
5
+ This link will expire in <%%= distance_of_time_in_words(0, MagicLinkConcern::MAGIC_LINK_EXPIRES_IN) %> and can only be used once.
6
+ </p>
@@ -0,0 +1,5 @@
1
+ You can sign in to your account by following the link below:
2
+
3
+ <%%= magic_link_url(@user.magic_link_token) %>
4
+
5
+ This link will expire in <%%= distance_of_time_in_words(0, MagicLinkConcern::MAGIC_LINK_EXPIRES_IN) %> and can only be used once.
@@ -11,7 +11,10 @@
11
11
  <%% end %>
12
12
  <br>
13
13
 
14
- <%%= link_to "Forgot password?", new_password_path %>
14
+ <% if magic_link? -%>
15
+ <br><%%= link_to "Sign in with magic link", new_magic_link_path %>
16
+ <% end -%>
17
+ <br><%%= link_to "Forgot password?", new_password_path %>
15
18
  <% if registerable? -%>
16
19
  <br><%%= link_to "Sign up", new_registration_path %>
17
20
  <% end -%>
@@ -0,0 +1,8 @@
1
+ class AddMagicLinkToUsers < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ add_column :users, :magic_link_token, :string
4
+ add_column :users, :magic_link_sent_at, :datetime
5
+
6
+ add_index :users, :magic_link_token, unique: true
7
+ end
8
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAuthentication
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_authentication
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Freerksen
@@ -37,6 +37,7 @@ files:
37
37
  - lib/generators/authentication/features/confirmable.rb
38
38
  - lib/generators/authentication/features/invitable.rb
39
39
  - lib/generators/authentication/features/lockable.rb
40
+ - lib/generators/authentication/features/magic_link.rb
40
41
  - lib/generators/authentication/features/recoverable.rb
41
42
  - lib/generators/authentication/features/registerable.rb
42
43
  - lib/generators/authentication/features/rememberable.rb
@@ -46,17 +47,20 @@ files:
46
47
  - lib/generators/authentication/templates/app/controllers/concerns/authentication.rb.tt
47
48
  - lib/generators/authentication/templates/app/controllers/confirmations_controller.rb.tt
48
49
  - lib/generators/authentication/templates/app/controllers/invitations_controller.rb.tt
50
+ - lib/generators/authentication/templates/app/controllers/magic_links_controller.rb.tt
49
51
  - lib/generators/authentication/templates/app/controllers/passwords_controller.rb.tt
50
52
  - lib/generators/authentication/templates/app/controllers/registrations_controller.rb.tt
51
53
  - lib/generators/authentication/templates/app/controllers/sessions_controller.rb.tt
52
54
  - lib/generators/authentication/templates/app/controllers/unlocks_controller.rb.tt
53
55
  - lib/generators/authentication/templates/app/mailers/confirmations_mailer.rb.tt
54
56
  - lib/generators/authentication/templates/app/mailers/invitations_mailer.rb.tt
57
+ - lib/generators/authentication/templates/app/mailers/magic_links_mailer.rb.tt
55
58
  - lib/generators/authentication/templates/app/mailers/passwords_mailer.rb.tt
56
59
  - lib/generators/authentication/templates/app/mailers/unlocks_mailer.rb.tt
57
60
  - lib/generators/authentication/templates/app/models/concerns/confirmable_concern.rb.tt
58
61
  - lib/generators/authentication/templates/app/models/concerns/invitable_concern.rb.tt
59
62
  - lib/generators/authentication/templates/app/models/concerns/lockable_concern.rb.tt
63
+ - lib/generators/authentication/templates/app/models/concerns/magic_link_concern.rb.tt
60
64
  - lib/generators/authentication/templates/app/models/concerns/recoverable_concern.rb.tt
61
65
  - lib/generators/authentication/templates/app/models/concerns/rememberable_concern.rb.tt
62
66
  - lib/generators/authentication/templates/app/models/concerns/timeoutable_concern.rb.tt
@@ -70,6 +74,9 @@ files:
70
74
  - lib/generators/authentication/templates/app/views/invitations/new.html.erb.tt
71
75
  - lib/generators/authentication/templates/app/views/invitations_mailer/invitation_instructions.html.erb.tt
72
76
  - lib/generators/authentication/templates/app/views/invitations_mailer/invitation_instructions.text.erb.tt
77
+ - lib/generators/authentication/templates/app/views/magic_links/new.html.erb.tt
78
+ - lib/generators/authentication/templates/app/views/magic_links_mailer/magic_link.html.erb.tt
79
+ - lib/generators/authentication/templates/app/views/magic_links_mailer/magic_link.text.erb.tt
73
80
  - lib/generators/authentication/templates/app/views/passwords_mailer/reset.html.erb.tt
74
81
  - lib/generators/authentication/templates/app/views/passwords_mailer/reset.text.erb.tt
75
82
  - lib/generators/authentication/templates/app/views/registrations/edit.html.erb.tt
@@ -81,6 +88,7 @@ files:
81
88
  - lib/generators/authentication/templates/db/migrate/add_confirmable_to_users.rb.tt
82
89
  - lib/generators/authentication/templates/db/migrate/add_invitable_to_users.rb.tt
83
90
  - lib/generators/authentication/templates/db/migrate/add_lockable_to_users.rb.tt
91
+ - lib/generators/authentication/templates/db/migrate/add_magic_link_to_users.rb.tt
84
92
  - lib/generators/authentication/templates/db/migrate/add_recoverable_to_users.rb.tt
85
93
  - lib/generators/authentication/templates/db/migrate/add_rememberable_to_users.rb.tt
86
94
  - lib/generators/authentication/templates/db/migrate/create_user_auths.rb.tt