rails_authentication 0.2.0 → 0.4.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 +33 -5
- data/lib/generators/authentication/authentication_generator.rb +31 -0
- data/lib/generators/authentication/features/magic_link.rb +28 -0
- data/lib/generators/authentication/features/ott.rb +30 -0
- data/lib/generators/authentication/templates/app/controllers/magic_links_controller.rb.tt +67 -0
- data/lib/generators/authentication/templates/app/controllers/otts_controller.rb.tt +95 -0
- data/lib/generators/authentication/templates/app/mailers/magic_links_mailer.rb.tt +6 -0
- data/lib/generators/authentication/templates/app/mailers/otts_mailer.rb.tt +6 -0
- data/lib/generators/authentication/templates/app/models/concerns/magic_link_concern.rb.tt +34 -0
- data/lib/generators/authentication/templates/app/models/concerns/ott_concern.rb.tt +50 -0
- data/lib/generators/authentication/templates/app/views/magic_links/new.html.erb.tt +12 -0
- data/lib/generators/authentication/templates/app/views/magic_links_mailer/magic_link.html.erb.tt +6 -0
- data/lib/generators/authentication/templates/app/views/magic_links_mailer/magic_link.text.erb.tt +5 -0
- data/lib/generators/authentication/templates/app/views/otts/edit.html.erb.tt +79 -0
- data/lib/generators/authentication/templates/app/views/otts_mailer/ott.html.erb.tt +8 -0
- data/lib/generators/authentication/templates/app/views/otts_mailer/ott.text.erb.tt +6 -0
- data/lib/generators/authentication/templates/app/views/sessions/new.html.erb.tt +20 -1
- data/lib/generators/authentication/templates/db/migrate/add_magic_link_to_users.rb.tt +8 -0
- data/lib/generators/authentication/templates/db/migrate/add_ott_to_users.rb.tt +7 -0
- data/lib/rails_authentication/version.rb +1 -1
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5c0f93e4dc6c9eb2562c7804920c82cff3a5a7d23d15758aaee3d63cdb60d99
|
|
4
|
+
data.tar.gz: 60ee05fafe8ffd4d9f56c10aa57c9ea5d347b801e94d3651ba2b04b258545a13
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bfd4e4526f964ef0ec6f0004b85061b64b86e6885c4e383adae282396b769f50e300de8e5424bd9892b59d5bae499d428be4719c6d8cdab0ec07fd76b64f5f3a
|
|
7
|
+
data.tar.gz: d4c2cb6365fd97356977944566aa332772c751061fcf8b1ad4b9e57b6d4eb7f0cd65ee497493d79286b1efb8bf85b897933e635b914e38a8994e419af61c1961
|
data/README.md
CHANGED
|
@@ -17,6 +17,8 @@ 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 |
|
|
21
|
+
| **Ott** (opt-in) | Passwordless sign-in via an emailed 6-digit one-time code — replaces the password sign-in form |
|
|
20
22
|
|
|
21
23
|
Everything is **generated into your app** as plain, readable code — controllers, views, mailers,
|
|
22
24
|
migrations, and one model concern per feature. There is no runtime dependency on this gem: after
|
|
@@ -27,7 +29,7 @@ This gem is meant to be installed temporarily. Install it long enough to run the
|
|
|
27
29
|
## Installation
|
|
28
30
|
|
|
29
31
|
Requires Rails >= 8.0 and, for the email-driven features (Confirmable, Recoverable, Lockable,
|
|
30
|
-
Invitable), Action Mailer.
|
|
32
|
+
Invitable, MagicLink, Ott), Action Mailer.
|
|
31
33
|
|
|
32
34
|
```ruby
|
|
33
35
|
# Gemfile
|
|
@@ -55,12 +57,37 @@ Available flags: `--skip-confirmable`, `--skip-recoverable`, `--skip-registerabl
|
|
|
55
57
|
`--skip-lockable`, `--skip-invitable`, and `--reconfirmable` (Confirmable: postpone email address
|
|
56
58
|
changes until the new address is confirmed, via an `unconfirmed_email` column).
|
|
57
59
|
|
|
60
|
+
MagicLink and Ott are **opt-in** features — they change the sign-in UX, so you have to ask for
|
|
61
|
+
them:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
bin/rails generate authentication --magic-link
|
|
65
|
+
bin/rails generate authentication --ott
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
MagicLink adds a "Sign in with magic link" link to the sign-in page, leading to an email-only
|
|
69
|
+
form. The emailed link signs the user in directly; tokens are DB-backed, single-use, and expire
|
|
70
|
+
after 20 minutes (`MagicLinkConcern::MAGIC_LINK_EXPIRES_IN`).
|
|
71
|
+
|
|
72
|
+
Ott replaces the sign-in form: the user enters only their email address, receives a 6-digit code
|
|
73
|
+
(`OttConcern::OTT_CODE_LENGTH`) by email, and enters it on a second screen — one single-digit
|
|
74
|
+
input per digit with auto-advance and paste/autofill support (plain inline JavaScript, no
|
|
75
|
+
framework required). A "Sign in with password
|
|
76
|
+
instead" link (`/session/new?with_password=1`) toggles back to the classic password form at
|
|
77
|
+
runtime. Codes are DB-backed, single-use, expire after 10 minutes (`OttConcern::OTT_EXPIRES_IN`),
|
|
78
|
+
and are voided after 5 wrong entries (`OttConcern::OTT_MAX_ATTEMPTS`). The password machinery
|
|
79
|
+
(SessionsController#create, Recoverable, Registerable) stays generated and functional — only the
|
|
80
|
+
sign-in UI changes.
|
|
81
|
+
|
|
82
|
+
Both flows honor the other enabled features: locked, unconfirmed, or invitation-pending accounts
|
|
83
|
+
still can't sign in, and Trackable records the attempt.
|
|
84
|
+
|
|
58
85
|
Each feature adds a single `include <Feature>Concern` line to `app/models/user.rb`; all of its
|
|
59
86
|
model behavior lives in `app/models/concerns/<feature>_concern.rb`. Tunables are plain constants in
|
|
60
87
|
the generated concerns — e.g. `TimeoutableConcern::TIMEOUT_IN`, `LockableConcern::MAXIMUM_ATTEMPTS`,
|
|
61
88
|
`LockableConcern::UNLOCK_IN`, `ConfirmableConcern::CONFIRMATION_TOKEN_EXPIRES_IN`,
|
|
62
|
-
`ValidatableConcern::PASSWORD_LENGTH`, `ValidatableConcern::PASSWORD_MINIMUM_COMPLEXITY
|
|
63
|
-
there. Concerns keep the model clean.
|
|
89
|
+
`ValidatableConcern::PASSWORD_LENGTH`, `ValidatableConcern::PASSWORD_MINIMUM_COMPLEXITY`,
|
|
90
|
+
`OttConcern::OTT_CODE_LENGTH` — edit them there. Concerns keep the model clean.
|
|
64
91
|
|
|
65
92
|
### Generated routes
|
|
66
93
|
|
|
@@ -69,6 +96,8 @@ resource :registration, only: %i[ new create edit update destroy ]
|
|
|
69
96
|
resources :confirmations, only: %i[ new create show ], param: :token
|
|
70
97
|
resources :unlocks, only: %i[ new create show ], param: :token
|
|
71
98
|
resources :invitations, only: %i[ new create edit update ], param: :token
|
|
99
|
+
resources :magic_links, only: %i[ new create show ], param: :token # with --magic-link
|
|
100
|
+
resource :ott, only: %i[ create edit update ] # with --ott
|
|
72
101
|
resource :session # from the base generator
|
|
73
102
|
resources :passwords, param: :token # from the base generator
|
|
74
103
|
```
|
|
@@ -87,8 +116,7 @@ resources :passwords, param: :token # from the base generator
|
|
|
87
116
|
|
|
88
117
|
## Future Plans
|
|
89
118
|
|
|
90
|
-
-
|
|
91
|
-
- Passwordless (code emailed)
|
|
119
|
+
- Passkey
|
|
92
120
|
|
|
93
121
|
## Development
|
|
94
122
|
|
|
@@ -11,6 +11,8 @@ 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"
|
|
15
|
+
require_relative "features/ott"
|
|
14
16
|
|
|
15
17
|
module RailsAuthentication
|
|
16
18
|
module Generators
|
|
@@ -42,6 +44,8 @@ module RailsAuthentication
|
|
|
42
44
|
include Features::Validatable
|
|
43
45
|
include Features::Lockable
|
|
44
46
|
include Features::Invitable
|
|
47
|
+
include Features::MagicLink
|
|
48
|
+
include Features::Ott
|
|
45
49
|
|
|
46
50
|
source_root File.expand_path("templates", __dir__)
|
|
47
51
|
|
|
@@ -53,6 +57,17 @@ module RailsAuthentication
|
|
|
53
57
|
class_option :reconfirmable, type: :boolean, default: false,
|
|
54
58
|
desc: "Confirmable: postpone email address changes until reconfirmed (adds unconfirmed_email column)"
|
|
55
59
|
|
|
60
|
+
# Unlike the FEATURES above (default-on, opt-out via --skip-<feature>),
|
|
61
|
+
# magic link is opt-in: it changes the sign-in UX, so it must be requested.
|
|
62
|
+
class_option :magic_link, type: :boolean, default: false,
|
|
63
|
+
desc: "Add passwordless magic link sign-in (opt-in)"
|
|
64
|
+
|
|
65
|
+
# Also opt-in: replaces the sign-in form with an email-only form that
|
|
66
|
+
# emails a 6-digit one-time code, entered on a second screen. Password
|
|
67
|
+
# machinery stays generated and functional.
|
|
68
|
+
class_option :ott, type: :boolean, default: false,
|
|
69
|
+
desc: "Add one-time token (emailed 6-digit code) sign-in (opt-in)"
|
|
70
|
+
|
|
56
71
|
def install_base_authentication
|
|
57
72
|
say "Running Rails' built-in authentication generator", :green
|
|
58
73
|
Rails::Generators.invoke("rails:authentication", [], behavior: behavior, destination_root: destination_root)
|
|
@@ -94,6 +109,14 @@ module RailsAuthentication
|
|
|
94
109
|
generate_invitable if invitable?
|
|
95
110
|
end
|
|
96
111
|
|
|
112
|
+
def install_magic_link
|
|
113
|
+
generate_magic_link if magic_link?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def install_ott
|
|
117
|
+
generate_ott if ott?
|
|
118
|
+
end
|
|
119
|
+
|
|
97
120
|
# Runs after every feature install so the blank line separates the concern
|
|
98
121
|
# includes (if any) from the rest of the class body, no matter which features
|
|
99
122
|
# are enabled.
|
|
@@ -120,6 +143,14 @@ module RailsAuthentication
|
|
|
120
143
|
confirmable? && options[:reconfirmable]
|
|
121
144
|
end
|
|
122
145
|
|
|
146
|
+
def magic_link?
|
|
147
|
+
options[:magic_link]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def ott?
|
|
151
|
+
options[:ott]
|
|
152
|
+
end
|
|
153
|
+
|
|
123
154
|
def include_concern_in_user(concern)
|
|
124
155
|
inject_into_class "app/models/user.rb", "User", " include #{concern}\n"
|
|
125
156
|
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,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAuthentication
|
|
4
|
+
module Generators
|
|
5
|
+
module Features
|
|
6
|
+
# Opt-in (--ott): one-time token sign-in. The sign-in page becomes an
|
|
7
|
+
# email-only form; a 6-digit code is emailed and entered on a second
|
|
8
|
+
# screen. Codes are DB-backed, single-use, expire after 10 minutes, and
|
|
9
|
+
# are voided after 5 wrong attempts. Password machinery (Recoverable,
|
|
10
|
+
# Registerable, SessionsController#create) is left intact.
|
|
11
|
+
module Ott
|
|
12
|
+
private
|
|
13
|
+
def generate_ott
|
|
14
|
+
template "app/models/concerns/ott_concern.rb"
|
|
15
|
+
include_concern_in_user "OttConcern"
|
|
16
|
+
migration_template "db/migrate/add_ott_to_users.rb", "db/migrate/add_ott_to_users.rb"
|
|
17
|
+
template "app/controllers/otts_controller.rb"
|
|
18
|
+
template "app/views/otts/edit.html.erb"
|
|
19
|
+
route "resource :ott, only: %i[ create edit update ]"
|
|
20
|
+
|
|
21
|
+
if defined?(ActionMailer::Railtie)
|
|
22
|
+
template "app/mailers/otts_mailer.rb"
|
|
23
|
+
template "app/views/otts_mailer/ott.html.erb"
|
|
24
|
+
template "app/views/otts_mailer/ott.text.erb"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
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,95 @@
|
|
|
1
|
+
class OttsController < ApplicationController
|
|
2
|
+
allow_unauthenticated_access
|
|
3
|
+
rate_limit to: 10, within: 3.minutes, only: %i[ create update ], with: -> { redirect_to new_session_path, alert: "Try again later." }
|
|
4
|
+
|
|
5
|
+
def create
|
|
6
|
+
email_address = params[:email_address].presence || session[:ott_email_address]
|
|
7
|
+
user = User.find_by(email_address: email_address)
|
|
8
|
+
user&.send_ott
|
|
9
|
+
session[:ott_email_address] = email_address
|
|
10
|
+
|
|
11
|
+
redirect_to edit_ott_path, notice: "We've emailed you a sign-in code (if a user with that email address exists)."
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def edit
|
|
15
|
+
redirect_to new_session_path if session[:ott_email_address].blank?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def update
|
|
19
|
+
user = User.find_by(email_address: session[:ott_email_address])
|
|
20
|
+
|
|
21
|
+
case user && user.verify_ott(params[:code])
|
|
22
|
+
when nil, :expired
|
|
23
|
+
redirect_to new_session_path, alert: "Your sign-in code is invalid or has expired. Request a new one."
|
|
24
|
+
when :exhausted
|
|
25
|
+
<% if trackable? -%>
|
|
26
|
+
record_authentication_attempt(user, success: false, failure_reason: "ott_attempts_exhausted")
|
|
27
|
+
<% end -%>
|
|
28
|
+
redirect_to new_session_path, alert: "Too many incorrect attempts. Request a new code."
|
|
29
|
+
when :invalid
|
|
30
|
+
<% if trackable? -%>
|
|
31
|
+
record_authentication_attempt(user, success: false, failure_reason: "invalid_ott")
|
|
32
|
+
<% end -%>
|
|
33
|
+
redirect_to edit_ott_path, alert: "Incorrect code. Try again."
|
|
34
|
+
else
|
|
35
|
+
complete_ott_sign_in(user)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def complete_ott_sign_in(user)
|
|
42
|
+
<% if lockable? -%>
|
|
43
|
+
if user.locked?
|
|
44
|
+
<% if trackable? -%>
|
|
45
|
+
record_authentication_attempt(user, success: false, failure_reason: "locked")
|
|
46
|
+
<% end -%>
|
|
47
|
+
return redirect_to new_session_path, alert: "Your account is locked. Check your email for unlock instructions."
|
|
48
|
+
end
|
|
49
|
+
<% end -%>
|
|
50
|
+
<% if confirmable? -%>
|
|
51
|
+
unless user.confirmed?
|
|
52
|
+
<% if trackable? -%>
|
|
53
|
+
record_authentication_attempt(user, success: false, failure_reason: "unconfirmed")
|
|
54
|
+
<% end -%>
|
|
55
|
+
return redirect_to new_session_path, alert: "You must confirm your email address before signing in."
|
|
56
|
+
end
|
|
57
|
+
<% end -%>
|
|
58
|
+
<% if invitable? -%>
|
|
59
|
+
if user.invitation_pending?
|
|
60
|
+
<% if trackable? -%>
|
|
61
|
+
record_authentication_attempt(user, success: false, failure_reason: "invitation_pending")
|
|
62
|
+
<% end -%>
|
|
63
|
+
return redirect_to new_session_path, alert: "You must accept your invitation before signing in."
|
|
64
|
+
end
|
|
65
|
+
<% end -%>
|
|
66
|
+
# Add your own authentication restrictions specific to your application
|
|
67
|
+
# if user.deleted_at.present?
|
|
68
|
+
<% if trackable? -%>
|
|
69
|
+
# record_authentication_attempt(user, success: false, failure_reason: "soft_deleted")
|
|
70
|
+
<% end -%>
|
|
71
|
+
# return redirect_to new_session_path, alert: "Your account is not available."
|
|
72
|
+
# end
|
|
73
|
+
|
|
74
|
+
user.consume_ott!
|
|
75
|
+
session.delete(:ott_email_address)
|
|
76
|
+
<% if lockable? -%>
|
|
77
|
+
user.reset_failed_attempts!
|
|
78
|
+
<% end -%>
|
|
79
|
+
<% if trackable? -%>
|
|
80
|
+
record_authentication_attempt(user, success: true)
|
|
81
|
+
<% end -%>
|
|
82
|
+
<% if rememberable? -%>
|
|
83
|
+
start_new_session_for user, remember: params[:remember_me] == "1"
|
|
84
|
+
<% else -%>
|
|
85
|
+
start_new_session_for user
|
|
86
|
+
<% end -%>
|
|
87
|
+
redirect_to after_authentication_url
|
|
88
|
+
end
|
|
89
|
+
<% if trackable? -%>
|
|
90
|
+
|
|
91
|
+
def record_authentication_attempt(user, success:, failure_reason: nil)
|
|
92
|
+
UserAuth.record(user, request, success: success, failure_reason: failure_reason)
|
|
93
|
+
end
|
|
94
|
+
<% end -%>
|
|
95
|
+
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,50 @@
|
|
|
1
|
+
module OttConcern
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
OTT_EXPIRES_IN = 10.minutes
|
|
5
|
+
OTT_MAX_ATTEMPTS = 5
|
|
6
|
+
OTT_CODE_LENGTH = 6
|
|
7
|
+
|
|
8
|
+
def send_ott
|
|
9
|
+
generate_ott!
|
|
10
|
+
OttsMailer.ott(self).deliver_later
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def generate_ott!
|
|
14
|
+
update!(ott_code: build_ott_code, ott_sent_at: Time.current, ott_attempts: 0)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns :valid, :invalid, :exhausted, or :expired. Codes are single-use:
|
|
18
|
+
# consumed on successful sign-in, and voided once OTT_MAX_ATTEMPTS wrong
|
|
19
|
+
# codes have been entered.
|
|
20
|
+
def verify_ott(code)
|
|
21
|
+
return :expired unless ott_active?
|
|
22
|
+
|
|
23
|
+
if ActiveSupport::SecurityUtils.secure_compare(ott_code, code.to_s)
|
|
24
|
+
:valid
|
|
25
|
+
else
|
|
26
|
+
increment!(:ott_attempts)
|
|
27
|
+
|
|
28
|
+
if ott_attempts >= OTT_MAX_ATTEMPTS
|
|
29
|
+
consume_ott!
|
|
30
|
+
:exhausted
|
|
31
|
+
else
|
|
32
|
+
:invalid
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def consume_ott!
|
|
38
|
+
update!(ott_code: nil, ott_sent_at: nil, ott_attempts: 0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
protected
|
|
42
|
+
|
|
43
|
+
def ott_active?
|
|
44
|
+
ott_code.present? && ott_sent_at&.after?(OTT_EXPIRES_IN.ago)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_ott_code
|
|
48
|
+
SecureRandom.random_number(10**OTT_CODE_LENGTH).to_s.rjust(OTT_CODE_LENGTH, "0")
|
|
49
|
+
end
|
|
50
|
+
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 %>
|
data/lib/generators/authentication/templates/app/views/magic_links_mailer/magic_link.html.erb.tt
ADDED
|
@@ -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,79 @@
|
|
|
1
|
+
<h1>Enter your sign-in code</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
|
+
<p>
|
|
7
|
+
We emailed a <%%= OttConcern::OTT_CODE_LENGTH %>-digit code to <%%= session[:ott_email_address] %>.
|
|
8
|
+
It expires in <%%= distance_of_time_in_words(0, OttConcern::OTT_EXPIRES_IN) %>.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<%%= form_with url: ott_path, method: :patch do |form| %>
|
|
12
|
+
<%%= form.hidden_field :code, id: "ott-code" %>
|
|
13
|
+
<div id="ott-digits">
|
|
14
|
+
<%% OttConcern::OTT_CODE_LENGTH.times do |i| %>
|
|
15
|
+
<input type="text" inputmode="numeric" pattern="[0-9]*" maxlength="<%%= OttConcern::OTT_CODE_LENGTH %>" required
|
|
16
|
+
autocomplete="<%%= i.zero? ? "one-time-code" : "off" %>"
|
|
17
|
+
aria-label="Digit <%%= i + 1 %>" <%%= "autofocus" if i.zero? %>
|
|
18
|
+
style="width:2em;text-align:center;font-size:1.5em">
|
|
19
|
+
<%% end %>
|
|
20
|
+
</div>
|
|
21
|
+
<br>
|
|
22
|
+
<% if rememberable? -%>
|
|
23
|
+
<%%= form.check_box :remember_me %> <%%= form.label :remember_me, "Remember me" %><br>
|
|
24
|
+
<% end -%>
|
|
25
|
+
<%%= form.submit "Sign in" %>
|
|
26
|
+
<%% end %>
|
|
27
|
+
<br>
|
|
28
|
+
|
|
29
|
+
<%%= button_to "Resend code", ott_path, method: :post %>
|
|
30
|
+
|
|
31
|
+
<script>
|
|
32
|
+
(function() {
|
|
33
|
+
var boxes = Array.prototype.slice.call(document.querySelectorAll("#ott-digits input"));
|
|
34
|
+
var hidden = document.getElementById("ott-code");
|
|
35
|
+
|
|
36
|
+
function sync() {
|
|
37
|
+
hidden.value = boxes.map(function(box) { return box.value; }).join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Distributes a string of digits across the boxes, starting at `start`.
|
|
41
|
+
function fill(text, start) {
|
|
42
|
+
var digits = text.replace(/\D/g, "").slice(0, boxes.length - start).split("");
|
|
43
|
+
digits.forEach(function(digit, i) { boxes[start + i].value = digit; });
|
|
44
|
+
boxes[Math.min(start + digits.length, boxes.length - 1)].focus();
|
|
45
|
+
sync();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
boxes.forEach(function(box, i) {
|
|
49
|
+
box.addEventListener("input", function() {
|
|
50
|
+
// A multi-character value means a paste or OS one-time-code autofill.
|
|
51
|
+
if (box.value.length > 1) return fill(box.value, i);
|
|
52
|
+
|
|
53
|
+
box.value = box.value.replace(/\D/g, "");
|
|
54
|
+
if (box.value && i < boxes.length - 1) boxes[i + 1].focus();
|
|
55
|
+
sync();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
box.addEventListener("keydown", function(event) {
|
|
59
|
+
if (event.key === "Backspace" && !box.value && i > 0) {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
boxes[i - 1].value = "";
|
|
62
|
+
boxes[i - 1].focus();
|
|
63
|
+
sync();
|
|
64
|
+
} else if (event.key === "ArrowLeft" && i > 0) {
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
boxes[i - 1].focus();
|
|
67
|
+
} else if (event.key === "ArrowRight" && i < boxes.length - 1) {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
boxes[i + 1].focus();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
box.addEventListener("paste", function(event) {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
fill((event.clipboardData || window.clipboardData).getData("text"), 0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
})();
|
|
79
|
+
</script>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<p>Your sign-in code is:</p>
|
|
2
|
+
|
|
3
|
+
<p><strong><%%= @user.ott_code %></strong></p>
|
|
4
|
+
|
|
5
|
+
<p>
|
|
6
|
+
This code will expire in <%%= distance_of_time_in_words(0, OttConcern::OTT_EXPIRES_IN) %> and can only be used once.
|
|
7
|
+
If you didn't request it, you can ignore this email.
|
|
8
|
+
</p>
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
<%%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
|
|
2
2
|
<%%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
|
|
3
3
|
|
|
4
|
+
<% if ott? -%>
|
|
5
|
+
<%% if params[:with_password].blank? %>
|
|
6
|
+
<%%= form_with url: ott_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 code" %>
|
|
9
|
+
<%% end %>
|
|
10
|
+
<br>
|
|
11
|
+
|
|
12
|
+
<br><%%= link_to "Sign in with password instead", new_session_path(with_password: 1) %>
|
|
13
|
+
<%% else %>
|
|
14
|
+
<% end -%>
|
|
4
15
|
<%%= form_with url: session_path do |form| %>
|
|
5
16
|
<%%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
|
|
6
17
|
<%%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %><br>
|
|
@@ -10,8 +21,16 @@
|
|
|
10
21
|
<%%= form.submit "Sign in" %>
|
|
11
22
|
<%% end %>
|
|
12
23
|
<br>
|
|
24
|
+
<% if ott? -%>
|
|
13
25
|
|
|
14
|
-
|
|
26
|
+
<br><%%= link_to "Sign in with a one-time code instead", new_session_path %>
|
|
27
|
+
<%% end %>
|
|
28
|
+
<% end -%>
|
|
29
|
+
|
|
30
|
+
<% if magic_link? -%>
|
|
31
|
+
<br><%%= link_to "Sign in with magic link", new_magic_link_path %>
|
|
32
|
+
<% end -%>
|
|
33
|
+
<br><%%= link_to "Forgot password?", new_password_path %>
|
|
15
34
|
<% if registerable? -%>
|
|
16
35
|
<br><%%= link_to "Sign up", new_registration_path %>
|
|
17
36
|
<% 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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Freerksen
|
|
@@ -37,6 +37,8 @@ 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
|
|
41
|
+
- lib/generators/authentication/features/ott.rb
|
|
40
42
|
- lib/generators/authentication/features/recoverable.rb
|
|
41
43
|
- lib/generators/authentication/features/registerable.rb
|
|
42
44
|
- lib/generators/authentication/features/rememberable.rb
|
|
@@ -46,17 +48,23 @@ files:
|
|
|
46
48
|
- lib/generators/authentication/templates/app/controllers/concerns/authentication.rb.tt
|
|
47
49
|
- lib/generators/authentication/templates/app/controllers/confirmations_controller.rb.tt
|
|
48
50
|
- lib/generators/authentication/templates/app/controllers/invitations_controller.rb.tt
|
|
51
|
+
- lib/generators/authentication/templates/app/controllers/magic_links_controller.rb.tt
|
|
52
|
+
- lib/generators/authentication/templates/app/controllers/otts_controller.rb.tt
|
|
49
53
|
- lib/generators/authentication/templates/app/controllers/passwords_controller.rb.tt
|
|
50
54
|
- lib/generators/authentication/templates/app/controllers/registrations_controller.rb.tt
|
|
51
55
|
- lib/generators/authentication/templates/app/controllers/sessions_controller.rb.tt
|
|
52
56
|
- lib/generators/authentication/templates/app/controllers/unlocks_controller.rb.tt
|
|
53
57
|
- lib/generators/authentication/templates/app/mailers/confirmations_mailer.rb.tt
|
|
54
58
|
- lib/generators/authentication/templates/app/mailers/invitations_mailer.rb.tt
|
|
59
|
+
- lib/generators/authentication/templates/app/mailers/magic_links_mailer.rb.tt
|
|
60
|
+
- lib/generators/authentication/templates/app/mailers/otts_mailer.rb.tt
|
|
55
61
|
- lib/generators/authentication/templates/app/mailers/passwords_mailer.rb.tt
|
|
56
62
|
- lib/generators/authentication/templates/app/mailers/unlocks_mailer.rb.tt
|
|
57
63
|
- lib/generators/authentication/templates/app/models/concerns/confirmable_concern.rb.tt
|
|
58
64
|
- lib/generators/authentication/templates/app/models/concerns/invitable_concern.rb.tt
|
|
59
65
|
- lib/generators/authentication/templates/app/models/concerns/lockable_concern.rb.tt
|
|
66
|
+
- lib/generators/authentication/templates/app/models/concerns/magic_link_concern.rb.tt
|
|
67
|
+
- lib/generators/authentication/templates/app/models/concerns/ott_concern.rb.tt
|
|
60
68
|
- lib/generators/authentication/templates/app/models/concerns/recoverable_concern.rb.tt
|
|
61
69
|
- lib/generators/authentication/templates/app/models/concerns/rememberable_concern.rb.tt
|
|
62
70
|
- lib/generators/authentication/templates/app/models/concerns/timeoutable_concern.rb.tt
|
|
@@ -70,6 +78,12 @@ files:
|
|
|
70
78
|
- lib/generators/authentication/templates/app/views/invitations/new.html.erb.tt
|
|
71
79
|
- lib/generators/authentication/templates/app/views/invitations_mailer/invitation_instructions.html.erb.tt
|
|
72
80
|
- lib/generators/authentication/templates/app/views/invitations_mailer/invitation_instructions.text.erb.tt
|
|
81
|
+
- lib/generators/authentication/templates/app/views/magic_links/new.html.erb.tt
|
|
82
|
+
- lib/generators/authentication/templates/app/views/magic_links_mailer/magic_link.html.erb.tt
|
|
83
|
+
- lib/generators/authentication/templates/app/views/magic_links_mailer/magic_link.text.erb.tt
|
|
84
|
+
- lib/generators/authentication/templates/app/views/otts/edit.html.erb.tt
|
|
85
|
+
- lib/generators/authentication/templates/app/views/otts_mailer/ott.html.erb.tt
|
|
86
|
+
- lib/generators/authentication/templates/app/views/otts_mailer/ott.text.erb.tt
|
|
73
87
|
- lib/generators/authentication/templates/app/views/passwords_mailer/reset.html.erb.tt
|
|
74
88
|
- lib/generators/authentication/templates/app/views/passwords_mailer/reset.text.erb.tt
|
|
75
89
|
- lib/generators/authentication/templates/app/views/registrations/edit.html.erb.tt
|
|
@@ -81,6 +95,8 @@ files:
|
|
|
81
95
|
- lib/generators/authentication/templates/db/migrate/add_confirmable_to_users.rb.tt
|
|
82
96
|
- lib/generators/authentication/templates/db/migrate/add_invitable_to_users.rb.tt
|
|
83
97
|
- lib/generators/authentication/templates/db/migrate/add_lockable_to_users.rb.tt
|
|
98
|
+
- lib/generators/authentication/templates/db/migrate/add_magic_link_to_users.rb.tt
|
|
99
|
+
- lib/generators/authentication/templates/db/migrate/add_ott_to_users.rb.tt
|
|
84
100
|
- lib/generators/authentication/templates/db/migrate/add_recoverable_to_users.rb.tt
|
|
85
101
|
- lib/generators/authentication/templates/db/migrate/add_rememberable_to_users.rb.tt
|
|
86
102
|
- lib/generators/authentication/templates/db/migrate/create_user_auths.rb.tt
|