dlnk_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/MIT-LICENSE +21 -0
- data/README.md +197 -0
- data/Rakefile +15 -0
- data/app/controllers/concerns/dlnk_auth/authentication/via_magic_link.rb +67 -0
- data/app/controllers/concerns/dlnk_auth/authentication.rb +92 -0
- data/app/controllers/concerns/dlnk_auth/impersonation.rb +47 -0
- data/app/controllers/dlnk_auth/application_controller.rb +7 -0
- data/app/controllers/dlnk_auth/impersonations_controller.rb +25 -0
- data/app/controllers/dlnk_auth/magic_links_controller.rb +63 -0
- data/app/controllers/dlnk_auth/sessions_controller.rb +41 -0
- data/app/mailers/dlnk_auth/application_mailer.rb +8 -0
- data/app/mailers/dlnk_auth/magic_link_mailer.rb +15 -0
- data/app/models/concerns/dlnk_auth/authenticatable.rb +25 -0
- data/app/models/dlnk_auth/application_record.rb +7 -0
- data/app/models/dlnk_auth/current.rb +7 -0
- data/app/models/dlnk_auth/magic_link.rb +55 -0
- data/app/models/dlnk_auth/session.rb +16 -0
- data/app/views/dlnk_auth/magic_link_mailer/sign_in_instructions.html.erb +7 -0
- data/app/views/dlnk_auth/magic_link_mailer/sign_in_instructions.text.erb +7 -0
- data/app/views/dlnk_auth/magic_links/show.html.erb +20 -0
- data/app/views/dlnk_auth/sessions/new.html.erb +10 -0
- data/app/views/layouts/dlnk_auth/application.html.erb +22 -0
- data/app/views/layouts/mailer.html.erb +12 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20260215000001_create_dlnk_auth_sessions.rb +10 -0
- data/db/migrate/20260215000002_create_dlnk_auth_magic_links.rb +13 -0
- data/db/migrate/20260215000005_add_expires_at_index_to_dlnk_auth_magic_links.rb +5 -0
- data/lib/dlnk_auth/code.rb +24 -0
- data/lib/dlnk_auth/configuration.rb +75 -0
- data/lib/dlnk_auth/engine.rb +23 -0
- data/lib/dlnk_auth/middleware/tenant_resolver.rb +65 -0
- data/lib/dlnk_auth/version.rb +5 -0
- data/lib/dlnk_auth.rb +23 -0
- data/lib/generators/dlnk_auth/install/install_generator.rb +33 -0
- data/lib/generators/dlnk_auth/install/templates/initializer.rb.tt +26 -0
- data/lib/generators/dlnk_auth/views/views_generator.rb +20 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c0d19743950394185b34f027606b621b9ed289ec80d37ff3086ad761c8869f3c
|
|
4
|
+
data.tar.gz: d3913fffd2763cc6aa0b79ce9a324d3433214a14541503317c99eb01052002ad
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 11bdc33693539a8b731ee7d1969178003e0ca63caf6cf04e6c837e1ff50a6b68e56bbf9fe6cd2c89f255ab605dd4439ab073975a6845a4209da567a5f3802a1c
|
|
7
|
+
data.tar.gz: 7659d49342261e67ca7074dfbbc19fc5344fe3f24874c0a86bcbcd36bf42517c91c2da3368e08aabefe0a457538dfac70dd0af663198ec78bde0686c58b706bc
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DALiNK
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# DlnkAuth
|
|
2
|
+
|
|
3
|
+
Passwordless magic link authentication engine for Rails.
|
|
4
|
+
|
|
5
|
+
DlnkAuth provides a complete sign-in flow using one-time codes and magic links, with opt-in modules for impersonation and multi-tenancy. It is designed as a Rails Engine that mounts into your application.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add the gem to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "dlnk_auth"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Run the install generator:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle install
|
|
19
|
+
rails generate dlnk_auth:install
|
|
20
|
+
rails db:migrate
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The generator will:
|
|
24
|
+
|
|
25
|
+
1. Copy an initializer to `config/initializers/dlnk_auth.rb`
|
|
26
|
+
2. Copy migrations for sessions and magic links
|
|
27
|
+
3. Add the engine route to `config/routes.rb`
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
### User model
|
|
32
|
+
|
|
33
|
+
Include the `Authenticatable` concern in your User model:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
class User < ApplicationRecord
|
|
37
|
+
include DlnkAuth::Authenticatable
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This adds:
|
|
42
|
+
|
|
43
|
+
- `has_many :dlnk_auth_sessions` and `has_many :dlnk_auth_magic_links`
|
|
44
|
+
- Email normalization (strip + downcase)
|
|
45
|
+
- Email presence and format validation
|
|
46
|
+
- `#send_magic_link` instance method
|
|
47
|
+
|
|
48
|
+
### Application controller
|
|
49
|
+
|
|
50
|
+
Include the `Authentication` concern in your ApplicationController:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
class ApplicationController < ActionController::Base
|
|
54
|
+
include DlnkAuth::Authentication
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This provides:
|
|
59
|
+
|
|
60
|
+
- `before_action :require_authentication` (applied by default)
|
|
61
|
+
- `authenticated?` and `current_user` helper methods
|
|
62
|
+
- `allow_unauthenticated_access` and `require_unauthenticated_access` class methods
|
|
63
|
+
- Session management via signed cookies
|
|
64
|
+
|
|
65
|
+
For controllers that should be accessible without sign-in:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class PublicController < ApplicationController
|
|
69
|
+
allow_unauthenticated_access
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Edit `config/initializers/dlnk_auth.rb`:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
DlnkAuth.configure do |c|
|
|
79
|
+
# The user model class name
|
|
80
|
+
c.user_class = "User"
|
|
81
|
+
|
|
82
|
+
# Attribute used for email lookup
|
|
83
|
+
c.email_attribute = :email
|
|
84
|
+
|
|
85
|
+
# Length of one-time codes (Base32 Crockford)
|
|
86
|
+
c.code_length = 6
|
|
87
|
+
|
|
88
|
+
# How long magic links remain valid
|
|
89
|
+
c.magic_link_ttl = 15.minutes
|
|
90
|
+
|
|
91
|
+
# How long sessions last
|
|
92
|
+
c.session_ttl = 2.weeks
|
|
93
|
+
|
|
94
|
+
# Redirect paths after sign in / sign out
|
|
95
|
+
c.after_sign_in_path = :root_path
|
|
96
|
+
c.after_sign_out_path = :root_path
|
|
97
|
+
|
|
98
|
+
# From address for magic link emails
|
|
99
|
+
c.mailer_sender = "noreply@example.com"
|
|
100
|
+
|
|
101
|
+
# Rate limiting for session creation attempts
|
|
102
|
+
c.rate_limit_sessions = { to: 10, within: 3.minutes }
|
|
103
|
+
|
|
104
|
+
# Rate limiting for code verification attempts
|
|
105
|
+
c.rate_limit_codes = { to: 10, within: 15.minutes }
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Routes
|
|
110
|
+
|
|
111
|
+
The engine mounts at `/auth` by default and provides:
|
|
112
|
+
|
|
113
|
+
| Method | Path | Description |
|
|
114
|
+
|----------|-------------------|--------------------------------|
|
|
115
|
+
| `GET` | `/auth/session/new` | Sign-in form (enter email) |
|
|
116
|
+
| `POST` | `/auth/session` | Create session (submit email) |
|
|
117
|
+
| `DELETE` | `/auth/session` | Sign out |
|
|
118
|
+
| `GET` | `/auth/magic_link` | Verify magic link token |
|
|
119
|
+
| `POST` | `/auth/magic_link` | Verify one-time code |
|
|
120
|
+
|
|
121
|
+
## Impersonation (opt-in)
|
|
122
|
+
|
|
123
|
+
Enable impersonation in the initializer:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
DlnkAuth.configure do |c|
|
|
127
|
+
c.impersonation.enabled = true
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
When enabled, the `DlnkAuth::Impersonation` concern is automatically included in `ActionController::Base`. It provides:
|
|
132
|
+
|
|
133
|
+
- `impersonate_user(user)` -- start impersonating
|
|
134
|
+
- `stop_impersonating` -- return to your real account
|
|
135
|
+
- `true_user` -- the originally signed-in user
|
|
136
|
+
- `impersonating?` -- whether impersonation is active
|
|
137
|
+
|
|
138
|
+
The engine exposes two impersonation routes:
|
|
139
|
+
|
|
140
|
+
| Method | Path | Description |
|
|
141
|
+
|----------|-------------------------------|----------------------|
|
|
142
|
+
| `POST` | `/auth/impersonation/:user_id` | Start impersonating |
|
|
143
|
+
| `DELETE` | `/auth/impersonation` | Stop impersonating |
|
|
144
|
+
|
|
145
|
+
The impersonation controller requires the current user to respond to `superadmin?` returning `true`. Add this method to your User model:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
class User < ApplicationRecord
|
|
149
|
+
include DlnkAuth::Authenticatable
|
|
150
|
+
|
|
151
|
+
def superadmin?
|
|
152
|
+
# your logic here
|
|
153
|
+
role == "superadmin"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Multi-tenancy (opt-in)
|
|
159
|
+
|
|
160
|
+
Enable multi-tenancy in the initializer:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
DlnkAuth.configure do |c|
|
|
164
|
+
c.tenanting.enabled = true
|
|
165
|
+
c.tenanting.tenant_class = "Tenant"
|
|
166
|
+
c.tenanting.slug_attribute = :slug
|
|
167
|
+
c.tenanting.skip_paths = %w[/auth /assets /rails /up /cable]
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
When enabled, the `DlnkAuth::Middleware::TenantResolver` Rack middleware is inserted automatically. It resolves the tenant from the first URL segment (e.g., `/acme/dashboard` resolves tenant with slug `acme`) and sets `DlnkAuth::Current.tenant`.
|
|
172
|
+
|
|
173
|
+
Paths listed in `skip_paths` bypass tenant resolution.
|
|
174
|
+
|
|
175
|
+
You can also provide a custom resolver:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
c.tenanting.tenant_resolver = ->(request) {
|
|
179
|
+
if request.path_info =~ %r{\A/([^/]+)}
|
|
180
|
+
Tenant.find_by(subdomain: $1)
|
|
181
|
+
end
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## View Customization
|
|
186
|
+
|
|
187
|
+
Copy the engine views into your application for customization:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
rails generate dlnk_auth:views
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
This copies all views to `app/views/dlnk_auth/` where you can modify them freely.
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
Released under the [MIT License](MIT-LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
require "bundler/gem_tasks"
|
|
3
|
+
require "rake/testtask"
|
|
4
|
+
|
|
5
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
6
|
+
load "rails/tasks/engine.rake"
|
|
7
|
+
load "rails/tasks/statistics.rake"
|
|
8
|
+
|
|
9
|
+
Rake::TestTask.new(:test) do |t|
|
|
10
|
+
t.libs << "test"
|
|
11
|
+
t.pattern = "test/**/*_test.rb"
|
|
12
|
+
t.verbose = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
task default: :test
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
module Authentication
|
|
5
|
+
module ViaMagicLink
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
helper_method :email_address_pending_authentication
|
|
10
|
+
after_action :ensure_development_magic_link_not_leaked
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
def ensure_development_magic_link_not_leaked
|
|
15
|
+
unless Rails.env.development? || Rails.env.test?
|
|
16
|
+
raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def redirect_to_magic_link_verification(magic_link)
|
|
21
|
+
serve_development_magic_link(magic_link)
|
|
22
|
+
set_pending_authentication_token(magic_link)
|
|
23
|
+
redirect_to dlnk_auth.magic_link_path, notice: I18n.t("dlnk_auth.magic_links.code_sent")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def redirect_to_fake_magic_link_verification(email)
|
|
27
|
+
fake = DlnkAuth::MagicLink.new(
|
|
28
|
+
user: user_class.new(DlnkAuth.configuration.email_attribute => email),
|
|
29
|
+
code: DlnkAuth::Code.generate(DlnkAuth.configuration.code_length),
|
|
30
|
+
expires_at: DlnkAuth.configuration.magic_link_ttl.from_now
|
|
31
|
+
)
|
|
32
|
+
redirect_to_magic_link_verification(fake)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def serve_development_magic_link(magic_link)
|
|
36
|
+
if Rails.env.development? && magic_link.present?
|
|
37
|
+
flash[:magic_link_code] = magic_link.code
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def set_pending_authentication_token(magic_link)
|
|
42
|
+
email = magic_link.user.public_send(DlnkAuth.configuration.email_attribute)
|
|
43
|
+
cookies[:pending_authentication_token] = {
|
|
44
|
+
value: pending_authentication_token_verifier.generate(email, expires_at: magic_link.expires_at),
|
|
45
|
+
httponly: true, same_site: :lax, expires: magic_link.expires_at
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def email_address_pending_authentication
|
|
50
|
+
return @_email_pending_auth if defined?(@_email_pending_auth)
|
|
51
|
+
@_email_pending_auth = pending_authentication_token_verifier.verified(pending_authentication_token)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pending_authentication_token_verifier
|
|
55
|
+
Rails.application.message_verifier(:pending_authentication)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def pending_authentication_token
|
|
59
|
+
cookies[:pending_authentication_token]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear_pending_authentication_token
|
|
63
|
+
cookies.delete(:pending_authentication_token)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
module Authentication
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
before_action :require_authentication
|
|
9
|
+
helper_method :authenticated?, :current_user, :current_tenant
|
|
10
|
+
include DlnkAuth::Authentication::ViaMagicLink
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class_methods do
|
|
14
|
+
def require_unauthenticated_access(**options)
|
|
15
|
+
allow_unauthenticated_access(**options)
|
|
16
|
+
before_action :redirect_authenticated_user, **options
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def allow_unauthenticated_access(**options)
|
|
20
|
+
skip_before_action :require_authentication, **options
|
|
21
|
+
before_action :resume_session, **options
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
def authenticated?
|
|
27
|
+
DlnkAuth::Current.session.present?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def current_user
|
|
31
|
+
DlnkAuth::Current.user
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def current_tenant
|
|
35
|
+
DlnkAuth::Current.tenant
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def require_authentication
|
|
39
|
+
resume_session || request_authentication
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resume_session
|
|
43
|
+
if (session_record = find_session_by_cookie)
|
|
44
|
+
set_current_session(session_record)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def find_session_by_cookie
|
|
49
|
+
DlnkAuth::Session.find_signed(cookies[:session_token])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def request_authentication
|
|
53
|
+
session[:return_to_after_authenticating] = request.fullpath
|
|
54
|
+
redirect_to dlnk_auth.new_session_path
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def after_authentication_url
|
|
58
|
+
url = session.delete(:return_to_after_authenticating)
|
|
59
|
+
if url.present? && url.start_with?("/") && !url.start_with?("//")
|
|
60
|
+
url
|
|
61
|
+
else
|
|
62
|
+
main_app.send(DlnkAuth.configuration.after_sign_in_path)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def redirect_authenticated_user
|
|
67
|
+
redirect_to after_authentication_url if authenticated?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def start_new_session_for(user)
|
|
71
|
+
user.dlnk_auth_sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session_record|
|
|
72
|
+
set_current_session(session_record)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def set_current_session(session_record)
|
|
77
|
+
DlnkAuth::Current.session = session_record
|
|
78
|
+
DlnkAuth::Current.user = session_record.user
|
|
79
|
+
ttl = DlnkAuth.configuration.session_ttl
|
|
80
|
+
cookies[:session_token] = { value: session_record.signed_id(expires_in: ttl), httponly: true, same_site: :lax, expires: ttl&.from_now }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def terminate_session
|
|
84
|
+
DlnkAuth::Current.session&.destroy
|
|
85
|
+
cookies.delete(:session_token)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def user_class
|
|
89
|
+
DlnkAuth.configuration.user_class.constantize
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
module Impersonation
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
helper_method :true_user, :impersonating?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
def impersonate_user(user)
|
|
13
|
+
session[:dlnk_auth_impersonated_user_id] = user.id
|
|
14
|
+
DlnkAuth::Current.user = user
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def stop_impersonating
|
|
18
|
+
session.delete(:dlnk_auth_impersonated_user_id)
|
|
19
|
+
DlnkAuth::Current.user = true_user
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def true_user
|
|
23
|
+
if session[:dlnk_auth_impersonated_user_id].present?
|
|
24
|
+
@_true_user ||= DlnkAuth::Current.session&.user
|
|
25
|
+
else
|
|
26
|
+
current_user
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def impersonating?
|
|
31
|
+
session[:dlnk_auth_impersonated_user_id].present? && true_user != current_user
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def current_user
|
|
35
|
+
if session[:dlnk_auth_impersonated_user_id].present?
|
|
36
|
+
@_impersonated_user ||= user_class.find_by(id: session[:dlnk_auth_impersonated_user_id])
|
|
37
|
+
else
|
|
38
|
+
DlnkAuth::Current.user
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def require_superadmin
|
|
43
|
+
authorizer = DlnkAuth.configuration.impersonation.authorize_with
|
|
44
|
+
head :forbidden unless true_user && authorizer.call(true_user)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
class ImpersonationsController < ApplicationController
|
|
5
|
+
include DlnkAuth::Impersonation
|
|
6
|
+
before_action :require_impersonation_enabled
|
|
7
|
+
before_action :require_superadmin, only: :create
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
user = user_class.find(params[:user_id])
|
|
11
|
+
impersonate_user(user)
|
|
12
|
+
redirect_back fallback_location: main_app.root_path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def destroy
|
|
16
|
+
stop_impersonating
|
|
17
|
+
redirect_back fallback_location: main_app.root_path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
def require_impersonation_enabled
|
|
22
|
+
head :not_found unless DlnkAuth.configuration.impersonation.enabled
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
class MagicLinksController < ApplicationController
|
|
5
|
+
require_unauthenticated_access
|
|
6
|
+
|
|
7
|
+
# Rate limit per IP (default) — prevents single-source brute force
|
|
8
|
+
rate_limit to: DlnkAuth.configuration.rate_limit_codes[:to],
|
|
9
|
+
within: DlnkAuth.configuration.rate_limit_codes[:within],
|
|
10
|
+
only: :create,
|
|
11
|
+
name: "code-verification-per-ip",
|
|
12
|
+
with: :rate_limit_exceeded
|
|
13
|
+
|
|
14
|
+
# Rate limit per email — prevents distributed brute force across multiple IPs
|
|
15
|
+
rate_limit to: DlnkAuth.configuration.rate_limit_codes[:to],
|
|
16
|
+
within: DlnkAuth.configuration.rate_limit_codes[:within],
|
|
17
|
+
by: -> { email_address_pending_authentication || request.remote_ip },
|
|
18
|
+
only: :create,
|
|
19
|
+
name: "code-verification-per-email",
|
|
20
|
+
with: :rate_limit_exceeded
|
|
21
|
+
|
|
22
|
+
before_action :ensure_pending_authentication
|
|
23
|
+
|
|
24
|
+
def show
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create
|
|
28
|
+
pending_email = email_address_pending_authentication
|
|
29
|
+
if pending_email.present? && (magic_link = DlnkAuth::MagicLink.consume(code, email: pending_email))
|
|
30
|
+
authenticate(magic_link)
|
|
31
|
+
else
|
|
32
|
+
redirect_to magic_link_path, alert: I18n.t("dlnk_auth.magic_links.invalid_code")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
def ensure_pending_authentication
|
|
38
|
+
unless email_address_pending_authentication.present?
|
|
39
|
+
redirect_to new_session_path, alert: I18n.t("dlnk_auth.magic_links.email_first")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def code
|
|
44
|
+
params[:code]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def authenticate(magic_link)
|
|
48
|
+
email_attr = DlnkAuth.configuration.email_attribute
|
|
49
|
+
if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.user.public_send(email_attr))
|
|
50
|
+
clear_pending_authentication_token
|
|
51
|
+
start_new_session_for(magic_link.user)
|
|
52
|
+
redirect_to after_authentication_url, notice: I18n.t("dlnk_auth.magic_links.signed_in")
|
|
53
|
+
else
|
|
54
|
+
clear_pending_authentication_token
|
|
55
|
+
redirect_to new_session_path, alert: I18n.t("dlnk_auth.magic_links.email_mismatch")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def rate_limit_exceeded
|
|
60
|
+
redirect_to magic_link_path, alert: I18n.t("dlnk_auth.magic_links.rate_limited")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
class SessionsController < ApplicationController
|
|
5
|
+
require_unauthenticated_access except: :destroy
|
|
6
|
+
rate_limit to: DlnkAuth.configuration.rate_limit_sessions[:to],
|
|
7
|
+
within: DlnkAuth.configuration.rate_limit_sessions[:within],
|
|
8
|
+
only: :create,
|
|
9
|
+
with: :rate_limit_exceeded
|
|
10
|
+
|
|
11
|
+
def new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
if (user = user_class.find_by(email_attribute => email_address))
|
|
16
|
+
magic_link = user.send_magic_link
|
|
17
|
+
redirect_to_magic_link_verification(magic_link)
|
|
18
|
+
else
|
|
19
|
+
redirect_to_fake_magic_link_verification(email_address)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def destroy
|
|
24
|
+
terminate_session
|
|
25
|
+
redirect_to main_app.send(DlnkAuth.configuration.after_sign_out_path), notice: I18n.t("dlnk_auth.sessions.signed_out")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
def email_address
|
|
30
|
+
params[:email_address]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def email_attribute
|
|
34
|
+
DlnkAuth.configuration.email_attribute
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rate_limit_exceeded
|
|
38
|
+
redirect_to new_session_path, alert: I18n.t("dlnk_auth.sessions.rate_limited")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
class MagicLinkMailer < ApplicationMailer
|
|
5
|
+
def sign_in_instructions(user, email, code)
|
|
6
|
+
@user = user
|
|
7
|
+
@code = code
|
|
8
|
+
|
|
9
|
+
mail(
|
|
10
|
+
to: email,
|
|
11
|
+
subject: I18n.t("dlnk_auth.mailer.sign_in_instructions.subject")
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
module Authenticatable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
has_many :dlnk_auth_sessions, class_name: "DlnkAuth::Session", as: :user, dependent: :destroy
|
|
9
|
+
has_many :dlnk_auth_magic_links, class_name: "DlnkAuth::MagicLink", as: :user, dependent: :destroy
|
|
10
|
+
|
|
11
|
+
normalizes DlnkAuth.configuration.email_attribute, with: ->(email) { email.strip.downcase }
|
|
12
|
+
|
|
13
|
+
validates DlnkAuth.configuration.email_attribute,
|
|
14
|
+
presence: true,
|
|
15
|
+
format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def send_magic_link
|
|
19
|
+
email_value = public_send(DlnkAuth.configuration.email_attribute)
|
|
20
|
+
magic_link = dlnk_auth_magic_links.create!(email: email_value)
|
|
21
|
+
DlnkAuth::MagicLinkMailer.sign_in_instructions(self, email_value, magic_link.code).deliver_later
|
|
22
|
+
magic_link
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module DlnkAuth
|
|
6
|
+
class MagicLink < ApplicationRecord
|
|
7
|
+
attr_accessor :code
|
|
8
|
+
|
|
9
|
+
belongs_to :user, polymorphic: true
|
|
10
|
+
|
|
11
|
+
scope :active, -> { where(expires_at: Time.current...) }
|
|
12
|
+
scope :stale, -> { where(expires_at: ..Time.current) }
|
|
13
|
+
|
|
14
|
+
before_validation :generate_code_digest, on: :create
|
|
15
|
+
before_validation :set_expiration, on: :create
|
|
16
|
+
|
|
17
|
+
validates :code_digest, uniqueness: true, presence: true
|
|
18
|
+
validates :email, presence: true
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def consume(code, email:)
|
|
22
|
+
sanitized = DlnkAuth::Code.sanitize(code)
|
|
23
|
+
return nil unless sanitized.present?
|
|
24
|
+
|
|
25
|
+
digest = Digest::SHA256.hexdigest(sanitized)
|
|
26
|
+
transaction do
|
|
27
|
+
active.where(email: email).lock.includes(:user).find_by(code_digest: digest)&.consume
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cleanup
|
|
32
|
+
stale.delete_all
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def consume
|
|
37
|
+
destroy
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
def generate_code_digest
|
|
43
|
+
self.code ||= loop do
|
|
44
|
+
candidate = DlnkAuth::Code.generate(DlnkAuth.configuration.code_length)
|
|
45
|
+
digest = Digest::SHA256.hexdigest(candidate)
|
|
46
|
+
break candidate unless self.class.exists?(code_digest: digest)
|
|
47
|
+
end
|
|
48
|
+
self.code_digest = Digest::SHA256.hexdigest(self.code)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def set_expiration
|
|
52
|
+
self.expires_at ||= DlnkAuth.configuration.magic_link_ttl.from_now
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
class Session < ApplicationRecord
|
|
5
|
+
belongs_to :user, polymorphic: true
|
|
6
|
+
|
|
7
|
+
scope :stale, -> {
|
|
8
|
+
ttl = DlnkAuth.configuration.session_ttl
|
|
9
|
+
ttl ? where(created_at: ..ttl.ago) : none
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
def self.cleanup
|
|
13
|
+
stale.delete_all
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<h2>Your sign in code</h2>
|
|
2
|
+
<p>Your code is:</p>
|
|
3
|
+
<p style="font-size: 32px; font-weight: bold; letter-spacing: 8px; text-align: center; padding: 16px; background: #f3f4f6; border-radius: 8px; font-family: monospace;">
|
|
4
|
+
<%= @code %>
|
|
5
|
+
</p>
|
|
6
|
+
<p>This code expires in <%= distance_of_time_in_words(DlnkAuth.configuration.magic_link_ttl) %>.</p>
|
|
7
|
+
<p>If you didn't request this, please ignore this email.</p>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<div style="max-width: 400px; margin: 100px auto; padding: 20px;">
|
|
2
|
+
<h1>Enter your code</h1>
|
|
3
|
+
<p>We sent a code to <strong><%= email_address_pending_authentication %></strong></p>
|
|
4
|
+
<% if flash[:magic_link_code] %>
|
|
5
|
+
<div style="margin: 16px 0; padding: 12px; background: #f0f0f0; border-radius: 8px; text-align: center;">
|
|
6
|
+
<small>Dev code:</small><br>
|
|
7
|
+
<strong style="font-size: 24px; letter-spacing: 8px; font-family: monospace;"><%= flash[:magic_link_code] %></strong>
|
|
8
|
+
</div>
|
|
9
|
+
<% end %>
|
|
10
|
+
<%= form_with url: dlnk_auth.magic_link_path, method: :post do |f| %>
|
|
11
|
+
<div style="margin-bottom: 16px;">
|
|
12
|
+
<%= f.label :code, "Code" %><br>
|
|
13
|
+
<%= f.text_field :code, required: true, autofocus: true, autocomplete: "one-time-code", maxlength: DlnkAuth.configuration.code_length, style: "width: 100%; padding: 8px; text-align: center; font-size: 20px; letter-spacing: 8px; font-family: monospace; text-transform: uppercase;" %>
|
|
14
|
+
</div>
|
|
15
|
+
<%= f.submit "Verify", style: "width: 100%; padding: 10px; cursor: pointer;" %>
|
|
16
|
+
<% end %>
|
|
17
|
+
<p style="margin-top: 16px; text-align: center;">
|
|
18
|
+
<%= link_to "Back to email", dlnk_auth.new_session_path %>
|
|
19
|
+
</p>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<div style="max-width: 400px; margin: 100px auto; padding: 20px;">
|
|
2
|
+
<h1>Sign in</h1>
|
|
3
|
+
<%= form_with url: dlnk_auth.session_path, method: :post do |f| %>
|
|
4
|
+
<div style="margin-bottom: 16px;">
|
|
5
|
+
<%= f.label :email_address, "Email address" %><br>
|
|
6
|
+
<%= f.email_field :email_address, required: true, autofocus: true, autocomplete: "email", style: "width: 100%; padding: 8px;" %>
|
|
7
|
+
</div>
|
|
8
|
+
<%= f.submit "Send sign in code", style: "width: 100%; padding: 10px; cursor: pointer;" %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Sign in</title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<% if flash[:alert] %>
|
|
11
|
+
<div style="max-width: 400px; margin: 16px auto; padding: 12px; background: #fee; border: 1px solid #fcc; border-radius: 4px; color: #c00;">
|
|
12
|
+
<%= flash[:alert] %>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
15
|
+
<% if flash[:notice] %>
|
|
16
|
+
<div style="max-width: 400px; margin: 16px auto; padding: 12px; background: #efe; border: 1px solid #cfc; border-radius: 4px; color: #0a0;">
|
|
17
|
+
<%= flash[:notice] %>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
20
|
+
<%= yield %>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
en:
|
|
2
|
+
dlnk_auth:
|
|
3
|
+
sessions:
|
|
4
|
+
rate_limited: "Too many requests. Try again later."
|
|
5
|
+
signed_out: "You have been signed out."
|
|
6
|
+
magic_links:
|
|
7
|
+
rate_limited: "Too many attempts. Try again later."
|
|
8
|
+
invalid_code: "Invalid or expired code. Please try again."
|
|
9
|
+
email_first: "Please enter your email first."
|
|
10
|
+
email_mismatch: "Email mismatch. Please try again."
|
|
11
|
+
signed_in: "Signed in successfully."
|
|
12
|
+
code_sent: "If this email address is registered, you will receive a sign-in code shortly."
|
|
13
|
+
mailer:
|
|
14
|
+
sign_in_instructions:
|
|
15
|
+
subject: "Your sign in code"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
DlnkAuth::Engine.routes.draw do
|
|
2
|
+
resource :session, only: [ :new, :create, :destroy ]
|
|
3
|
+
resource :magic_link, only: [ :show, :create ]
|
|
4
|
+
|
|
5
|
+
post "impersonation/:user_id", to: "impersonations#create", as: :impersonation
|
|
6
|
+
delete "impersonation", to: "impersonations#destroy"
|
|
7
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateDlnkAuthMagicLinks < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :dlnk_auth_magic_links do |t|
|
|
4
|
+
t.references :user, null: false, polymorphic: true
|
|
5
|
+
t.string :email, null: false
|
|
6
|
+
t.string :code_digest, null: false
|
|
7
|
+
t.datetime :expires_at, null: false
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :dlnk_auth_magic_links, :code_digest, unique: true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module DlnkAuth
|
|
6
|
+
module Code
|
|
7
|
+
BASE32_ALPHABET = %w[0 1 2 3 4 5 6 7 8 9 A B C D E F G H J K M N P Q R S T V W X Y Z].freeze
|
|
8
|
+
SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def generate(length)
|
|
12
|
+
Array.new(length) { BASE32_ALPHABET[SecureRandom.random_number(BASE32_ALPHABET.size)] }.join
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def sanitize(code)
|
|
16
|
+
if code.present?
|
|
17
|
+
code.to_s.upcase
|
|
18
|
+
.then { |c| SUBSTITUTIONS.reduce(c) { |result, (from, to)| result.gsub(from, to) } }
|
|
19
|
+
.then { |c| c.gsub(/[^#{BASE32_ALPHABET.join}]/, "") }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :user_class, :email_attribute, :code_length,
|
|
6
|
+
:magic_link_ttl, :session_ttl,
|
|
7
|
+
:after_sign_in_path, :after_sign_out_path,
|
|
8
|
+
:mailer_sender,
|
|
9
|
+
:rate_limit_sessions, :rate_limit_codes
|
|
10
|
+
|
|
11
|
+
attr_reader :impersonation, :tenanting
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@user_class = "User"
|
|
15
|
+
@email_attribute = :email
|
|
16
|
+
@code_length = 6
|
|
17
|
+
@magic_link_ttl = 15.minutes
|
|
18
|
+
@session_ttl = 2.weeks
|
|
19
|
+
@after_sign_in_path = :root_path
|
|
20
|
+
@after_sign_out_path = :root_path
|
|
21
|
+
@mailer_sender = "noreply@example.com"
|
|
22
|
+
@rate_limit_sessions = { to: 10, within: 3.minutes }
|
|
23
|
+
@rate_limit_codes = { to: 10, within: 15.minutes }
|
|
24
|
+
@impersonation = ImpersonationConfig.new
|
|
25
|
+
@tenanting = TenantingConfig.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize_copy(original)
|
|
29
|
+
super
|
|
30
|
+
@impersonation = original.impersonation.dup
|
|
31
|
+
@tenanting = original.tenanting.dup
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate!
|
|
35
|
+
raise ArgumentError, "user_class must be a String" unless user_class.is_a?(String)
|
|
36
|
+
raise ArgumentError, "code_length must be a positive Integer" unless code_length.is_a?(Integer) && code_length > 0
|
|
37
|
+
raise ArgumentError, "email_attribute must be a Symbol" unless email_attribute.is_a?(Symbol)
|
|
38
|
+
raise ArgumentError, "magic_link_ttl must respond to :from_now" unless magic_link_ttl.respond_to?(:from_now)
|
|
39
|
+
raise ArgumentError, "session_ttl must respond to :from_now or be nil" unless session_ttl.nil? || session_ttl.respond_to?(:from_now)
|
|
40
|
+
raise ArgumentError, "mailer_sender must be present" if mailer_sender.blank?
|
|
41
|
+
validate_rate_limit_hash!(:rate_limit_sessions, rate_limit_sessions)
|
|
42
|
+
validate_rate_limit_hash!(:rate_limit_codes, rate_limit_codes)
|
|
43
|
+
raise ArgumentError, "impersonation.authorize_with must respond to :call" unless impersonation.authorize_with.respond_to?(:call)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class ImpersonationConfig
|
|
47
|
+
attr_accessor :enabled, :authorize_with
|
|
48
|
+
|
|
49
|
+
def initialize
|
|
50
|
+
@enabled = false
|
|
51
|
+
@authorize_with = ->(user) { user.superadmin? }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class TenantingConfig
|
|
56
|
+
attr_accessor :enabled, :tenant_class, :slug_attribute,
|
|
57
|
+
:skip_paths, :tenant_resolver
|
|
58
|
+
|
|
59
|
+
def initialize
|
|
60
|
+
@enabled = false
|
|
61
|
+
@tenant_class = "Tenant"
|
|
62
|
+
@slug_attribute = :slug
|
|
63
|
+
@skip_paths = %w[/auth /assets /rails /up /cable]
|
|
64
|
+
@tenant_resolver = nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
def validate_rate_limit_hash!(name, value)
|
|
70
|
+
unless value.is_a?(Hash) && value.key?(:to) && value.key?(:within)
|
|
71
|
+
raise ArgumentError, "#{name} must be a Hash with :to and :within keys"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace DlnkAuth
|
|
6
|
+
|
|
7
|
+
initializer "dlnk_auth.middleware" do |app|
|
|
8
|
+
if DlnkAuth.configuration.tenanting.enabled
|
|
9
|
+
app.middleware.insert_after Rack::TempfileReaper, DlnkAuth::Middleware::TenantResolver
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "dlnk_auth.configuration_validation", after: :load_config_initializers do
|
|
14
|
+
DlnkAuth.configuration.validate!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "dlnk_auth.i18n" do
|
|
18
|
+
DlnkAuth::Engine.root.glob("config/locales/**/*.yml").each do |locale|
|
|
19
|
+
I18n.load_path << locale unless I18n.load_path.include?(locale)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
module Middleware
|
|
5
|
+
class TenantResolver
|
|
6
|
+
SLUG_PATTERN = %r{\A/([a-z0-9][a-z0-9\-]*[a-z0-9]|[a-z0-9])}
|
|
7
|
+
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
config = DlnkAuth.configuration.tenanting
|
|
14
|
+
return @app.call(env) unless config.enabled
|
|
15
|
+
|
|
16
|
+
request = ActionDispatch::Request.new(env)
|
|
17
|
+
path = request.path_info
|
|
18
|
+
|
|
19
|
+
return @app.call(env) if skip_path?(path, config)
|
|
20
|
+
|
|
21
|
+
tenant = resolve_tenant(request, config)
|
|
22
|
+
|
|
23
|
+
if tenant
|
|
24
|
+
rewrite_path(request, path)
|
|
25
|
+
DlnkAuth::Current.tenant = tenant
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@app.call(env)
|
|
29
|
+
ensure
|
|
30
|
+
DlnkAuth::Current.reset if config&.enabled
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
def skip_path?(path, config)
|
|
35
|
+
return true if path == "/"
|
|
36
|
+
config.skip_paths.any? { |prefix| path.start_with?(prefix) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_tenant(request, config)
|
|
40
|
+
if config.tenant_resolver
|
|
41
|
+
config.tenant_resolver.call(request)
|
|
42
|
+
else
|
|
43
|
+
resolve_by_slug(request.path_info, config)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resolve_by_slug(path, config)
|
|
48
|
+
if (match = path.match(SLUG_PATTERN))
|
|
49
|
+
slug = match[1].downcase
|
|
50
|
+
tenant_class = config.tenant_class.constantize
|
|
51
|
+
tenant_class.find_by(config.slug_attribute => slug)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def rewrite_path(request, path)
|
|
56
|
+
if (match = path.match(SLUG_PATTERN))
|
|
57
|
+
matched = match[0]
|
|
58
|
+
request.env["SCRIPT_NAME"] = request.script_name + matched
|
|
59
|
+
remaining = path.delete_prefix(matched)
|
|
60
|
+
request.env["PATH_INFO"] = remaining.empty? ? "/" : remaining
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/dlnk_auth.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dlnk_auth/version"
|
|
4
|
+
require "dlnk_auth/configuration"
|
|
5
|
+
require "dlnk_auth/code"
|
|
6
|
+
require "dlnk_auth/middleware/tenant_resolver"
|
|
7
|
+
require "dlnk_auth/engine"
|
|
8
|
+
|
|
9
|
+
module DlnkAuth
|
|
10
|
+
class << self
|
|
11
|
+
def configuration
|
|
12
|
+
@configuration ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def configure
|
|
16
|
+
yield(configuration)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reset_configuration!
|
|
20
|
+
@configuration = Configuration.new
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
def copy_initializer
|
|
9
|
+
template "initializer.rb.tt", "config/initializers/dlnk_auth.rb"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def copy_migrations
|
|
13
|
+
rails_command "dlnk_auth:install:migrations"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_route
|
|
17
|
+
route 'mount DlnkAuth::Engine => "/auth"'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def show_post_install
|
|
21
|
+
say ""
|
|
22
|
+
say "DlnkAuth installed successfully!", :green
|
|
23
|
+
say ""
|
|
24
|
+
say "Next steps:"
|
|
25
|
+
say " 1. Edit config/initializers/dlnk_auth.rb"
|
|
26
|
+
say " 2. Add `include DlnkAuth::Authenticatable` to your User model"
|
|
27
|
+
say " 3. Add `include DlnkAuth::Authentication` to your ApplicationController"
|
|
28
|
+
say " 4. Run `rails db:migrate`"
|
|
29
|
+
say ""
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
DlnkAuth.configure do |c|
|
|
4
|
+
# Core auth
|
|
5
|
+
c.user_class = "User"
|
|
6
|
+
c.email_attribute = :email
|
|
7
|
+
c.code_length = 6
|
|
8
|
+
c.magic_link_ttl = 15.minutes
|
|
9
|
+
c.session_ttl = 2.weeks
|
|
10
|
+
c.after_sign_in_path = :root_path
|
|
11
|
+
c.after_sign_out_path = :root_path
|
|
12
|
+
c.mailer_sender = "noreply@example.com"
|
|
13
|
+
|
|
14
|
+
# Rate limiting
|
|
15
|
+
c.rate_limit_sessions = { to: 10, within: 3.minutes }
|
|
16
|
+
c.rate_limit_codes = { to: 10, within: 15.minutes }
|
|
17
|
+
|
|
18
|
+
# Impersonation (opt-in)
|
|
19
|
+
# c.impersonation.enabled = true
|
|
20
|
+
|
|
21
|
+
# Multi-tenancy (opt-in)
|
|
22
|
+
# c.tenanting.enabled = true
|
|
23
|
+
# c.tenanting.tenant_class = "Tenant"
|
|
24
|
+
# c.tenanting.slug_attribute = :slug
|
|
25
|
+
# c.tenanting.skip_paths = %w[/auth /assets /rails /up /cable]
|
|
26
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DlnkAuth
|
|
4
|
+
module Generators
|
|
5
|
+
class ViewsGenerator < Rails::Generators::Base
|
|
6
|
+
source_root DlnkAuth::Engine.root.join("app/views/dlnk_auth")
|
|
7
|
+
|
|
8
|
+
def copy_views
|
|
9
|
+
directory ".", "app/views/dlnk_auth"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show_info
|
|
13
|
+
say ""
|
|
14
|
+
say "DlnkAuth views copied to app/views/dlnk_auth/", :green
|
|
15
|
+
say "You can now customize the sign-in pages."
|
|
16
|
+
say ""
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: dlnk_auth
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- DALiNK
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
description: A Rails Engine providing passwordless magic link authentication with
|
|
27
|
+
opt-in impersonation and multi-tenancy modules.
|
|
28
|
+
email:
|
|
29
|
+
- contact@dalink.fr
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- MIT-LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- Rakefile
|
|
37
|
+
- app/controllers/concerns/dlnk_auth/authentication.rb
|
|
38
|
+
- app/controllers/concerns/dlnk_auth/authentication/via_magic_link.rb
|
|
39
|
+
- app/controllers/concerns/dlnk_auth/impersonation.rb
|
|
40
|
+
- app/controllers/dlnk_auth/application_controller.rb
|
|
41
|
+
- app/controllers/dlnk_auth/impersonations_controller.rb
|
|
42
|
+
- app/controllers/dlnk_auth/magic_links_controller.rb
|
|
43
|
+
- app/controllers/dlnk_auth/sessions_controller.rb
|
|
44
|
+
- app/mailers/dlnk_auth/application_mailer.rb
|
|
45
|
+
- app/mailers/dlnk_auth/magic_link_mailer.rb
|
|
46
|
+
- app/models/concerns/dlnk_auth/authenticatable.rb
|
|
47
|
+
- app/models/dlnk_auth/application_record.rb
|
|
48
|
+
- app/models/dlnk_auth/current.rb
|
|
49
|
+
- app/models/dlnk_auth/magic_link.rb
|
|
50
|
+
- app/models/dlnk_auth/session.rb
|
|
51
|
+
- app/views/dlnk_auth/magic_link_mailer/sign_in_instructions.html.erb
|
|
52
|
+
- app/views/dlnk_auth/magic_link_mailer/sign_in_instructions.text.erb
|
|
53
|
+
- app/views/dlnk_auth/magic_links/show.html.erb
|
|
54
|
+
- app/views/dlnk_auth/sessions/new.html.erb
|
|
55
|
+
- app/views/layouts/dlnk_auth/application.html.erb
|
|
56
|
+
- app/views/layouts/mailer.html.erb
|
|
57
|
+
- config/locales/en.yml
|
|
58
|
+
- config/routes.rb
|
|
59
|
+
- db/migrate/20260215000001_create_dlnk_auth_sessions.rb
|
|
60
|
+
- db/migrate/20260215000002_create_dlnk_auth_magic_links.rb
|
|
61
|
+
- db/migrate/20260215000005_add_expires_at_index_to_dlnk_auth_magic_links.rb
|
|
62
|
+
- lib/dlnk_auth.rb
|
|
63
|
+
- lib/dlnk_auth/code.rb
|
|
64
|
+
- lib/dlnk_auth/configuration.rb
|
|
65
|
+
- lib/dlnk_auth/engine.rb
|
|
66
|
+
- lib/dlnk_auth/middleware/tenant_resolver.rb
|
|
67
|
+
- lib/dlnk_auth/version.rb
|
|
68
|
+
- lib/generators/dlnk_auth/install/install_generator.rb
|
|
69
|
+
- lib/generators/dlnk_auth/install/templates/initializer.rb.tt
|
|
70
|
+
- lib/generators/dlnk_auth/views/views_generator.rb
|
|
71
|
+
homepage: https://github.com/dalink/dlnk_auth
|
|
72
|
+
licenses:
|
|
73
|
+
- MIT
|
|
74
|
+
metadata: {}
|
|
75
|
+
rdoc_options: []
|
|
76
|
+
require_paths:
|
|
77
|
+
- lib
|
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: 3.3.0
|
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
requirements: []
|
|
89
|
+
rubygems_version: 4.0.1
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: Passwordless magic link authentication engine for Rails
|
|
92
|
+
test_files: []
|