katalyst-koi 5.5.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/components/koi/header/edit_component.rb +1 -1
- data/app/components/koi/tables/cells/link_component.rb +2 -2
- data/app/controllers/admin/admin_users_controller.rb +3 -1
- data/app/controllers/admin/credentials_controller.rb +1 -1
- data/app/controllers/admin/device_authorizations_controller.rb +2 -2
- data/app/controllers/admin/sessions_controller.rb +22 -18
- data/app/controllers/admin/tokens_controller.rb +2 -4
- data/app/controllers/admin/url_rewrites_controller.rb +1 -1
- data/app/controllers/admin/well_knowns_controller.rb +1 -1
- data/app/controllers/concerns/koi/controller/has_admin_users.rb +21 -3
- data/app/controllers/concerns/koi/controller/records_authentication.rb +13 -23
- data/app/controllers/concerns/koi/controller.rb +1 -1
- data/app/models/admin/device_authorization.rb +9 -3
- data/app/models/admin/session.rb +33 -0
- data/app/models/admin/user.rb +34 -8
- data/app/models/koi/current.rb +10 -2
- data/app/views/admin/admin_users/index.html.erb +6 -5
- data/app/views/admin/admin_users/show.html.erb +9 -7
- data/app/views/admin/profiles/show.html.erb +2 -1
- data/config/locales/koi.en.yml +1 -0
- data/db/migrate/20260501000000_add_last_sign_out_at_to_admin_users.rb +7 -0
- data/db/migrate/20260525041029_create_admin_sessions.rb +13 -0
- data/db/migrate/20260525111759_clean_up_admin_user_timestamps.rb +23 -0
- data/lib/generators/koi/admin/admin_generator.rb +1 -1
- data/lib/generators/koi/admin_controller/admin_controller_generator.rb +1 -1
- data/lib/generators/koi/admin_route/admin_route_generator.rb +5 -3
- data/lib/generators/koi/helpers/attribute_helpers.rb +2 -2
- data/lib/koi/config.rb +8 -8
- data/lib/koi/form/builder.rb +2 -2
- data/lib/koi/middleware/admin_authentication.rb +24 -26
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b2d934ca47c301704a15d567990aeca4d8e8fb4a2612de0c5cd163629b7aa28
|
|
4
|
+
data.tar.gz: 1bc8579dba8ec097e767410de41d3c46e0f559e62ba9bb65e5bb4fe1cfefeb71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a8e4c2381708c16a86b340a2c7280ce515b7f34536c78fcf99a4752f58024c1d00c238611c900049903ffe5628a6e93d6bed9e899747fc244c40386b723ec138
|
|
7
|
+
data.tar.gz: 59739a3bf9a50646df7076a2a66ce86c0d2c751bcf4eabebf57a89cb5e70783afad8998920a7f385a9bc05856a2ca27bdd8261442eac77e3774492306680c7f3
|
|
@@ -80,7 +80,7 @@ module Admin
|
|
|
80
80
|
private
|
|
81
81
|
|
|
82
82
|
def set_admin_user
|
|
83
|
-
@admin_user = Admin::User.with_archived.find(params
|
|
83
|
+
@admin_user = Admin::User.with_archived.find(params.expect(:id))
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def admin_user_params
|
|
@@ -94,6 +94,8 @@ module Admin
|
|
|
94
94
|
attribute :name, :string
|
|
95
95
|
attribute :email, :string
|
|
96
96
|
attribute :last_sign_in_at, :date
|
|
97
|
+
attribute :last_sign_out_at, :date
|
|
98
|
+
attribute :sessions_count, :integer
|
|
97
99
|
attribute :sign_in_count, :integer
|
|
98
100
|
attribute :password_login, :enum, scope: :has_password_login, multiple: false
|
|
99
101
|
attribute :passkey, :boolean, scope: :has_passkey
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Admin
|
|
4
4
|
class DeviceAuthorizationsController < ApplicationController
|
|
5
5
|
EXPIRES_IN = 10.minutes
|
|
6
|
-
INTERVAL
|
|
6
|
+
INTERVAL = 5
|
|
7
7
|
|
|
8
8
|
rate_limit to: 3, within: 1.minute, only: :create
|
|
9
9
|
skip_before_action :verify_authenticity_token, only: :create
|
|
@@ -52,7 +52,7 @@ module Admin
|
|
|
52
52
|
private
|
|
53
53
|
|
|
54
54
|
def set_device_authorization
|
|
55
|
-
@device_authorization = Admin::DeviceAuthorization.find_by!(user_code: params
|
|
55
|
+
@device_authorization = Admin::DeviceAuthorization.find_by!(user_code: params.expect(:user_code))
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
end
|
|
@@ -6,8 +6,13 @@ module Admin
|
|
|
6
6
|
include Koi::Controller::RecordsAuthentication
|
|
7
7
|
|
|
8
8
|
before_action :redirect_authenticated, only: %i[new], if: :admin_signed_in?
|
|
9
|
+
before_action :requires_session_authentication!, only: %i[destroy]
|
|
9
10
|
before_action :authenticate_local_admin, only: %i[new], if: -> { Koi.config.authenticate_local_admins }
|
|
10
11
|
|
|
12
|
+
rate_limit to: 10, within: 3.minutes, only: :create, with: -> do
|
|
13
|
+
redirect_to(new_admin_session_path, status: :too_many_requests, alert: t("koi.auth.too_many_requests"))
|
|
14
|
+
end
|
|
15
|
+
|
|
11
16
|
attr_reader :admin_user
|
|
12
17
|
|
|
13
18
|
def new
|
|
@@ -29,21 +34,19 @@ module Admin
|
|
|
29
34
|
create_session_with_password
|
|
30
35
|
elsif session_params[:email].present?
|
|
31
36
|
# conversational flow, ask for password regardless of email
|
|
32
|
-
admin_user = Admin::User.new(session_params.slice(:email))
|
|
37
|
+
@admin_user = Admin::User.new(session_params.slice(:email))
|
|
33
38
|
|
|
34
39
|
render(:password, status: :unprocessable_content, locals: { admin_user: })
|
|
35
40
|
else
|
|
36
41
|
# invalid request, re-render new
|
|
37
|
-
admin_user = Admin::User.new
|
|
42
|
+
@admin_user = Admin::User.new
|
|
38
43
|
|
|
39
44
|
render(:new, status: :unprocessable_content, locals: { admin_user: })
|
|
40
45
|
end
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
def destroy
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
session[:admin_user_id] = nil
|
|
49
|
+
destroy_admin_sessions!(Koi::Current.admin_user)
|
|
47
50
|
|
|
48
51
|
redirect_to new_admin_session_path
|
|
49
52
|
end
|
|
@@ -52,7 +55,7 @@ module Admin
|
|
|
52
55
|
|
|
53
56
|
def create_session_with_password
|
|
54
57
|
# constant time lookup for user with password verification
|
|
55
|
-
admin_user = Admin::User.authenticate_by(session_params.slice(:email, :password))
|
|
58
|
+
@admin_user = Admin::User.authenticate_by(session_params.slice(:email, :password))
|
|
56
59
|
|
|
57
60
|
if admin_user.present? && admin_user.requires_otp?
|
|
58
61
|
session[:pending_admin_user_id] = admin_user.id
|
|
@@ -61,7 +64,7 @@ module Admin
|
|
|
61
64
|
elsif admin_user.present?
|
|
62
65
|
admin_sign_in(admin_user)
|
|
63
66
|
else
|
|
64
|
-
admin_user = Admin::User.new(session_params.slice(:email, :password))
|
|
67
|
+
@admin_user = Admin::User.new(session_params.slice(:email, :password))
|
|
65
68
|
admin_user.errors.add(:email, :invalid)
|
|
66
69
|
|
|
67
70
|
render(:new, status: :unprocessable_content, locals: { admin_user: })
|
|
@@ -70,15 +73,15 @@ module Admin
|
|
|
70
73
|
|
|
71
74
|
def create_session_with_token
|
|
72
75
|
# assume that the previous step injected the user's ID into the session and remove it regardless of outcome
|
|
73
|
-
admin_user = Admin::User.find_by(id: session.delete(:pending_admin_user_id))
|
|
76
|
+
@admin_user = Admin::User.find_by(id: session.delete(:pending_admin_user_id))
|
|
74
77
|
|
|
75
78
|
if admin_user&.otp&.verify(session_params[:token],
|
|
76
79
|
drift_ahead: 15,
|
|
77
80
|
drift_behind: 15,
|
|
78
|
-
after: admin_user.
|
|
81
|
+
after: admin_user.last_sign_in_at)
|
|
79
82
|
admin_sign_in(admin_user)
|
|
80
83
|
else
|
|
81
|
-
admin_user = Admin::User.new
|
|
84
|
+
@admin_user = Admin::User.new
|
|
82
85
|
admin_user.errors.add(:email, :invalid)
|
|
83
86
|
|
|
84
87
|
render(:new, status: :unprocessable_content, locals: { admin_user: })
|
|
@@ -86,10 +89,10 @@ module Admin
|
|
|
86
89
|
end
|
|
87
90
|
|
|
88
91
|
def create_session_with_webauthn
|
|
89
|
-
if (admin_user = webauthn_authenticate!(session_params[:response]))
|
|
92
|
+
if (@admin_user = webauthn_authenticate!(session_params[:response]))
|
|
90
93
|
admin_sign_in(admin_user)
|
|
91
94
|
else
|
|
92
|
-
admin_user = Admin::User.new
|
|
95
|
+
@admin_user = Admin::User.new
|
|
93
96
|
admin_user.errors.add(:email, :invalid)
|
|
94
97
|
|
|
95
98
|
render(:new, status: :unprocessable_content, locals: { admin_user: })
|
|
@@ -103,11 +106,14 @@ module Admin
|
|
|
103
106
|
def authenticate_local_admin
|
|
104
107
|
return if admin_signed_in? || !Rails.env.development?
|
|
105
108
|
|
|
106
|
-
|
|
109
|
+
@admin_user = Admin::User.find_by(email: [
|
|
110
|
+
ENV.fetch("EMAIL", nil),
|
|
111
|
+
"#{ENV.fetch('USER', nil)}@katalyst.com.au",
|
|
112
|
+
].compact)
|
|
107
113
|
|
|
108
|
-
return
|
|
114
|
+
return if admin_user.nil?
|
|
109
115
|
|
|
110
|
-
|
|
116
|
+
create_admin_session!(admin_user)
|
|
111
117
|
|
|
112
118
|
flash.delete(:redirect) if (redirect = flash[:redirect])
|
|
113
119
|
|
|
@@ -115,9 +121,7 @@ module Admin
|
|
|
115
121
|
end
|
|
116
122
|
|
|
117
123
|
def admin_sign_in(admin_user)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
session[:admin_user_id] = admin_user.id
|
|
124
|
+
create_admin_session!(admin_user)
|
|
121
125
|
|
|
122
126
|
redirect_to(url_from(params[:redirect].presence) || admin_dashboard_path, status: :see_other)
|
|
123
127
|
end
|
|
@@ -22,9 +22,7 @@ module Admin
|
|
|
22
22
|
|
|
23
23
|
def update
|
|
24
24
|
if (@admin_user = Admin::User.find_by_token_for(:password_reset, params[:token]))
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
session[:admin_user_id] = admin_user.id
|
|
25
|
+
create_admin_session!(admin_user)
|
|
28
26
|
|
|
29
27
|
if admin_user.credentials.any?
|
|
30
28
|
redirect_to(admin_root_path, status: :see_other)
|
|
@@ -39,7 +37,7 @@ module Admin
|
|
|
39
37
|
private
|
|
40
38
|
|
|
41
39
|
def set_admin_user
|
|
42
|
-
@admin_user = Admin::User.find(params
|
|
40
|
+
@admin_user = Admin::User.find(params.expect(:admin_user_id))
|
|
43
41
|
end
|
|
44
42
|
end
|
|
45
43
|
end
|
|
@@ -14,10 +14,14 @@ module Koi
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def admin_signed_in?
|
|
17
|
+
resume_admin_session
|
|
18
|
+
|
|
17
19
|
Koi::Current.admin_user.present?
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def current_admin_user
|
|
23
|
+
resume_admin_session
|
|
24
|
+
|
|
21
25
|
Koi::Current.admin_user
|
|
22
26
|
end
|
|
23
27
|
|
|
@@ -25,7 +29,17 @@ module Koi
|
|
|
25
29
|
alias_method :current_admin, :current_admin_user
|
|
26
30
|
|
|
27
31
|
def requires_session_authentication!
|
|
28
|
-
head(:forbidden)
|
|
32
|
+
head(:forbidden) unless resume_admin_session
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Admin::Session, nil]
|
|
36
|
+
def resume_admin_session
|
|
37
|
+
Koi::Current.session ||= find_admin_session_by_cookie
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Admin::Session, nil]
|
|
41
|
+
def find_admin_session_by_cookie
|
|
42
|
+
Admin::Session.find_by(id: cookies.signed[:admin_session_id]) if cookies.signed[:admin_session_id]
|
|
29
43
|
end
|
|
30
44
|
|
|
31
45
|
module Test
|
|
@@ -37,14 +51,18 @@ module Koi
|
|
|
37
51
|
before do
|
|
38
52
|
view.singleton_class.module_eval do
|
|
39
53
|
def admin_signed_in?
|
|
40
|
-
|
|
54
|
+
resume_admin_session.present?
|
|
41
55
|
end
|
|
42
56
|
|
|
43
57
|
def current_admin_user
|
|
44
|
-
|
|
58
|
+
resume_admin_session&.admin
|
|
45
59
|
end
|
|
46
60
|
|
|
47
61
|
alias_method :current_admin, :current_admin_user
|
|
62
|
+
|
|
63
|
+
def resume_admin_session
|
|
64
|
+
Koi::Current.session ||= admin_user.sessions.new if respond_to?(:admin_user)
|
|
65
|
+
end
|
|
48
66
|
end
|
|
49
67
|
end
|
|
50
68
|
end
|
|
@@ -3,32 +3,22 @@
|
|
|
3
3
|
module Koi
|
|
4
4
|
module Controller
|
|
5
5
|
module RecordsAuthentication
|
|
6
|
-
def
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
def create_admin_session!(admin_user)
|
|
7
|
+
admin_user.sessions.create!(
|
|
8
|
+
user_agent: request.user_agent,
|
|
9
|
+
ip_address: request.remote_ip,
|
|
10
|
+
).tap do |session|
|
|
11
|
+
Koi::Current.session = session
|
|
12
|
+
cookies.signed.permanent[:admin_session_id] = { value: session.id, httponly: true, same_site: :lax }
|
|
13
|
+
end
|
|
11
14
|
end
|
|
12
15
|
|
|
13
|
-
def
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
admin_user.current_sign_in_at = Time.current
|
|
17
|
-
admin_user.current_sign_in_ip = request.remote_ip
|
|
18
|
-
admin_user.sign_in_count += 1
|
|
19
|
-
|
|
20
|
-
admin_user.save!
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def record_sign_out!(admin_user)
|
|
24
|
-
return unless admin_user
|
|
25
|
-
|
|
26
|
-
update_last_sign_in(admin_user)
|
|
27
|
-
|
|
28
|
-
admin_user.current_sign_in_at = nil
|
|
29
|
-
admin_user.current_sign_in_ip = nil
|
|
16
|
+
def destroy_admin_sessions!(admin_user)
|
|
17
|
+
admin_user.device_authorizations.destroy_all
|
|
18
|
+
admin_user.sessions.destroy_all
|
|
30
19
|
|
|
31
|
-
|
|
20
|
+
Koi::Current.session = nil
|
|
21
|
+
cookies.delete(:admin_session_id)
|
|
32
22
|
end
|
|
33
23
|
end
|
|
34
24
|
end
|
|
@@ -15,15 +15,21 @@ module Admin
|
|
|
15
15
|
|
|
16
16
|
self.table_name = :admin_device_authorizations
|
|
17
17
|
|
|
18
|
-
belongs_to :admin_user, class_name: "Admin::User", optional: true
|
|
19
|
-
|
|
20
18
|
enum :status, %w[pending approved denied consumed].index_with(&:to_s)
|
|
21
19
|
|
|
20
|
+
generates_token_for(:api_access, expires_in: 12.hours) { admin_user&.last_sign_in_at }
|
|
21
|
+
|
|
22
22
|
validates :device_code_digest, presence: true, uniqueness: true
|
|
23
23
|
validates :request_expires_at, presence: true
|
|
24
24
|
validates :status, presence: true, inclusion: { in: statuses.values }
|
|
25
25
|
validates :user_code, presence: true, uniqueness: true
|
|
26
26
|
|
|
27
|
+
belongs_to :admin_user,
|
|
28
|
+
class_name: "Admin::User",
|
|
29
|
+
counter_cache: true,
|
|
30
|
+
inverse_of: :device_authorizations,
|
|
31
|
+
optional: true
|
|
32
|
+
|
|
27
33
|
def self.issue!(requested_ip:, user_agent:)
|
|
28
34
|
device_code = SecureRandom.urlsafe_base64(32)
|
|
29
35
|
|
|
@@ -57,7 +63,7 @@ module Admin
|
|
|
57
63
|
raise TokenError.new(error)
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
access_token = device_authorization.
|
|
66
|
+
access_token = device_authorization.generate_token_for(:api_access)
|
|
61
67
|
device_authorization.consume!(token_expires_in:)
|
|
62
68
|
|
|
63
69
|
{
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Admin
|
|
4
|
+
class Session < ApplicationRecord
|
|
5
|
+
self.table_name = :admin_sessions
|
|
6
|
+
|
|
7
|
+
belongs_to :admin,
|
|
8
|
+
class_name: "Admin::User",
|
|
9
|
+
counter_cache: true,
|
|
10
|
+
inverse_of: :sessions
|
|
11
|
+
|
|
12
|
+
after_create_commit :record_sign_in!
|
|
13
|
+
after_destroy_commit :record_sign_out!
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def record_sign_in!
|
|
18
|
+
admin.update!(
|
|
19
|
+
last_sign_in_at: created_at,
|
|
20
|
+
last_sign_in_ip: ip_address,
|
|
21
|
+
sign_in_count: admin.sign_in_count + 1,
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record_sign_out!
|
|
26
|
+
return if admin.destroyed?
|
|
27
|
+
|
|
28
|
+
admin.update!(last_sign_out_at: Time.current)
|
|
29
|
+
rescue ActiveRecord::RecordNotFound
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/app/models/admin/user.rb
CHANGED
|
@@ -11,20 +11,32 @@ module Admin
|
|
|
11
11
|
|
|
12
12
|
self.table_name = :admins
|
|
13
13
|
|
|
14
|
-
# disable validations for password_digest
|
|
15
|
-
has_secure_password validations: false
|
|
16
|
-
|
|
17
|
-
generates_token_for(:api_access, expires_in: 12.hours) { current_sign_in_at }
|
|
18
|
-
generates_token_for(:password_reset, expires_in: 30.minutes) { current_sign_in_at }
|
|
19
|
-
|
|
20
|
-
has_many :credentials, inverse_of: :admin, class_name: "Admin::Credential", dependent: :destroy
|
|
21
|
-
|
|
22
14
|
attribute :password_login, :string
|
|
23
15
|
enum :password_login, { none: "none", password_only: "password_only", mfa: "mfa" }, prefix: true
|
|
24
16
|
|
|
17
|
+
# disable validations for password_digest – we don't require a password (i.e. passkey only user)
|
|
18
|
+
has_secure_password validations: false
|
|
19
|
+
|
|
20
|
+
normalizes :email, with: ->(e) { e.strip.downcase }
|
|
21
|
+
|
|
25
22
|
validates :name, :email, presence: true
|
|
26
23
|
validates :email, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
27
24
|
|
|
25
|
+
generates_token_for(:password_reset, expires_in: 30.minutes) { last_sign_in_at }
|
|
26
|
+
|
|
27
|
+
has_many :credentials,
|
|
28
|
+
class_name: "Admin::Credential",
|
|
29
|
+
dependent: :destroy,
|
|
30
|
+
inverse_of: :admin
|
|
31
|
+
has_many :device_authorizations,
|
|
32
|
+
class_name: "Admin::DeviceAuthorization",
|
|
33
|
+
dependent: :destroy,
|
|
34
|
+
inverse_of: :admin_user
|
|
35
|
+
has_many :sessions,
|
|
36
|
+
class_name: "Admin::Session",
|
|
37
|
+
dependent: :destroy,
|
|
38
|
+
inverse_of: :admin
|
|
39
|
+
|
|
28
40
|
scope :alphabetical, -> { order(name: :asc) }
|
|
29
41
|
|
|
30
42
|
if "PgSearch::Model".safe_constantize
|
|
@@ -69,6 +81,20 @@ module Admin
|
|
|
69
81
|
end
|
|
70
82
|
alias passkey passkey?
|
|
71
83
|
|
|
84
|
+
# Describe the last time the user signed in or out. For self-reflection, excludes the current session.
|
|
85
|
+
#
|
|
86
|
+
# @return [ActiveSupport::TimeWithZone, nil] the last time the user was active
|
|
87
|
+
def last_active_at
|
|
88
|
+
[last_sign_in_at, last_sign_out_at].compact.max
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Describe the last time the user signed in or out, excluding the current session (for self).
|
|
92
|
+
#
|
|
93
|
+
# @return [ActiveSupport::TimeWithZone, nil] the last time the user was active
|
|
94
|
+
def previous_active_at(current_session = Koi::Current.session)
|
|
95
|
+
[last_sign_out_at, sessions.where.not(id: current_session.id).maximum(:created_at)].compact.max
|
|
96
|
+
end
|
|
97
|
+
|
|
72
98
|
def password_login
|
|
73
99
|
if password_digest.blank?
|
|
74
100
|
:none
|
data/app/models/koi/current.rb
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module Koi
|
|
4
4
|
class Current < ActiveSupport::CurrentAttributes
|
|
5
|
-
# @return [Admin::
|
|
6
|
-
attribute :
|
|
5
|
+
# @return [Admin::DeviceAuthorization, nil]
|
|
6
|
+
attribute :device_authorization
|
|
7
|
+
|
|
8
|
+
# @return [Admin::Session, nil]
|
|
9
|
+
attribute :session
|
|
10
|
+
|
|
11
|
+
# @return [Admin::User, nil]
|
|
12
|
+
def admin_user
|
|
13
|
+
device_authorization&.admin_user || session&.admin
|
|
14
|
+
end
|
|
7
15
|
end
|
|
8
16
|
end
|
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
<%= table_with(collection:) do |row| %>
|
|
19
19
|
<% row.select %>
|
|
20
|
-
<% row.link
|
|
21
|
-
<% row.text
|
|
22
|
-
<% row.enum
|
|
23
|
-
<% row.boolean
|
|
20
|
+
<% row.link(:name, url: :admin_admin_user_path) %>
|
|
21
|
+
<% row.text(:email) %>
|
|
22
|
+
<% row.enum(:password_login, label: "Password") %>
|
|
23
|
+
<% row.boolean(:credentials, label: "Passkey") do |cell| %>
|
|
24
24
|
<%= cell.value.any? ? "Yes" : "No" %>
|
|
25
25
|
<% end %>
|
|
26
|
-
<% row.date
|
|
26
|
+
<% row.date(:last_active_at, label: "Last active") %>
|
|
27
|
+
<% row.text(:sessions_count, label: "Sessions") %>
|
|
27
28
|
<% end %>
|
|
28
29
|
|
|
29
30
|
<%= table_pagination_with(collection:) %>
|
|
@@ -16,13 +16,15 @@
|
|
|
16
16
|
<% end %>
|
|
17
17
|
|
|
18
18
|
<%= summary_table_with(model: admin_user) do |row| %>
|
|
19
|
-
<% row.text
|
|
20
|
-
<% row.text
|
|
21
|
-
<% row.date
|
|
22
|
-
<% row.
|
|
23
|
-
<% row.
|
|
24
|
-
<% row.
|
|
25
|
-
<% row.boolean
|
|
19
|
+
<% row.text(:name) %>
|
|
20
|
+
<% row.text(:email) %>
|
|
21
|
+
<% row.date(:created_at) %>
|
|
22
|
+
<% row.text(:sessions_count, label: "Sessions") %>
|
|
23
|
+
<% row.date(:last_active_at, label: "Last active") %>
|
|
24
|
+
<% row.text(:sign_in_count, label: "Total logins") %>
|
|
25
|
+
<% row.boolean(:passkey) %>
|
|
26
|
+
<% row.boolean(:otp, label: "MFA") %>
|
|
27
|
+
<% row.boolean(:archived?) %>
|
|
26
28
|
<% end %>
|
|
27
29
|
|
|
28
30
|
<% unless admin_user.archived? %>
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
<% row.text :name %>
|
|
13
13
|
<% row.text :email %>
|
|
14
14
|
<% row.date :created_at %>
|
|
15
|
-
<% row.
|
|
15
|
+
<% row.text(:sessions_count, label: "Sessions") %>
|
|
16
|
+
<% row.date(:previous_active_at, label: "Last active") %>
|
|
16
17
|
<% row.boolean :passkey %>
|
|
17
18
|
<% row.boolean(:otp, label: "MFA") do |otp| %>
|
|
18
19
|
<span class="repel">
|
data/config/locales/koi.en.yml
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateAdminSessions < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :admin_sessions do |t|
|
|
6
|
+
t.references :admin, null: false, foreign_key: true
|
|
7
|
+
t.string :ip_address
|
|
8
|
+
t.string :user_agent
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CleanUpAdminUserTimestamps < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
# remove 'last sign in' tracking columns, database sessions make these redundant
|
|
6
|
+
remove_column :admins, :last_sign_in_at, :datetime, precision: nil
|
|
7
|
+
remove_column :admins, :last_sign_in_ip, :string
|
|
8
|
+
|
|
9
|
+
# rename 'current sign in' columns to 'last sign in', as all active sessions are invalidated by this release
|
|
10
|
+
rename_column :admins, :current_sign_in_at, :last_sign_in_at
|
|
11
|
+
rename_column :admins, :current_sign_in_ip, :last_sign_in_ip
|
|
12
|
+
|
|
13
|
+
# add counter caches for admin visibility
|
|
14
|
+
add_column :admins, :device_authorizations_count, :integer, default: 0, null: false
|
|
15
|
+
add_column :admins, :sessions_count, :integer, default: 0, null: false
|
|
16
|
+
|
|
17
|
+
up_only do
|
|
18
|
+
change_column :admins, :created_at, :datetime, precision: 6
|
|
19
|
+
change_column :admins, :updated_at, :datetime, precision: 6
|
|
20
|
+
change_column :admins, :last_sign_in_at, :datetime, precision: 6
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -8,7 +8,7 @@ module Koi
|
|
|
8
8
|
|
|
9
9
|
hook_for :admin_controller, in: :koi, as: :admin, type: :boolean, default: true do |instance, controller|
|
|
10
10
|
args, opts, config = @_initializer
|
|
11
|
-
opts
|
|
11
|
+
opts ||= {}
|
|
12
12
|
|
|
13
13
|
# setting model_name so that generators will use the controller_class_path
|
|
14
14
|
instance.invoke controller, args, { model_name: instance.name, **opts }, config
|
|
@@ -41,7 +41,7 @@ module Koi
|
|
|
41
41
|
def permitted_params
|
|
42
42
|
attachments, others = attributes_names.partition { |name| attachments?(name) }
|
|
43
43
|
params = others.map { |name| ":#{name}" }
|
|
44
|
-
params
|
|
44
|
+
params += attachments.map { |name| "#{name}: []" }
|
|
45
45
|
params.join(", ")
|
|
46
46
|
end
|
|
47
47
|
end
|
|
@@ -86,8 +86,10 @@ module Koi
|
|
|
86
86
|
if (namespace_match = match_file(route_file, namespace_pattern))
|
|
87
87
|
base_indent, *, existing_block_indent = namespace_match.captures.compact.map(&:length)
|
|
88
88
|
existing_line_pattern = /^ {,#{existing_block_indent}}\S.+\n?/
|
|
89
|
-
routing_code
|
|
90
|
-
|
|
89
|
+
routing_code = rebase_indentation(routing_code, base_indent + 2).gsub(
|
|
90
|
+
existing_line_pattern, ""
|
|
91
|
+
)
|
|
92
|
+
namespace_pattern = /#{Regexp.escape namespace_match.to_s}/
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
inject_into_file route_file, routing_code, after: namespace_pattern, verbose: true, force: false
|
|
@@ -115,7 +117,7 @@ module Koi
|
|
|
115
117
|
resource_match = match_file(route_file, block_pattern)
|
|
116
118
|
|
|
117
119
|
*, existing_block_indent, _ = resource_match.captures.compact.map(&:length)
|
|
118
|
-
routing_code
|
|
120
|
+
routing_code = rebase_indentation(routing_code, existing_block_indent + 2)
|
|
119
121
|
|
|
120
122
|
inject_into_file route_file, routing_code, after: block_pattern, verbose: true, force: false
|
|
121
123
|
end
|
data/lib/koi/config.rb
CHANGED
|
@@ -16,15 +16,15 @@ module Koi
|
|
|
16
16
|
:site_name
|
|
17
17
|
|
|
18
18
|
def initialize
|
|
19
|
-
@admin_name
|
|
20
|
-
@authenticate_local_admins
|
|
21
|
-
@resource_name_candidates
|
|
22
|
-
@admin_stylesheet
|
|
19
|
+
@admin_name = "Koi"
|
|
20
|
+
@authenticate_local_admins = Rails.env.development?
|
|
21
|
+
@resource_name_candidates = %i[title name]
|
|
22
|
+
@admin_stylesheet = "admin"
|
|
23
23
|
@admin_javascript_entry_point = "@katalyst/koi"
|
|
24
|
-
@document_mime_types
|
|
25
|
-
@document_size_limit
|
|
26
|
-
@image_mime_types
|
|
27
|
-
@image_size_limit
|
|
24
|
+
@document_mime_types = %w[image/png image/gif image/jpeg image/webp application/pdf audio/*].freeze
|
|
25
|
+
@document_size_limit = 10.megabytes
|
|
26
|
+
@image_mime_types = %w[image/png image/gif image/jpeg image/webp].freeze
|
|
27
|
+
@image_size_limit = 10.megabytes
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|
data/lib/koi/form/builder.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Koi
|
|
|
41
41
|
# @see GOVUKDesignSystemFormBuilder::Builder#govuk_document_field
|
|
42
42
|
def govuk_document_field(attribute_name, hint: {}, **, &)
|
|
43
43
|
if hint.is_a?(Hash)
|
|
44
|
-
max_size
|
|
44
|
+
max_size = hint.fetch(:max_size, Koi.config.document_size_limit)
|
|
45
45
|
hint[:text] ||= t("helpers.hint.default.document", max_size: @template.number_to_human_size(max_size))
|
|
46
46
|
end
|
|
47
47
|
|
|
@@ -52,7 +52,7 @@ module Koi
|
|
|
52
52
|
# @see GOVUKDesignSystemFormBuilder::Builder#govuk_image_field
|
|
53
53
|
def govuk_image_field(attribute_name, hint: {}, **, &)
|
|
54
54
|
if hint.is_a?(Hash)
|
|
55
|
-
max_size
|
|
55
|
+
max_size = hint.fetch(:max_size, Koi.config.image_size_limit)
|
|
56
56
|
hint[:text] ||= t("helpers.hint.default.document", max_size: @template.number_to_human_size(max_size))
|
|
57
57
|
end
|
|
58
58
|
|
|
@@ -17,17 +17,20 @@ module Koi
|
|
|
17
17
|
|
|
18
18
|
def admin_call(env)
|
|
19
19
|
request = ActionDispatch::Request.new(env)
|
|
20
|
-
|
|
20
|
+
cookies = request.cookie_jar
|
|
21
21
|
|
|
22
22
|
# Always retrieve user to ensure we are not vulnerable to timing attacks
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
if (token = bearer_token(request:)).present?
|
|
24
|
+
Koi::Current.device_authorization = find_device_authentication(token:)
|
|
25
|
+
|
|
26
|
+
# disable Rails session for API requests
|
|
27
|
+
request.session_options[:skip] = true
|
|
28
|
+
elsif (session_id = cookies.signed[:admin_session_id]).present?
|
|
29
|
+
Koi::Current.session = find_admin_session(session_id:)
|
|
30
|
+
end
|
|
28
31
|
|
|
29
32
|
# Remove from session if not found
|
|
30
|
-
|
|
33
|
+
cookies.delete(:admin_session_id) unless authenticated?
|
|
31
34
|
|
|
32
35
|
if requires_authentication?(request) && !authenticated?
|
|
33
36
|
unauthorized_response(request)
|
|
@@ -35,40 +38,29 @@ module Koi
|
|
|
35
38
|
@app.call(env)
|
|
36
39
|
end
|
|
37
40
|
ensure
|
|
38
|
-
Koi::Current.
|
|
41
|
+
Koi::Current.reset
|
|
39
42
|
end
|
|
40
43
|
|
|
41
44
|
private
|
|
42
45
|
|
|
43
46
|
def requires_authentication?(request)
|
|
44
|
-
!request.path.starts_with?("/admin/session") && !device_flow_request?(request)
|
|
47
|
+
!request.path.starts_with?("/admin/session") && !device_flow_request?(request:)
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
def authenticated?
|
|
48
51
|
Koi::Current.admin_user.present?
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
def
|
|
52
|
-
token
|
|
53
|
-
return if token.blank?
|
|
54
|
-
|
|
55
|
-
request.session_options[:skip] = true
|
|
56
|
-
|
|
57
|
-
Admin::User.find_by_token_for(:api_access, token)
|
|
54
|
+
def find_device_authentication(token:)
|
|
55
|
+
Admin::DeviceAuthorization.find_by_token_for(:api_access, token)
|
|
58
56
|
end
|
|
59
57
|
|
|
60
|
-
def
|
|
61
|
-
Admin::
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def bearer_token(request)
|
|
65
|
-
return nil if request.authorization.blank?
|
|
66
|
-
|
|
67
|
-
request.authorization.match(/^Bearer (?<token>.+)$/)&.named_captures&.fetch("token", nil)
|
|
58
|
+
def find_admin_session(session_id:)
|
|
59
|
+
Admin::Session.find_by(id: session_id)
|
|
68
60
|
end
|
|
69
61
|
|
|
70
62
|
def unauthorized_response(request)
|
|
71
|
-
if bearer_token(request).present?
|
|
63
|
+
if bearer_token(request:).present?
|
|
72
64
|
# If the user provided a token, it was not valid, and the request requires authentication
|
|
73
65
|
[401, {}, []]
|
|
74
66
|
else
|
|
@@ -82,9 +74,15 @@ module Koi
|
|
|
82
74
|
end
|
|
83
75
|
end
|
|
84
76
|
|
|
85
|
-
def device_flow_request?(request)
|
|
77
|
+
def device_flow_request?(request:)
|
|
86
78
|
request.post? && %w[/admin/device_authorizations /admin/device_tokens].include?(request.path)
|
|
87
79
|
end
|
|
80
|
+
|
|
81
|
+
def bearer_token(request:)
|
|
82
|
+
return nil if request.authorization.blank?
|
|
83
|
+
|
|
84
|
+
request.authorization.match(/^Bearer (?<token>.+)$/)&.named_captures&.fetch("token", nil)
|
|
85
|
+
end
|
|
88
86
|
end
|
|
89
87
|
end
|
|
90
88
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: katalyst-koi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Katalyst Interactive
|
|
@@ -396,6 +396,7 @@ files:
|
|
|
396
396
|
- app/models/admin/collection.rb
|
|
397
397
|
- app/models/admin/credential.rb
|
|
398
398
|
- app/models/admin/device_authorization.rb
|
|
399
|
+
- app/models/admin/session.rb
|
|
399
400
|
- app/models/admin/user.rb
|
|
400
401
|
- app/models/application_record.rb
|
|
401
402
|
- app/models/concerns/koi/model/archivable.rb
|
|
@@ -470,6 +471,9 @@ files:
|
|
|
470
471
|
- db/migrate/20241214060913_add_otp_secret_to_admin_users.rb
|
|
471
472
|
- db/migrate/20250204060748_create_well_knowns.rb
|
|
472
473
|
- db/migrate/20260413014834_create_admin_device_authorizations.rb
|
|
474
|
+
- db/migrate/20260501000000_add_last_sign_out_at_to_admin_users.rb
|
|
475
|
+
- db/migrate/20260525041029_create_admin_sessions.rb
|
|
476
|
+
- db/migrate/20260525111759_clean_up_admin_user_timestamps.rb
|
|
473
477
|
- db/seeds.rb
|
|
474
478
|
- lib/generators/koi/admin/USAGE
|
|
475
479
|
- lib/generators/koi/admin/admin_generator.rb
|
|
@@ -529,7 +533,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
529
533
|
- !ruby/object:Gem::Version
|
|
530
534
|
version: '0'
|
|
531
535
|
requirements: []
|
|
532
|
-
rubygems_version: 4.0.
|
|
536
|
+
rubygems_version: 4.0.10
|
|
533
537
|
specification_version: 4
|
|
534
538
|
summary: Koi CMS admin framework
|
|
535
539
|
test_files: []
|