easy-admin-rails 0.2.4 → 0.2.6
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/app/assets/builds/easy_admin.base.js +100 -27
- data/app/assets/builds/easy_admin.base.js.map +4 -4
- data/app/assets/builds/easy_admin.css +59 -0
- data/app/components/easy_admin/navbar_component.rb +19 -4
- data/app/components/easy_admin/permissions/user_role_assignment_component.rb +43 -16
- data/app/components/easy_admin/profile/change_password_modal_component.rb +75 -0
- data/app/components/easy_admin/profile/profile_tab_component.rb +92 -0
- data/app/components/easy_admin/profile/security_tab_component.rb +53 -0
- data/app/components/easy_admin/profile/settings_component.rb +103 -0
- data/app/components/easy_admin/two_factor/backup_codes_component.rb +118 -0
- data/app/components/easy_admin/two_factor/setup_component.rb +124 -0
- data/app/components/easy_admin/two_factor/status_component.rb +92 -0
- data/app/controllers/concerns/easy_admin/two_factor.rb +110 -0
- data/app/controllers/easy_admin/application_controller.rb +10 -0
- data/app/controllers/easy_admin/profile_controller.rb +25 -0
- data/app/controllers/easy_admin/sessions_controller.rb +107 -1
- data/app/javascript/easy_admin/controllers/collapsible_filters_controller.js +14 -7
- data/app/javascript/easy_admin/controllers/permission_toggle_controller.js +2 -33
- data/app/javascript/easy_admin/controllers/vertical_tabs_controller.js +112 -0
- data/app/javascript/easy_admin/controllers.js +3 -1
- data/app/models/easy_admin/admin_user.rb +3 -0
- data/app/views/easy_admin/profile/backup_codes_regenerated.turbo_stream.erb +12 -0
- data/app/views/easy_admin/profile/change_password.html.erb +24 -0
- data/app/views/easy_admin/profile/index.html.erb +1 -0
- data/app/views/easy_admin/profile/password_error.turbo_stream.erb +6 -0
- data/app/views/easy_admin/profile/password_invalid_current.turbo_stream.erb +6 -0
- data/app/views/easy_admin/profile/password_updated.turbo_stream.erb +9 -0
- data/app/views/easy_admin/profile/two_factor_backup_codes.html.erb +24 -0
- data/app/views/easy_admin/profile/two_factor_enabled.turbo_stream.erb +12 -0
- data/app/views/easy_admin/profile/two_factor_invalid_code.turbo_stream.erb +6 -0
- data/app/views/easy_admin/profile/two_factor_not_enabled.turbo_stream.erb +6 -0
- data/app/views/easy_admin/profile/two_factor_setup.html.erb +24 -0
- data/app/views/easy_admin/profile/two_factor_unavailable.turbo_stream.erb +6 -0
- data/app/views/easy_admin/sessions/two_factor_verification.html.erb +48 -0
- data/app/views/easy_admin/sessions/verify_2fa_error.turbo_stream.erb +13 -0
- data/config/routes.rb +20 -1
- data/lib/easy-admin-rails.rb +1 -0
- data/lib/easy_admin/two_factor_authentication.rb +156 -0
- data/lib/easy_admin/version.rb +1 -1
- data/lib/generators/easy_admin/two_factor/templates/README +29 -0
- data/lib/generators/easy_admin/two_factor/templates/migration.rb +10 -0
- data/lib/generators/easy_admin/two_factor/two_factor_generator.rb +22 -0
- metadata +30 -2
@@ -0,0 +1,112 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = ["tab", "content"]
|
5
|
+
|
6
|
+
connect() {
|
7
|
+
// Check for tab parameter in URL query string
|
8
|
+
const urlParams = new URLSearchParams(window.location.search)
|
9
|
+
const tabFromUrl = urlParams.get('tab')
|
10
|
+
|
11
|
+
// If tab parameter exists and matches a valid tab, use it
|
12
|
+
if (tabFromUrl && this.isValidTab(tabFromUrl)) {
|
13
|
+
this.activateTab(tabFromUrl)
|
14
|
+
} else {
|
15
|
+
// Set initial active tab (first tab by default)
|
16
|
+
const firstTab = this.tabTargets[0]
|
17
|
+
if (firstTab) {
|
18
|
+
this.activateTab(firstTab.dataset.tabId)
|
19
|
+
}
|
20
|
+
}
|
21
|
+
|
22
|
+
// Listen for popstate events (back/forward navigation)
|
23
|
+
this.handlePopState = this.handlePopState.bind(this)
|
24
|
+
window.addEventListener('popstate', this.handlePopState)
|
25
|
+
}
|
26
|
+
|
27
|
+
disconnect() {
|
28
|
+
window.removeEventListener('popstate', this.handlePopState)
|
29
|
+
}
|
30
|
+
|
31
|
+
switchTab(event) {
|
32
|
+
event.preventDefault()
|
33
|
+
const tabId = event.currentTarget.dataset.tabId
|
34
|
+
this.activateTab(tabId)
|
35
|
+
}
|
36
|
+
|
37
|
+
activateTab(tabId) {
|
38
|
+
// Update tab buttons
|
39
|
+
this.tabTargets.forEach(tab => {
|
40
|
+
const isActive = tab.dataset.tabId === tabId
|
41
|
+
|
42
|
+
if (isActive) {
|
43
|
+
tab.classList.remove("text-gray-700", "hover:bg-gray-100", "hover:text-gray-900")
|
44
|
+
tab.classList.add("bg-blue-50", "text-blue-700", "border-blue-200")
|
45
|
+
} else {
|
46
|
+
tab.classList.remove("bg-blue-50", "text-blue-700", "border-blue-200")
|
47
|
+
tab.classList.add("text-gray-700", "hover:bg-gray-100", "hover:text-gray-900")
|
48
|
+
}
|
49
|
+
})
|
50
|
+
|
51
|
+
// Update content areas
|
52
|
+
this.contentTargets.forEach(content => {
|
53
|
+
const isActive = content.dataset.tabId === tabId
|
54
|
+
|
55
|
+
if (isActive) {
|
56
|
+
content.classList.remove("hidden")
|
57
|
+
} else {
|
58
|
+
content.classList.add("hidden")
|
59
|
+
}
|
60
|
+
})
|
61
|
+
|
62
|
+
// Update URL hash for deep linking (optional)
|
63
|
+
if (history.replaceState) {
|
64
|
+
const url = new URL(window.location)
|
65
|
+
url.searchParams.set('tab', tabId)
|
66
|
+
history.replaceState(null, '', url)
|
67
|
+
}
|
68
|
+
}
|
69
|
+
|
70
|
+
isValidTab(tabId) {
|
71
|
+
return this.tabTargets.some(tab => tab.dataset.tabId === tabId)
|
72
|
+
}
|
73
|
+
|
74
|
+
handlePopState() {
|
75
|
+
const urlParams = new URLSearchParams(window.location.search)
|
76
|
+
const tabFromUrl = urlParams.get('tab')
|
77
|
+
|
78
|
+
if (tabFromUrl && this.isValidTab(tabFromUrl)) {
|
79
|
+
this.activateTabSilently(tabFromUrl)
|
80
|
+
} else {
|
81
|
+
const firstTab = this.tabTargets[0]
|
82
|
+
if (firstTab) {
|
83
|
+
this.activateTabSilently(firstTab.dataset.tabId)
|
84
|
+
}
|
85
|
+
}
|
86
|
+
}
|
87
|
+
|
88
|
+
activateTabSilently(tabId) {
|
89
|
+
// Activate tab without updating URL (for popstate handling)
|
90
|
+
this.tabTargets.forEach(tab => {
|
91
|
+
const isActive = tab.dataset.tabId === tabId
|
92
|
+
|
93
|
+
if (isActive) {
|
94
|
+
tab.classList.remove("text-gray-700", "hover:bg-gray-100", "hover:text-gray-900")
|
95
|
+
tab.classList.add("bg-blue-50", "text-blue-700", "border-blue-200")
|
96
|
+
} else {
|
97
|
+
tab.classList.remove("bg-blue-50", "text-blue-700", "border-blue-200")
|
98
|
+
tab.classList.add("text-gray-700", "hover:bg-gray-100", "hover:text-gray-900")
|
99
|
+
}
|
100
|
+
})
|
101
|
+
|
102
|
+
this.contentTargets.forEach(content => {
|
103
|
+
const isActive = content.dataset.tabId === tabId
|
104
|
+
|
105
|
+
if (isActive) {
|
106
|
+
content.classList.remove("hidden")
|
107
|
+
} else {
|
108
|
+
content.classList.add("hidden")
|
109
|
+
}
|
110
|
+
})
|
111
|
+
}
|
112
|
+
}
|
@@ -28,6 +28,7 @@ import JsoneditorController from './controllers/jsoneditor_controller'
|
|
28
28
|
import VersionRevertController from './controllers/version_revert_controller'
|
29
29
|
import RolePreviewController from './controllers/role_preview_controller'
|
30
30
|
import PermissionToggleController from './controllers/permission_toggle_controller'
|
31
|
+
import VerticalTabsController from './controllers/vertical_tabs_controller'
|
31
32
|
|
32
33
|
// Register controllers
|
33
34
|
application.register('sidebar', SidebarController)
|
@@ -57,4 +58,5 @@ application.register('row-action', RowActionController)
|
|
57
58
|
application.register('jsoneditor', JsoneditorController)
|
58
59
|
application.register('version-revert', VersionRevertController)
|
59
60
|
application.register('role-preview', RolePreviewController)
|
60
|
-
application.register('permission-toggle', PermissionToggleController)
|
61
|
+
application.register('permission-toggle', PermissionToggleController)
|
62
|
+
application.register('vertical-tabs', VerticalTabsController)
|
@@ -10,6 +10,9 @@ module EasyAdmin
|
|
10
10
|
# EasyAdmin Permissions
|
11
11
|
include EasyAdmin::Permissions::UserExtensions
|
12
12
|
|
13
|
+
# Two-Factor Authentication (optional)
|
14
|
+
include EasyAdmin::TwoFactorAuthentication
|
15
|
+
|
13
16
|
# Direct role association (each admin user has one role)
|
14
17
|
belongs_to :role, class_name: 'EasyAdmin::Permissions::Role', optional: true
|
15
18
|
|
@@ -0,0 +1,12 @@
|
|
1
|
+
<%= turbo_stream.replace "backup_codes_section" do %>
|
2
|
+
<div id="backup_codes_section">
|
3
|
+
<%== EasyAdmin::TwoFactor::BackupCodesComponent.new(user: current_admin_user).call %>
|
4
|
+
</div>
|
5
|
+
<% end %>
|
6
|
+
|
7
|
+
<%= turbo_stream.replace "notifications" do %>
|
8
|
+
<%== EasyAdmin::NotificationComponent.new(
|
9
|
+
message: "New backup codes have been generated",
|
10
|
+
type: :success
|
11
|
+
).call %>
|
12
|
+
<% end %>
|
@@ -0,0 +1,24 @@
|
|
1
|
+
<% if turbo_frame_request? %>
|
2
|
+
<turbo-frame id="modal">
|
3
|
+
<div id="modal-backdrop" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 opacity-100 transition-opacity duration-300" data-controller="modal" data-action="click->modal#closeOnBackdrop">
|
4
|
+
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 xl:w-2/5 shadow-lg rounded-md bg-white">
|
5
|
+
<div class="mt-3">
|
6
|
+
<!-- Modal header -->
|
7
|
+
<div class="flex items-center justify-between mb-4">
|
8
|
+
<h3 class="text-lg font-medium text-gray-900">Change Password</h3>
|
9
|
+
<button class="text-gray-400 hover:text-gray-600 focus:outline-none" data-action="click->modal#close">
|
10
|
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
11
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
12
|
+
</svg>
|
13
|
+
</button>
|
14
|
+
</div>
|
15
|
+
|
16
|
+
<!-- Modal content -->
|
17
|
+
<%== EasyAdmin::Profile::ChangePasswordModalComponent.new(user: current_admin_user).call %>
|
18
|
+
</div>
|
19
|
+
</div>
|
20
|
+
</div>
|
21
|
+
</turbo-frame>
|
22
|
+
<% else %>
|
23
|
+
<%== EasyAdmin::Profile::ChangePasswordModalComponent.new(user: current_admin_user).call %>
|
24
|
+
<% end %>
|
@@ -0,0 +1 @@
|
|
1
|
+
<%== EasyAdmin::Profile::SettingsComponent.new(user: @user).call %>
|
@@ -0,0 +1,24 @@
|
|
1
|
+
<% if turbo_frame_request? %>
|
2
|
+
<turbo-frame id="modal">
|
3
|
+
<div id="modal-backdrop" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 opacity-100 transition-opacity duration-300" data-controller="modal" data-action="click->modal#closeOnBackdrop">
|
4
|
+
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 xl:w-2/5 shadow-lg rounded-md bg-white">
|
5
|
+
<div class="mt-3">
|
6
|
+
<!-- Modal header -->
|
7
|
+
<div class="flex items-center justify-between mb-4">
|
8
|
+
<h3 class="text-lg font-medium text-gray-900">Backup Codes</h3>
|
9
|
+
<button class="text-gray-400 hover:text-gray-600 focus:outline-none" data-action="click->modal#close">
|
10
|
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
11
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
12
|
+
</svg>
|
13
|
+
</button>
|
14
|
+
</div>
|
15
|
+
|
16
|
+
<!-- Modal content -->
|
17
|
+
<%== EasyAdmin::TwoFactor::BackupCodesComponent.new(user: current_admin_user).call %>
|
18
|
+
</div>
|
19
|
+
</div>
|
20
|
+
</div>
|
21
|
+
</turbo-frame>
|
22
|
+
<% else %>
|
23
|
+
<%== EasyAdmin::TwoFactor::BackupCodesComponent.new(user: current_admin_user).call %>
|
24
|
+
<% end %>
|
@@ -0,0 +1,12 @@
|
|
1
|
+
<%= turbo_stream.replace "two_factor_section" do %>
|
2
|
+
<div id="two_factor_section">
|
3
|
+
<%== EasyAdmin::TwoFactor::StatusComponent.new(user: current_admin_user).call %>
|
4
|
+
</div>
|
5
|
+
<% end %>
|
6
|
+
|
7
|
+
<%= turbo_stream.replace "notifications" do %>
|
8
|
+
<%== EasyAdmin::NotificationComponent.new(
|
9
|
+
message: "Two-factor authentication has been enabled successfully!",
|
10
|
+
type: :success
|
11
|
+
).call %>
|
12
|
+
<% end %>
|
@@ -0,0 +1,24 @@
|
|
1
|
+
<% if turbo_frame_request? %>
|
2
|
+
<turbo-frame id="modal">
|
3
|
+
<div id="modal-backdrop" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 opacity-100 transition-opacity duration-300" data-controller="modal" data-action="click->modal#closeOnBackdrop">
|
4
|
+
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 xl:w-2/5 shadow-lg rounded-md bg-white">
|
5
|
+
<div class="mt-3">
|
6
|
+
<!-- Modal header -->
|
7
|
+
<div class="flex items-center justify-between mb-4">
|
8
|
+
<h3 class="text-lg font-medium text-gray-900">Set up Two-Factor Authentication</h3>
|
9
|
+
<button class="text-gray-400 hover:text-gray-600 focus:outline-none" data-action="click->modal#close">
|
10
|
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
11
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
12
|
+
</svg>
|
13
|
+
</button>
|
14
|
+
</div>
|
15
|
+
|
16
|
+
<!-- Modal content -->
|
17
|
+
<%== EasyAdmin::TwoFactor::SetupComponent.new(user: current_admin_user).call %>
|
18
|
+
</div>
|
19
|
+
</div>
|
20
|
+
</div>
|
21
|
+
</turbo-frame>
|
22
|
+
<% else %>
|
23
|
+
<%== EasyAdmin::TwoFactor::SetupComponent.new(user: current_admin_user).call %>
|
24
|
+
<% end %>
|
@@ -0,0 +1,48 @@
|
|
1
|
+
<!-- Form -->
|
2
|
+
<div class="p-10">
|
3
|
+
<%= form_with url: verify_2fa_path, method: :post, local: true, html: { class: "space-y-8" } do |f| %>
|
4
|
+
|
5
|
+
<!-- Header -->
|
6
|
+
<div class="text-center mb-6">
|
7
|
+
<h2 class="text-xl font-bold text-gray-900 mb-2">Two-Factor Authentication</h2>
|
8
|
+
<p class="text-gray-600">Enter the 6-digit code from your authenticator app</p>
|
9
|
+
<% if @user %>
|
10
|
+
<p class="text-sm text-gray-500 mt-2">Signing in as <%= @user.email %></p>
|
11
|
+
<% end %>
|
12
|
+
</div>
|
13
|
+
|
14
|
+
<!-- Input Fields Container -->
|
15
|
+
<div class="space-y-4">
|
16
|
+
<!-- OTP Code Field -->
|
17
|
+
<div class="relative">
|
18
|
+
<%= text_field_tag :otp_code, '', autofocus: true, autocomplete: "one-time-code",
|
19
|
+
maxlength: 6, pattern: "[0-9]{6}", id: "otp_code",
|
20
|
+
class: "w-full px-6 py-5 bg-gray-50/80 border-0 text-gray-900 placeholder-gray-400 focus:bg-gray-100/80 focus:ring-0 focus:outline-none text-base rounded-2xl transition-colors duration-200 font-medium text-center tracking-widest",
|
21
|
+
placeholder: "000000" %>
|
22
|
+
</div>
|
23
|
+
</div>
|
24
|
+
|
25
|
+
<!-- Submit Button -->
|
26
|
+
<div class="pt-4">
|
27
|
+
<%= f.submit "Verify Code",
|
28
|
+
class: "w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-bold py-5 px-6 rounded-2xl transition-all duration-200 focus:outline-none focus:ring-4 focus:ring-blue-500/30 text-base shadow-xl shadow-blue-500/20 active:scale-[0.98]" %>
|
29
|
+
</div>
|
30
|
+
|
31
|
+
<% end %>
|
32
|
+
|
33
|
+
<!-- Help Text -->
|
34
|
+
<div class="text-center pt-6">
|
35
|
+
<p class="text-sm text-gray-500 mb-2">
|
36
|
+
Can't access your authenticator app?
|
37
|
+
</p>
|
38
|
+
<p class="text-sm text-gray-400">
|
39
|
+
Contact your administrator for help with account recovery.
|
40
|
+
</p>
|
41
|
+
</div>
|
42
|
+
|
43
|
+
<!-- Back to Sign In -->
|
44
|
+
<div class="text-center pt-4">
|
45
|
+
<%= link_to "← Back to Sign In", cancel_2fa_path,
|
46
|
+
class: "text-sm text-blue-500 hover:text-blue-600 font-medium transition-colors duration-200" %>
|
47
|
+
</div>
|
48
|
+
</div>
|
@@ -0,0 +1,13 @@
|
|
1
|
+
<%= turbo_stream.replace "notifications" do %>
|
2
|
+
<%== EasyAdmin::NotificationComponent.new(
|
3
|
+
message: "Invalid authentication code. Please try again.",
|
4
|
+
type: :error
|
5
|
+
).call %>
|
6
|
+
<% end %>
|
7
|
+
|
8
|
+
<%= turbo_stream.replace "otp_code" do %>
|
9
|
+
<%= text_field_tag :otp_code, '', autofocus: true, autocomplete: "one-time-code",
|
10
|
+
maxlength: 6, pattern: "[0-9]{6}",
|
11
|
+
class: "w-full px-6 py-5 bg-gray-50/80 border-0 text-gray-900 placeholder-gray-400 focus:bg-gray-100/80 focus:ring-0 focus:outline-none text-base rounded-2xl transition-colors duration-200 font-medium text-center tracking-widest",
|
12
|
+
placeholder: "000000" %>
|
13
|
+
<% end %>
|
data/config/routes.rb
CHANGED
@@ -13,13 +13,32 @@ EasyAdmin::Engine.routes.draw do
|
|
13
13
|
sign_out: 'sign_out',
|
14
14
|
sign_up: 'sign_up'
|
15
15
|
}
|
16
|
+
|
17
|
+
# 2FA verification routes (wrapped in devise_scope)
|
18
|
+
devise_scope :admin_user do
|
19
|
+
get 'two_factor_verification', to: 'sessions#two_factor_verification', as: 'two_factor_verification'
|
20
|
+
post 'verify_2fa', to: 'sessions#verify_2fa', as: 'verify_2fa'
|
21
|
+
get 'cancel_2fa', to: 'sessions#cancel_2fa', as: 'cancel_2fa'
|
22
|
+
end
|
16
23
|
|
17
24
|
root 'dashboard#index'
|
18
25
|
|
19
|
-
#
|
26
|
+
# Global settings routes (Flipper)
|
20
27
|
get 'settings', to: 'settings#index'
|
21
28
|
patch 'settings', to: 'settings#update'
|
22
29
|
|
30
|
+
# User profile settings routes
|
31
|
+
get 'profile', to: 'profile#index', as: 'profile'
|
32
|
+
patch 'profile', to: 'profile#update', as: 'update_profile'
|
33
|
+
get 'profile/change_password', to: 'profile#change_password', as: 'change_password'
|
34
|
+
patch 'profile/change_password', to: 'profile#update_password', as: 'update_password'
|
35
|
+
|
36
|
+
# Two-factor authentication routes (via profile controller)
|
37
|
+
get 'profile/two_factor/setup', to: 'profile#two_factor_setup', as: 'two_factor_setup'
|
38
|
+
post 'profile/two_factor/enable', to: 'profile#two_factor_enable', as: 'two_factor_enable'
|
39
|
+
get 'profile/two_factor/backup_codes', to: 'profile#two_factor_backup_codes', as: 'two_factor_backup_codes'
|
40
|
+
post 'profile/two_factor/regenerate_backup_codes', to: 'profile#regenerate_backup_codes', as: 'two_factor_regenerate_backup_codes'
|
41
|
+
|
23
42
|
# Confirmation modal
|
24
43
|
get 'confirmation_modal', to: 'confirmation_modal#show'
|
25
44
|
|
data/lib/easy-admin-rails.rb
CHANGED
@@ -0,0 +1,156 @@
|
|
1
|
+
module EasyAdmin
|
2
|
+
module TwoFactorAuthentication
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
# Check if required gems are available
|
6
|
+
def self.available?
|
7
|
+
@available ||= begin
|
8
|
+
require 'rotp'
|
9
|
+
require 'rqrcode'
|
10
|
+
true
|
11
|
+
rescue LoadError
|
12
|
+
false
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
included do
|
17
|
+
# Only add validations and callbacks if 2FA gems are available
|
18
|
+
if EasyAdmin::TwoFactorAuthentication.available?
|
19
|
+
validates :otp_secret, presence: true, if: :otp_required_for_login?
|
20
|
+
validates :otp_secret, uniqueness: true, allow_blank: true
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def two_factor_available?
|
25
|
+
EasyAdmin::TwoFactorAuthentication.available?
|
26
|
+
end
|
27
|
+
|
28
|
+
def two_factor_enabled?
|
29
|
+
two_factor_available? && otp_required_for_login? && otp_secret.present?
|
30
|
+
end
|
31
|
+
|
32
|
+
def generate_otp_secret!
|
33
|
+
return false unless two_factor_available?
|
34
|
+
|
35
|
+
require 'rotp'
|
36
|
+
self.otp_secret = ROTP::Base32.random
|
37
|
+
save!
|
38
|
+
end
|
39
|
+
|
40
|
+
def current_otp
|
41
|
+
return nil unless two_factor_available? && otp_secret.present?
|
42
|
+
|
43
|
+
require 'rotp'
|
44
|
+
ROTP::TOTP.new(otp_secret, issuer: "EasyAdmin").now
|
45
|
+
end
|
46
|
+
|
47
|
+
def validate_and_consume_otp!(token)
|
48
|
+
return false unless two_factor_available? && otp_secret.present?
|
49
|
+
return false if token.blank?
|
50
|
+
|
51
|
+
require 'rotp'
|
52
|
+
|
53
|
+
totp = ROTP::TOTP.new(otp_secret, issuer: "EasyAdmin")
|
54
|
+
last_otp_at_timestamp = last_otp_at&.to_i
|
55
|
+
|
56
|
+
# Verify with 30-second drift tolerance and replay protection
|
57
|
+
if totp.verify(token.to_s, drift_behind: 30, drift_ahead: 30, after: last_otp_at_timestamp)
|
58
|
+
touch(:last_otp_at)
|
59
|
+
true
|
60
|
+
else
|
61
|
+
false
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def validate_backup_code!(code)
|
66
|
+
return false unless two_factor_available?
|
67
|
+
return false if code.blank? || otp_backup_codes.blank?
|
68
|
+
|
69
|
+
normalized_code = code.to_s.upcase.strip
|
70
|
+
|
71
|
+
if otp_backup_codes.include?(normalized_code)
|
72
|
+
invalidate_backup_code!(normalized_code)
|
73
|
+
true
|
74
|
+
else
|
75
|
+
false
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def generate_backup_codes!
|
80
|
+
return false unless two_factor_available?
|
81
|
+
|
82
|
+
# Generate 10 backup codes (8 characters each)
|
83
|
+
codes = 10.times.map { SecureRandom.hex(4).upcase }
|
84
|
+
self.otp_backup_codes = codes
|
85
|
+
save!
|
86
|
+
codes
|
87
|
+
end
|
88
|
+
|
89
|
+
def invalidate_backup_code!(code)
|
90
|
+
return false unless two_factor_available?
|
91
|
+
|
92
|
+
normalized_code = code.to_s.upcase.strip
|
93
|
+
self.otp_backup_codes = otp_backup_codes.reject { |c| c == normalized_code }
|
94
|
+
save!
|
95
|
+
end
|
96
|
+
|
97
|
+
def backup_codes_remaining
|
98
|
+
two_factor_available? ? (otp_backup_codes&.length || 0) : 0
|
99
|
+
end
|
100
|
+
|
101
|
+
def provisioning_uri
|
102
|
+
return nil unless two_factor_available? && otp_secret.present?
|
103
|
+
|
104
|
+
require 'rotp'
|
105
|
+
ROTP::TOTP.new(otp_secret, issuer: "EasyAdmin").provisioning_uri(email)
|
106
|
+
end
|
107
|
+
|
108
|
+
def qr_code_svg(size: 200)
|
109
|
+
return nil unless two_factor_available?
|
110
|
+
|
111
|
+
uri = provisioning_uri
|
112
|
+
return nil if uri.blank?
|
113
|
+
|
114
|
+
require 'rqrcode'
|
115
|
+
|
116
|
+
qr_code = RQRCode::QRCode.new(uri)
|
117
|
+
qr_code.as_svg(
|
118
|
+
viewbox: true,
|
119
|
+
module_size: 4,
|
120
|
+
standalone: true,
|
121
|
+
use_path: true
|
122
|
+
)
|
123
|
+
end
|
124
|
+
|
125
|
+
def enable_two_factor!
|
126
|
+
return false unless two_factor_available? && otp_secret.present?
|
127
|
+
|
128
|
+
update!(otp_required_for_login: true)
|
129
|
+
end
|
130
|
+
|
131
|
+
def disable_two_factor!
|
132
|
+
update!(
|
133
|
+
otp_required_for_login: false,
|
134
|
+
otp_secret: nil,
|
135
|
+
otp_backup_codes: nil,
|
136
|
+
last_otp_at: nil
|
137
|
+
)
|
138
|
+
end
|
139
|
+
|
140
|
+
# Check if user needs 2FA based on role requirements
|
141
|
+
def two_factor_required?
|
142
|
+
return false unless two_factor_available?
|
143
|
+
|
144
|
+
# Check if role requires 2FA (if role system exists)
|
145
|
+
if respond_to?(:role) && role.respond_to?(:require_two_factor?)
|
146
|
+
role.require_two_factor?
|
147
|
+
else
|
148
|
+
false
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def should_enable_two_factor?
|
153
|
+
two_factor_required? && !two_factor_enabled?
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
data/lib/easy_admin/version.rb
CHANGED
@@ -0,0 +1,29 @@
|
|
1
|
+
===============================================================================
|
2
|
+
|
3
|
+
Two-Factor Authentication has been added to EasyAdmin!
|
4
|
+
|
5
|
+
To complete the setup, you'll need to:
|
6
|
+
|
7
|
+
1. Install the required gems for 2FA functionality:
|
8
|
+
|
9
|
+
gem 'rotp', '~> 6.3' # TOTP generation/validation
|
10
|
+
gem 'rqrcode', '~> 2.2' # QR code generation
|
11
|
+
|
12
|
+
2. Run the migration:
|
13
|
+
|
14
|
+
rails db:migrate
|
15
|
+
|
16
|
+
3. Restart your server
|
17
|
+
|
18
|
+
4. Two-factor authentication is now available in user settings!
|
19
|
+
|
20
|
+
Features:
|
21
|
+
• Optional 2FA (disabled by default)
|
22
|
+
• TOTP-based (compatible with Google Authenticator, Authy, etc.)
|
23
|
+
• QR code setup for easy configuration
|
24
|
+
• Backup codes for account recovery
|
25
|
+
• Admin can view/manage 2FA status for users
|
26
|
+
|
27
|
+
Without the optional gems, 2FA features will be gracefully hidden.
|
28
|
+
|
29
|
+
===============================================================================
|
@@ -0,0 +1,10 @@
|
|
1
|
+
class AddTwoFactorToEasyAdminAdminUsers < ActiveRecord::Migration[<%= ActiveRecord::VERSION::MAJOR %>.<%= ActiveRecord::VERSION::MINOR %>]
|
2
|
+
def change
|
3
|
+
add_column :easy_admin_admin_users, :otp_secret, :string
|
4
|
+
add_column :easy_admin_admin_users, :otp_required_for_login, :boolean, default: false, null: false
|
5
|
+
add_column :easy_admin_admin_users, :otp_backup_codes, :json
|
6
|
+
add_column :easy_admin_admin_users, :last_otp_at, :datetime
|
7
|
+
|
8
|
+
add_index :easy_admin_admin_users, :otp_secret, unique: true
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
|
3
|
+
module EasyAdmin
|
4
|
+
module Generators
|
5
|
+
class TwoFactorGenerator < Rails::Generators::Base
|
6
|
+
source_root File.expand_path('templates', __dir__)
|
7
|
+
|
8
|
+
desc "Add two-factor authentication fields to EasyAdmin AdminUser model"
|
9
|
+
|
10
|
+
def create_migration
|
11
|
+
timestamp = Time.current.utc.strftime("%Y%m%d%H%M%S")
|
12
|
+
migration_file = "#{timestamp}_add_two_factor_to_easy_admin_admin_users.rb"
|
13
|
+
|
14
|
+
template 'migration.rb', "db/migrate/#{migration_file}"
|
15
|
+
end
|
16
|
+
|
17
|
+
def show_readme
|
18
|
+
readme "README" if behavior == :invoke
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|