passkey_auth 0.1.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 +7 -0
- data/CHANGELOG.md +30 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +334 -0
- data/Rakefile +12 -0
- data/app/controllers/passkey_auth/application_controller.rb +13 -0
- data/app/controllers/passkey_auth/magic_links_controller.rb +120 -0
- data/app/controllers/passkey_auth/webauthn/authentications/challenges_controller.rb +40 -0
- data/app/controllers/passkey_auth/webauthn/authentications_controller.rb +62 -0
- data/app/controllers/passkey_auth/webauthn/credentials/challenges_controller.rb +53 -0
- data/app/controllers/passkey_auth/webauthn/credentials_controller.rb +58 -0
- data/app/javascript/passkey_auth/controllers/index.js +4 -0
- data/app/javascript/passkey_auth/controllers/passkey_authentication_controller.js +156 -0
- data/app/javascript/passkey_auth/controllers/webauthn_auth_controller.js +40 -0
- data/app/javascript/passkey_auth/controllers/webauthn_register_controller.js +70 -0
- data/app/javascript/passkey_auth/index.js +3 -0
- data/app/javascript/passkey_auth/lib/passkey.js +103 -0
- data/app/mailers/passkey_auth/application_mailer.rb +8 -0
- data/app/mailers/passkey_auth/magic_link_mailer.rb +17 -0
- data/app/models/passkey_auth/magic_link/short_code.rb +29 -0
- data/app/models/passkey_auth/magic_link.rb +92 -0
- data/app/models/passkey_auth/webauthn_credential.rb +14 -0
- data/app/views/passkey_auth/magic_link_mailer/login_link.html.erb +45 -0
- data/app/views/passkey_auth/magic_links/new.html.erb +19 -0
- data/app/views/passkey_auth/magic_links/verify_code.html.erb +21 -0
- data/config/locales/en.yml +53 -0
- data/config/routes.rb +28 -0
- data/lib/generators/passkey_auth/install/install_generator.rb +98 -0
- data/lib/generators/passkey_auth/install/templates/add_passwordless_to_users.rb.erb +8 -0
- data/lib/generators/passkey_auth/install/templates/create_passkey_auth_magic_links.rb.erb +21 -0
- data/lib/generators/passkey_auth/install/templates/create_passkey_auth_webauthn_credentials.rb.erb +16 -0
- data/lib/generators/passkey_auth/install/templates/initializer.rb +26 -0
- data/lib/passkey_auth/concerns/passwordless.rb +102 -0
- data/lib/passkey_auth/engine.rb +32 -0
- data/lib/passkey_auth/version.rb +5 -0
- data/lib/passkey_auth.rb +34 -0
- data/sig/passkey_auth.rbs +4 -0
- metadata +127 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PasskeyAuth
|
|
4
|
+
class MagicLink < ApplicationRecord
|
|
5
|
+
self.table_name = "passkey_auth_magic_links"
|
|
6
|
+
|
|
7
|
+
belongs_to :user, class_name: PasskeyAuth.user_model_name, foreign_key: :user_id
|
|
8
|
+
|
|
9
|
+
PURPOSES = %w[login recovery email_confirmation].freeze
|
|
10
|
+
|
|
11
|
+
has_secure_token :token, length: 32
|
|
12
|
+
|
|
13
|
+
validates :expires_at, presence: true
|
|
14
|
+
validates :purpose, inclusion: { in: PURPOSES }
|
|
15
|
+
|
|
16
|
+
scope :active, -> { where(used_at: nil).where("expires_at > ?", Time.current) }
|
|
17
|
+
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
18
|
+
scope :used, -> { where.not(used_at: nil) }
|
|
19
|
+
|
|
20
|
+
before_validation :set_defaults, on: :create
|
|
21
|
+
|
|
22
|
+
def expired?
|
|
23
|
+
expires_at <= Time.current
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def used?
|
|
27
|
+
used_at.present?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def valid_for_use?
|
|
31
|
+
!expired? && !used?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def use!(ip: nil, user_agent: nil)
|
|
35
|
+
with_lock do
|
|
36
|
+
return false unless valid_for_use?
|
|
37
|
+
|
|
38
|
+
update!(
|
|
39
|
+
used_at: Time.current,
|
|
40
|
+
ip_address: ip,
|
|
41
|
+
user_agent: user_agent
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.consume_by_token(token, ip:, user_agent:)
|
|
47
|
+
link = find_by(token: token)
|
|
48
|
+
|
|
49
|
+
return unless link
|
|
50
|
+
|
|
51
|
+
link.use!(ip: ip, user_agent: user_agent) ? link : nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.consume_by_email_and_code(email, code, ip:, user_agent:)
|
|
55
|
+
cleaned_code = code&.upcase&.strip
|
|
56
|
+
cleaned_email = email&.downcase&.strip
|
|
57
|
+
|
|
58
|
+
# Use configured email attribute
|
|
59
|
+
email_attr = PasskeyAuth.user_email_attribute
|
|
60
|
+
link = includes(:user).find_by(short_code: cleaned_code, user: { email_attr => cleaned_email })
|
|
61
|
+
|
|
62
|
+
return unless link
|
|
63
|
+
|
|
64
|
+
link.use!(ip: ip, user_agent: user_agent) ? link : nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.generate_short_code
|
|
68
|
+
ShortCode.generate
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.cleanup_expired
|
|
72
|
+
expired.destroy_all
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def invalid_reason
|
|
76
|
+
if expired?
|
|
77
|
+
:expired
|
|
78
|
+
elsif used?
|
|
79
|
+
:used
|
|
80
|
+
else
|
|
81
|
+
:generic
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def set_defaults
|
|
88
|
+
self.short_code ||= self.class.generate_short_code
|
|
89
|
+
self.expires_at ||= PasskeyAuth.magic_link_expiration.from_now
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PasskeyAuth
|
|
4
|
+
class WebauthnCredential < ApplicationRecord
|
|
5
|
+
self.table_name = "passkey_auth_webauthn_credentials"
|
|
6
|
+
|
|
7
|
+
belongs_to :user, class_name: PasskeyAuth.user_model_name, foreign_key: :user_id
|
|
8
|
+
|
|
9
|
+
validates :external_id, presence: true, uniqueness: true
|
|
10
|
+
validates :public_key, presence: true
|
|
11
|
+
validates :nickname, presence: true, uniqueness: { scope: :user_id }
|
|
12
|
+
validates :sign_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<div style="padding: 40px;">
|
|
2
|
+
<h1 style="color: #1a1a1a; font-size: 24px; font-weight: 600; margin: 0 0 24px 0;">
|
|
3
|
+
<%= I18n.t("passkey_auth.magic_link_mailer.login_link.title") %>
|
|
4
|
+
</h1>
|
|
5
|
+
|
|
6
|
+
<p style="color: #4a4a4a; font-size: 16px; line-height: 24px; margin: 0 0 16px 0;">
|
|
7
|
+
<%= I18n.t("passkey_auth.magic_link_mailer.login_link.greeting", name: @user.try(:first_name) || PasskeyAuth.user_email(@user).split("@").first) %>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p style="color: #4a4a4a; font-size: 16px; line-height: 24px; margin: 0 0 32px 0;">
|
|
11
|
+
<%= I18n.t("passkey_auth.magic_link_mailer.login_link.body") %>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<div style="text-align: center; margin: 32px 0;">
|
|
15
|
+
<%= link_to I18n.t("passkey_auth.magic_link_mailer.login_link.button"), @url,
|
|
16
|
+
style: "display: inline-block; padding: 14px 32px; background-color: #5469d4; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;" %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<p style="color: #4a4a4a; font-size: 16px; line-height: 24px; margin: 32px 0 16px 0;">
|
|
20
|
+
<%= I18n.t("passkey_auth.magic_link_mailer.login_link.code_prompt") %>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<p style="font-size: 28px; font-weight: 600; text-align: center; letter-spacing: 2px; color: #1a1a1a; margin: 16px 0 32px 0;" data-otp="<%= @short_code.tr('-', '') %>">
|
|
24
|
+
<%= @short_code %>
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<p style="color: #6b6b6b; font-size: 14px; line-height: 20px; margin: 0 0 16px 0;">
|
|
28
|
+
<%= I18n.t("passkey_auth.magic_link_mailer.login_link.copy_link") %><br>
|
|
29
|
+
<%= @url %>
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
<p style="color: #6b6b6b; font-size: 14px; line-height: 20px; margin: 0 0 16px 0;">
|
|
33
|
+
<strong><%= I18n.t("passkey_auth.magic_link_mailer.login_link.expires", minutes: @expires_in) %></strong>
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<p style="color: #6b6b6b; font-size: 14px; line-height: 20px; margin: 0 0 16px 0;">
|
|
37
|
+
<%= I18n.t("passkey_auth.magic_link_mailer.login_link.ignore") %>
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 32px 0;">
|
|
41
|
+
|
|
42
|
+
<p style="color: #9a9a9a; font-size: 12px; line-height: 18px; margin: 0;">
|
|
43
|
+
<%= I18n.t("passkey_auth.magic_link_mailer.login_link.security_notice") %>
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<div class="passkey-auth-container">
|
|
2
|
+
<h1><%= I18n.t("passkey_auth.magic_links.new.title") %></h1>
|
|
3
|
+
<p><%= I18n.t("passkey_auth.magic_links.new.description") %></p>
|
|
4
|
+
|
|
5
|
+
<%= form_with url: magic_links_path do |form| %>
|
|
6
|
+
<div class="form-group">
|
|
7
|
+
<%= form.label :email %>
|
|
8
|
+
<%= form.email_field :email,
|
|
9
|
+
required: true,
|
|
10
|
+
autofocus: true,
|
|
11
|
+
placeholder: I18n.t("passkey_auth.magic_links.new.email_placeholder"),
|
|
12
|
+
value: @email || params[:email] %>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<%= form.submit I18n.t("passkey_auth.magic_links.new.submit_button"), class: "btn btn-primary" %>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<p><%= link_to I18n.t("passkey_auth.magic_links.new.back_to_login"), new_session_path %></p>
|
|
19
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<div class="passkey-auth-container">
|
|
2
|
+
<h1><%= I18n.t("passkey_auth.magic_links.verify_code.title") %></h1>
|
|
3
|
+
<p><%= I18n.t("passkey_auth.magic_links.verify_code.description", email: @email) %></p>
|
|
4
|
+
|
|
5
|
+
<%= form_with url: verify_with_code_magic_links_path do |form| %>
|
|
6
|
+
<%= form.hidden_field :email, value: @email %>
|
|
7
|
+
|
|
8
|
+
<div class="form-group">
|
|
9
|
+
<%= form.label :code, I18n.t("passkey_auth.magic_links.verify_code.code_label") %>
|
|
10
|
+
<%= form.text_field :code,
|
|
11
|
+
required: true,
|
|
12
|
+
autofocus: true,
|
|
13
|
+
autocomplete: "one-time-code",
|
|
14
|
+
placeholder: "XXXX-XXXX" %>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<%= form.submit I18n.t("passkey_auth.magic_links.verify_code.submit_button"), class: "btn btn-primary" %>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<p><%= I18n.t("passkey_auth.magic_links.verify_code.cant_find_code_html", request_new_url: new_magic_link_path(email: @email)) %></p>
|
|
21
|
+
</div>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
en:
|
|
2
|
+
passkey_auth:
|
|
3
|
+
sessions:
|
|
4
|
+
new:
|
|
5
|
+
title: "Sign In"
|
|
6
|
+
email_placeholder: "Enter your email"
|
|
7
|
+
submit: "Continue with Email"
|
|
8
|
+
invalid_credentials: "Invalid credentials"
|
|
9
|
+
|
|
10
|
+
magic_links:
|
|
11
|
+
new:
|
|
12
|
+
title: "Sign In with Magic Link"
|
|
13
|
+
description: "Enter your email to receive a sign-in link"
|
|
14
|
+
email_placeholder: "your@email.com"
|
|
15
|
+
submit_button: "Send Magic Link"
|
|
16
|
+
back_to_login: "Back to sign in"
|
|
17
|
+
verify_code:
|
|
18
|
+
title: "Enter Your Code"
|
|
19
|
+
description: "We sent a code to %{email}"
|
|
20
|
+
code_label: "Verification Code"
|
|
21
|
+
submit_button: "Verify Code"
|
|
22
|
+
cant_find_code_html: "Can't find the code? <a href='%{request_new_url}'>Request a new one</a>"
|
|
23
|
+
success: "Successfully signed in"
|
|
24
|
+
success_with_passkey_prompt: "Successfully signed in. Consider setting up a passkey for faster sign-in next time."
|
|
25
|
+
errors:
|
|
26
|
+
rate_limited: "Too many requests. Please wait a moment before trying again."
|
|
27
|
+
user_not_found: "No account found with that email address."
|
|
28
|
+
send_failed: "Failed to send magic link. Please try again."
|
|
29
|
+
invalid_code: "Invalid or expired code"
|
|
30
|
+
expired: "This link has expired. Please request a new one."
|
|
31
|
+
already_used: "This link has already been used"
|
|
32
|
+
generic: "Something went wrong. Please try again."
|
|
33
|
+
|
|
34
|
+
magic_link_mailer:
|
|
35
|
+
login_link:
|
|
36
|
+
subject: "Your login code is %{code}"
|
|
37
|
+
title: "Sign In"
|
|
38
|
+
greeting: "Hi %{name},"
|
|
39
|
+
body: "You requested a sign-in link. Click the button below to sign in:"
|
|
40
|
+
button: "Sign In"
|
|
41
|
+
code_prompt: "Or enter this code if prompted:"
|
|
42
|
+
copy_link: "Or copy and paste this link into your browser:"
|
|
43
|
+
expires: "This link and code will expire in %{minutes} minutes."
|
|
44
|
+
ignore: "If you didn't request this link, you can safely ignore this email."
|
|
45
|
+
security_notice: "For security reasons, this link can only be used once. If you need another link, please request a new one."
|
|
46
|
+
|
|
47
|
+
webauthn:
|
|
48
|
+
session_expired: "Session expired. Please refresh the page and try again."
|
|
49
|
+
verification_failed: "Passkey verification failed"
|
|
50
|
+
authentication_failed: "Passkey verification failed. Please try signing in with your email."
|
|
51
|
+
passkey_not_registered: "This passkey is not registered. Please sign in with your email and register this passkey."
|
|
52
|
+
unexpected_error: "An unexpected error occurred. Please try signing in with your email."
|
|
53
|
+
credential_deleted: "Passkey deleted successfully"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
PasskeyAuth::Engine.routes.draw do
|
|
4
|
+
resource :session, only: %i[new show create destroy]
|
|
5
|
+
|
|
6
|
+
resources :magic_links, only: %i[new create] do
|
|
7
|
+
collection do
|
|
8
|
+
get :verify_code
|
|
9
|
+
post :verify_with_code
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
get "magic_links/:token", to: "magic_links#show", as: :verify_magic_link
|
|
14
|
+
|
|
15
|
+
namespace :webauthn do
|
|
16
|
+
resources :credentials, only: %i[index create destroy] do
|
|
17
|
+
collection do
|
|
18
|
+
resource :challenge, only: %i[create], module: :credentials, as: :credentials_challenge
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
resources :authentications, only: %i[index create] do
|
|
23
|
+
collection do
|
|
24
|
+
resource :challenge, only: %i[create], module: :authentications, as: :authentications_challenge
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module PasskeyAuth
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Installs PasskeyAuth and generates necessary migrations and initializer"
|
|
14
|
+
|
|
15
|
+
def copy_migrations
|
|
16
|
+
migration_template "create_passkey_auth_magic_links.rb.erb",
|
|
17
|
+
"db/migrate/create_passkey_auth_magic_links.rb",
|
|
18
|
+
migration_version: migration_version
|
|
19
|
+
migration_template "create_passkey_auth_webauthn_credentials.rb.erb",
|
|
20
|
+
"db/migrate/create_passkey_auth_webauthn_credentials.rb",
|
|
21
|
+
migration_version: migration_version
|
|
22
|
+
migration_template "add_passwordless_to_users.rb.erb",
|
|
23
|
+
"db/migrate/add_passwordless_to_users.rb",
|
|
24
|
+
migration_version: migration_version
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_initializer
|
|
28
|
+
template "initializer.rb", "config/initializers/passkey_auth.rb"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def add_routes
|
|
32
|
+
route 'mount PasskeyAuth::Engine => "/auth"'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def copy_javascript_files
|
|
36
|
+
# Copy JavaScript controllers from the gem's app/javascript directory
|
|
37
|
+
source_paths << File.expand_path("../../../../app/javascript", __dir__)
|
|
38
|
+
directory "passkey_auth", "app/javascript/passkey_auth"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def install_javascript_dependencies
|
|
42
|
+
say "\nInstalling JavaScript dependencies...", :yellow
|
|
43
|
+
|
|
44
|
+
if File.exist?("config/importmap.rb")
|
|
45
|
+
append_to_file "config/importmap.rb" do
|
|
46
|
+
<<~RUBY
|
|
47
|
+
|
|
48
|
+
# PasskeyAuth dependencies
|
|
49
|
+
pin "@github/webauthn-json", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.js"
|
|
50
|
+
pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.9/src/index.js"
|
|
51
|
+
pin "passkey_auth/controllers", to: "passkey_auth/controllers/index.js"
|
|
52
|
+
pin "passkey_auth/controllers/passkey_authentication_controller"
|
|
53
|
+
pin "passkey_auth/controllers/webauthn_register_controller"
|
|
54
|
+
pin "passkey_auth/controllers/webauthn_auth_controller"
|
|
55
|
+
pin "passkey_auth/lib/passkey"
|
|
56
|
+
RUBY
|
|
57
|
+
end
|
|
58
|
+
say "Added importmap pins for PasskeyAuth", :green
|
|
59
|
+
else
|
|
60
|
+
say "\nWARNING: config/importmap.rb not found.", :yellow
|
|
61
|
+
say "If you're using a different JavaScript bundler (esbuild, webpack, etc.),"
|
|
62
|
+
say "make sure to add @github/webauthn-json as a dependency:", :yellow
|
|
63
|
+
say "\n npm install @github/webauthn-json\n", :cyan
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def display_post_install_message
|
|
68
|
+
say "\n"
|
|
69
|
+
say "PasskeyAuth installed successfully!", :green
|
|
70
|
+
say "\n"
|
|
71
|
+
say "Next steps:", :yellow
|
|
72
|
+
say " 1. Ensure you have Rails authentication installed (rails generate authentication)"
|
|
73
|
+
say " 2. Add 'include PasskeyAuth::Concerns::Passwordless' to your User model"
|
|
74
|
+
say " 3. Run 'rails db:migrate' to create the necessary tables"
|
|
75
|
+
say " 4. Configure the initializer at config/initializers/passkey_auth.rb"
|
|
76
|
+
say " 5. Register Stimulus controllers in app/javascript/controllers/application.js:"
|
|
77
|
+
say ""
|
|
78
|
+
say " import { PasskeyAuthenticationController, WebauthnRegisterController, WebauthnAuthController }", :cyan
|
|
79
|
+
say " from 'passkey_auth/controllers'", :cyan
|
|
80
|
+
say " application.register('passkey-authentication', PasskeyAuthenticationController)", :cyan
|
|
81
|
+
say " application.register('webauthn-register', WebauthnRegisterController)", :cyan
|
|
82
|
+
say " application.register('webauthn-auth', WebauthnAuthController)", :cyan
|
|
83
|
+
say "\n"
|
|
84
|
+
say "PasskeyAuth works alongside Rails' authentication system.", :yellow
|
|
85
|
+
say "Users can authenticate with passwords (Rails) OR passwordless methods (this gem)."
|
|
86
|
+
say "\n"
|
|
87
|
+
say "See the README for more details: https://github.com/jhubert/passkey_auth"
|
|
88
|
+
say "\n"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def migration_version
|
|
94
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class CreatePasskeyAuthMagicLinks < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :passkey_auth_magic_links do |t|
|
|
4
|
+
t.references :user, null: false, foreign_key: true
|
|
5
|
+
t.string :token, null: false
|
|
6
|
+
t.string :short_code, null: false
|
|
7
|
+
t.datetime :expires_at, null: false
|
|
8
|
+
t.datetime :used_at
|
|
9
|
+
t.string :ip_address
|
|
10
|
+
t.string :user_agent
|
|
11
|
+
t.string :purpose, default: "login", null: false
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :passkey_auth_magic_links, :token, unique: true
|
|
17
|
+
add_index :passkey_auth_magic_links, :short_code
|
|
18
|
+
add_index :passkey_auth_magic_links, [:user_id, :expires_at]
|
|
19
|
+
add_index :passkey_auth_magic_links, :expires_at
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/generators/passkey_auth/install/templates/create_passkey_auth_webauthn_credentials.rb.erb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreatePasskeyAuthWebauthnCredentials < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :passkey_auth_webauthn_credentials do |t|
|
|
4
|
+
t.references :user, null: false, foreign_key: true
|
|
5
|
+
t.string :external_id, null: false
|
|
6
|
+
t.string :public_key, null: false
|
|
7
|
+
t.string :nickname, null: false
|
|
8
|
+
t.integer :sign_count, null: false, default: 0
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :passkey_auth_webauthn_credentials, :external_id, unique: true
|
|
14
|
+
add_index :passkey_auth_webauthn_credentials, [:nickname, :user_id], unique: true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
PasskeyAuth.setup do |config|
|
|
4
|
+
# The name of your User model (default: "User")
|
|
5
|
+
# config.user_model_name = "User"
|
|
6
|
+
|
|
7
|
+
# The email attribute on your User model (default: :email_address)
|
|
8
|
+
# Rails 8 uses :email_address by default
|
|
9
|
+
# If your User model uses :email, set this to :email
|
|
10
|
+
# Or use alias_attribute in your User model: alias_attribute :email, :email_address
|
|
11
|
+
# config.user_email_attribute = :email_address
|
|
12
|
+
|
|
13
|
+
# How long magic links are valid (default: 10.minutes)
|
|
14
|
+
# config.magic_link_expiration = 10.minutes
|
|
15
|
+
|
|
16
|
+
# Rate limit for magic link requests (default: 1.minute)
|
|
17
|
+
# config.magic_link_rate_limit = 1.minute
|
|
18
|
+
|
|
19
|
+
# WebAuthn configuration
|
|
20
|
+
# config.webauthn_origin = ENV.fetch("WEBAUTHN_ORIGIN", "http://localhost:3000")
|
|
21
|
+
# config.webauthn_rp_name = "Your App Name"
|
|
22
|
+
|
|
23
|
+
# Hooks - add custom behavior to passwordless authentication events
|
|
24
|
+
# config.on_magic_link_requested = ->(user, magic_link) { puts "Magic link requested" }
|
|
25
|
+
# config.on_passkey_created = ->(user, credential) { puts "Passkey created" }
|
|
26
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PasskeyAuth
|
|
4
|
+
module Concerns
|
|
5
|
+
module Passwordless
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class AuthenticationError < StandardError; end
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
has_many :passkey_auth_magic_links,
|
|
12
|
+
class_name: "PasskeyAuth::MagicLink",
|
|
13
|
+
foreign_key: :user_id,
|
|
14
|
+
dependent: :delete_all
|
|
15
|
+
|
|
16
|
+
has_many :passkey_auth_webauthn_credentials,
|
|
17
|
+
class_name: "PasskeyAuth::WebauthnCredential",
|
|
18
|
+
foreign_key: :user_id,
|
|
19
|
+
dependent: :delete_all
|
|
20
|
+
|
|
21
|
+
# Friendly aliases
|
|
22
|
+
alias_method :magic_links, :passkey_auth_magic_links
|
|
23
|
+
alias_method :webauthn_credentials, :passkey_auth_webauthn_credentials
|
|
24
|
+
|
|
25
|
+
validates :webauthn_id, uniqueness: true, allow_blank: true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Magic link authentication methods
|
|
29
|
+
def can_request_magic_link?
|
|
30
|
+
return true if magic_link_sent_at.nil?
|
|
31
|
+
|
|
32
|
+
# Rate limit based on configuration
|
|
33
|
+
magic_link_sent_at < PasskeyAuth.magic_link_rate_limit.ago
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def create_magic_link!(purpose: "login")
|
|
37
|
+
return nil unless can_request_magic_link?
|
|
38
|
+
|
|
39
|
+
transaction do
|
|
40
|
+
# Expire any existing active magic links
|
|
41
|
+
magic_links.active.update_all(used_at: Time.current)
|
|
42
|
+
|
|
43
|
+
# Create new magic link
|
|
44
|
+
magic_link = magic_links.create!(purpose: purpose)
|
|
45
|
+
|
|
46
|
+
# Update rate limiting timestamp
|
|
47
|
+
update!(magic_link_sent_at: Time.current)
|
|
48
|
+
|
|
49
|
+
magic_link
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def send_magic_link!
|
|
54
|
+
unless can_request_magic_link?
|
|
55
|
+
return { success: false, error: "rate_limited" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
magic_link = create_magic_link!
|
|
59
|
+
|
|
60
|
+
if magic_link
|
|
61
|
+
PasskeyAuth::MagicLinkMailer.login_link(magic_link).deliver_later
|
|
62
|
+
|
|
63
|
+
# Call hook if configured
|
|
64
|
+
PasskeyAuth.on_magic_link_requested&.call(self, magic_link)
|
|
65
|
+
|
|
66
|
+
{ success: true, magic_link: magic_link }
|
|
67
|
+
else
|
|
68
|
+
{ success: false, error: "creation_failed" }
|
|
69
|
+
end
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
Rails.logger.error "MagicLink error: #{e.message}"
|
|
72
|
+
{ success: false, error: "unexpected_error" }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class_methods do
|
|
76
|
+
def authenticate_with_webauthn(credential, challenge)
|
|
77
|
+
# Find the stored credential by external_id
|
|
78
|
+
stored_credential = PasskeyAuth::WebauthnCredential.find_by(external_id: credential.id)
|
|
79
|
+
|
|
80
|
+
raise AuthenticationError, "Passkey not registered" unless stored_credential
|
|
81
|
+
|
|
82
|
+
# Verify the credential against the stored_credential
|
|
83
|
+
credential.verify(
|
|
84
|
+
challenge,
|
|
85
|
+
public_key: stored_credential.public_key,
|
|
86
|
+
sign_count: stored_credential.sign_count
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Update the sign count to prevent replay attacks
|
|
90
|
+
stored_credential.update!(sign_count: credential.sign_count)
|
|
91
|
+
|
|
92
|
+
# Return the user
|
|
93
|
+
stored_credential.user
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def passwordless?
|
|
98
|
+
webauthn_credentials.exists? || magic_links.active.exists?
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
require "webauthn"
|
|
5
|
+
|
|
6
|
+
module PasskeyAuth
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace PasskeyAuth
|
|
9
|
+
|
|
10
|
+
config.generators do |g|
|
|
11
|
+
g.test_framework :minitest
|
|
12
|
+
g.fixture_replacement :minitest
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer "passkey_auth.set_configs" do
|
|
16
|
+
# Set WebAuthn configuration based on Rails environment
|
|
17
|
+
PasskeyAuth.webauthn_origin ||= begin
|
|
18
|
+
if Rails.env.production?
|
|
19
|
+
ENV.fetch("WEBAUTHN_ORIGIN", "https://example.com")
|
|
20
|
+
else
|
|
21
|
+
"http://localhost:3000"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Configure WebAuthn gem
|
|
26
|
+
WebAuthn.configure do |config|
|
|
27
|
+
config.origin = PasskeyAuth.webauthn_origin
|
|
28
|
+
config.rp_name = PasskeyAuth.webauthn_rp_name
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/passkey_auth.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "passkey_auth/version"
|
|
4
|
+
require_relative "passkey_auth/engine"
|
|
5
|
+
require_relative "passkey_auth/concerns/passwordless"
|
|
6
|
+
|
|
7
|
+
module PasskeyAuth
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Configuration
|
|
11
|
+
mattr_accessor :user_model_name, default: "User"
|
|
12
|
+
mattr_accessor :user_email_attribute, default: :email_address # Rails 8 default
|
|
13
|
+
mattr_accessor :magic_link_expiration, default: 10.minutes
|
|
14
|
+
mattr_accessor :magic_link_rate_limit, default: 1.minute
|
|
15
|
+
mattr_accessor :webauthn_origin
|
|
16
|
+
mattr_accessor :webauthn_rp_name, default: "PasskeyAuth"
|
|
17
|
+
|
|
18
|
+
# Hooks - allow apps to add custom behavior
|
|
19
|
+
mattr_accessor :on_magic_link_requested, default: nil
|
|
20
|
+
mattr_accessor :on_passkey_created, default: nil
|
|
21
|
+
|
|
22
|
+
def self.setup
|
|
23
|
+
yield self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.user_class
|
|
27
|
+
user_model_name.constantize
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Helper to get the email attribute value from a user
|
|
31
|
+
def self.user_email(user)
|
|
32
|
+
user.public_send(user_email_attribute)
|
|
33
|
+
end
|
|
34
|
+
end
|