katalyst-koi 5.6.0 → 5.7.1
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 +2 -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 +19 -16
- data/app/controllers/admin/tokens_controller.rb +1 -1
- 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 -36
- 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/20230531063707_update_admin_users.rb +2 -0
- data/db/migrate/20260525041029_create_admin_sessions.rb +13 -0
- data/db/migrate/20260525111759_clean_up_admin_user_timestamps.rb +25 -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 -42
- data/spec/factories/admin_sessions.rb +9 -0
- 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: eebb6eb78b256d385d6ac52f7868d61ccf877177c69d18095eee36e69d7ce710
|
|
4
|
+
data.tar.gz: 450a8d50dd9f5139690f10d394eb3bc7e7228ae5890070620ff8e7535d2c1c72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee7575375983c5b232c063560741ad608aadbc22705f1158942944c8dcdc16638ad72adc2e193f6a2e6ebead9386e020ead1eb7d5d392a3f4b816857011545fe
|
|
7
|
+
data.tar.gz: 21994dd6bd359b5064f3f07dd923adfd74cf5e1822a5be93bda786c3dd2f550f7410548dbd939120f8910aaba0435c537d4b6f139add00ca19254e0879268626
|
|
@@ -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
|
|
@@ -95,6 +95,7 @@ module Admin
|
|
|
95
95
|
attribute :email, :string
|
|
96
96
|
attribute :last_sign_in_at, :date
|
|
97
97
|
attribute :last_sign_out_at, :date
|
|
98
|
+
attribute :sessions_count, :integer
|
|
98
99
|
attribute :sign_in_count, :integer
|
|
99
100
|
attribute :password_login, :enum, scope: :has_password_login, multiple: false
|
|
100
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,19 +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
|
-
|
|
49
|
+
destroy_admin_sessions!(Koi::Current.admin_user)
|
|
45
50
|
|
|
46
51
|
redirect_to new_admin_session_path
|
|
47
52
|
end
|
|
@@ -50,7 +55,7 @@ module Admin
|
|
|
50
55
|
|
|
51
56
|
def create_session_with_password
|
|
52
57
|
# constant time lookup for user with password verification
|
|
53
|
-
admin_user = Admin::User.authenticate_by(session_params.slice(:email, :password))
|
|
58
|
+
@admin_user = Admin::User.authenticate_by(session_params.slice(:email, :password))
|
|
54
59
|
|
|
55
60
|
if admin_user.present? && admin_user.requires_otp?
|
|
56
61
|
session[:pending_admin_user_id] = admin_user.id
|
|
@@ -59,7 +64,7 @@ module Admin
|
|
|
59
64
|
elsif admin_user.present?
|
|
60
65
|
admin_sign_in(admin_user)
|
|
61
66
|
else
|
|
62
|
-
admin_user = Admin::User.new(session_params.slice(:email, :password))
|
|
67
|
+
@admin_user = Admin::User.new(session_params.slice(:email, :password))
|
|
63
68
|
admin_user.errors.add(:email, :invalid)
|
|
64
69
|
|
|
65
70
|
render(:new, status: :unprocessable_content, locals: { admin_user: })
|
|
@@ -68,15 +73,15 @@ module Admin
|
|
|
68
73
|
|
|
69
74
|
def create_session_with_token
|
|
70
75
|
# assume that the previous step injected the user's ID into the session and remove it regardless of outcome
|
|
71
|
-
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))
|
|
72
77
|
|
|
73
78
|
if admin_user&.otp&.verify(session_params[:token],
|
|
74
79
|
drift_ahead: 15,
|
|
75
80
|
drift_behind: 15,
|
|
76
|
-
after: admin_user.
|
|
81
|
+
after: admin_user.last_sign_in_at)
|
|
77
82
|
admin_sign_in(admin_user)
|
|
78
83
|
else
|
|
79
|
-
admin_user = Admin::User.new
|
|
84
|
+
@admin_user = Admin::User.new
|
|
80
85
|
admin_user.errors.add(:email, :invalid)
|
|
81
86
|
|
|
82
87
|
render(:new, status: :unprocessable_content, locals: { admin_user: })
|
|
@@ -84,10 +89,10 @@ module Admin
|
|
|
84
89
|
end
|
|
85
90
|
|
|
86
91
|
def create_session_with_webauthn
|
|
87
|
-
if (admin_user = webauthn_authenticate!(session_params[:response]))
|
|
92
|
+
if (@admin_user = webauthn_authenticate!(session_params[:response]))
|
|
88
93
|
admin_sign_in(admin_user)
|
|
89
94
|
else
|
|
90
|
-
admin_user = Admin::User.new
|
|
95
|
+
@admin_user = Admin::User.new
|
|
91
96
|
admin_user.errors.add(:email, :invalid)
|
|
92
97
|
|
|
93
98
|
render(:new, status: :unprocessable_content, locals: { admin_user: })
|
|
@@ -101,14 +106,14 @@ module Admin
|
|
|
101
106
|
def authenticate_local_admin
|
|
102
107
|
return if admin_signed_in? || !Rails.env.development?
|
|
103
108
|
|
|
104
|
-
|
|
109
|
+
@admin_user = Admin::User.find_by(email: [
|
|
105
110
|
ENV.fetch("EMAIL", nil),
|
|
106
111
|
"#{ENV.fetch('USER', nil)}@katalyst.com.au",
|
|
107
112
|
].compact)
|
|
108
113
|
|
|
109
|
-
return
|
|
114
|
+
return if admin_user.nil?
|
|
110
115
|
|
|
111
|
-
create_admin_session!
|
|
116
|
+
create_admin_session!(admin_user)
|
|
112
117
|
|
|
113
118
|
flash.delete(:redirect) if (redirect = flash[:redirect])
|
|
114
119
|
|
|
@@ -116,9 +121,7 @@ module Admin
|
|
|
116
121
|
end
|
|
117
122
|
|
|
118
123
|
def admin_sign_in(admin_user)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
create_admin_session!
|
|
124
|
+
create_admin_session!(admin_user)
|
|
122
125
|
|
|
123
126
|
redirect_to(url_from(params[:redirect].presence) || admin_dashboard_path, status: :see_other)
|
|
124
127
|
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,45 +3,22 @@
|
|
|
3
3
|
module Koi
|
|
4
4
|
module Controller
|
|
5
5
|
module RecordsAuthentication
|
|
6
|
-
def create_admin_session!(admin_user
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
admin_user.save!
|
|
16
|
-
|
|
17
|
-
session[:admin_user_id] = admin_user.id
|
|
18
|
-
session[:admin_user_signed_in_at] = sign_in_at.iso8601
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def destroy_admin_session!(admin_user = Koi::Current.admin_user)
|
|
22
|
-
session[:admin_user_id] = nil
|
|
23
|
-
session[:admin_user_signed_in_at] = nil
|
|
24
|
-
|
|
25
|
-
return unless admin_user
|
|
26
|
-
|
|
27
|
-
sign_out_at = Time.current
|
|
28
|
-
|
|
29
|
-
update_last_sign_in(admin_user)
|
|
30
|
-
|
|
31
|
-
admin_user.last_sign_out_at = sign_out_at
|
|
32
|
-
admin_user.current_sign_in_at = nil
|
|
33
|
-
admin_user.current_sign_in_ip = nil
|
|
34
|
-
|
|
35
|
-
admin_user.save!
|
|
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
|
|
36
14
|
end
|
|
37
15
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return if admin_user.current_sign_in_at.blank?
|
|
16
|
+
def destroy_admin_sessions!(admin_user)
|
|
17
|
+
admin_user.device_authorizations.destroy_all
|
|
18
|
+
admin_user.sessions.destroy_all
|
|
42
19
|
|
|
43
|
-
|
|
44
|
-
|
|
20
|
+
Koi::Current.session = nil
|
|
21
|
+
cookies.delete(:admin_session_id)
|
|
45
22
|
end
|
|
46
23
|
end
|
|
47
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Rails/BulkChangeTable
|
|
3
4
|
class UpdateAdminUsers < ActiveRecord::Migration[7.0]
|
|
4
5
|
class Admin < ApplicationRecord; end
|
|
5
6
|
|
|
@@ -38,3 +39,4 @@ class UpdateAdminUsers < ActiveRecord::Migration[7.0]
|
|
|
38
39
|
remove_column :admins, :name, :string
|
|
39
40
|
end
|
|
40
41
|
end
|
|
42
|
+
# rubocop:enable Rails/BulkChangeTable
|
|
@@ -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,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Rails/BulkChangeTable
|
|
4
|
+
class CleanUpAdminUserTimestamps < ActiveRecord::Migration[8.1]
|
|
5
|
+
def change
|
|
6
|
+
# remove 'last sign in' tracking columns, database sessions make these redundant
|
|
7
|
+
remove_column :admins, :last_sign_in_at, :datetime, precision: nil
|
|
8
|
+
remove_column :admins, :last_sign_in_ip, :string
|
|
9
|
+
|
|
10
|
+
# rename 'current sign in' columns to 'last sign in', as all active sessions are invalidated by this release
|
|
11
|
+
rename_column :admins, :current_sign_in_at, :last_sign_in_at
|
|
12
|
+
rename_column :admins, :current_sign_in_ip, :last_sign_in_ip
|
|
13
|
+
|
|
14
|
+
# add counter caches for admin visibility
|
|
15
|
+
add_column :admins, :device_authorizations_count, :integer, default: 0, null: false
|
|
16
|
+
add_column :admins, :sessions_count, :integer, default: 0, null: false
|
|
17
|
+
|
|
18
|
+
up_only do
|
|
19
|
+
change_column :admins, :created_at, :datetime, precision: 6
|
|
20
|
+
change_column :admins, :updated_at, :datetime, precision: 6
|
|
21
|
+
change_column :admins, :last_sign_in_at, :datetime, precision: 6
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
# rubocop:enable Rails/BulkChangeTable
|
|
@@ -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,74 +17,50 @@ 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
|
-
else
|
|
26
|
-
session_admin_user(session)
|
|
27
|
-
end
|
|
23
|
+
if (token = bearer_token(request:)).present?
|
|
24
|
+
Koi::Current.device_authorization = find_device_authentication(token:)
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
session
|
|
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:)
|
|
33
30
|
end
|
|
34
31
|
|
|
32
|
+
# Remove from session if not found
|
|
33
|
+
cookies.delete(:admin_session_id) unless authenticated?
|
|
34
|
+
|
|
35
35
|
if requires_authentication?(request) && !authenticated?
|
|
36
36
|
unauthorized_response(request)
|
|
37
37
|
else
|
|
38
38
|
@app.call(env)
|
|
39
39
|
end
|
|
40
40
|
ensure
|
|
41
|
-
Koi::Current.
|
|
41
|
+
Koi::Current.reset
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
private
|
|
45
45
|
|
|
46
46
|
def requires_authentication?(request)
|
|
47
|
-
!request.path.starts_with?("/admin/session") && !device_flow_request?(request)
|
|
47
|
+
!request.path.starts_with?("/admin/session") && !device_flow_request?(request:)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def authenticated?
|
|
51
51
|
Koi::Current.admin_user.present?
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
def
|
|
55
|
-
token
|
|
56
|
-
return if token.blank?
|
|
57
|
-
|
|
58
|
-
request.session_options[:skip] = true
|
|
59
|
-
|
|
60
|
-
Admin::User.find_by_token_for(:api_access, token)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def session_admin_user(session)
|
|
64
|
-
admin_user = Admin::User.find_by(id: session[:admin_user_id])
|
|
65
|
-
return unless admin_user
|
|
66
|
-
|
|
67
|
-
signed_in_at = session_signed_in_at(session)
|
|
68
|
-
return if signed_in_at.blank?
|
|
69
|
-
return if admin_user.last_sign_out_at.present? && signed_in_at < admin_user.last_sign_out_at
|
|
70
|
-
|
|
71
|
-
admin_user
|
|
54
|
+
def find_device_authentication(token:)
|
|
55
|
+
Admin::DeviceAuthorization.find_by_token_for(:api_access, token)
|
|
72
56
|
end
|
|
73
57
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
rescue ArgumentError
|
|
77
|
-
nil
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def bearer_token(request)
|
|
81
|
-
return nil if request.authorization.blank?
|
|
82
|
-
|
|
83
|
-
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)
|
|
84
60
|
end
|
|
85
61
|
|
|
86
62
|
def unauthorized_response(request)
|
|
87
|
-
if bearer_token(request).present?
|
|
63
|
+
if bearer_token(request:).present?
|
|
88
64
|
# If the user provided a token, it was not valid, and the request requires authentication
|
|
89
65
|
[401, {}, []]
|
|
90
66
|
else
|
|
@@ -98,9 +74,15 @@ module Koi
|
|
|
98
74
|
end
|
|
99
75
|
end
|
|
100
76
|
|
|
101
|
-
def device_flow_request?(request)
|
|
77
|
+
def device_flow_request?(request:)
|
|
102
78
|
request.post? && %w[/admin/device_authorizations /admin/device_tokens].include?(request.path)
|
|
103
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
|
|
104
86
|
end
|
|
105
87
|
end
|
|
106
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.1
|
|
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
|
|
@@ -471,6 +472,8 @@ files:
|
|
|
471
472
|
- db/migrate/20250204060748_create_well_knowns.rb
|
|
472
473
|
- db/migrate/20260413014834_create_admin_device_authorizations.rb
|
|
473
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
|
|
474
477
|
- db/seeds.rb
|
|
475
478
|
- lib/generators/koi/admin/USAGE
|
|
476
479
|
- lib/generators/koi/admin/admin_generator.rb
|
|
@@ -508,6 +511,7 @@ files:
|
|
|
508
511
|
- lib/koi/middleware/url_redirect.rb
|
|
509
512
|
- lib/koi/release.rb
|
|
510
513
|
- spec/factories/admin_device_authorizations.rb
|
|
514
|
+
- spec/factories/admin_sessions.rb
|
|
511
515
|
- spec/factories/admins.rb
|
|
512
516
|
- spec/factories/url_rewrites.rb
|
|
513
517
|
- spec/factories/well_knowns.rb
|
|
@@ -530,7 +534,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
530
534
|
- !ruby/object:Gem::Version
|
|
531
535
|
version: '0'
|
|
532
536
|
requirements: []
|
|
533
|
-
rubygems_version: 4.0.
|
|
537
|
+
rubygems_version: 4.0.10
|
|
534
538
|
specification_version: 4
|
|
535
539
|
summary: Koi CMS admin framework
|
|
536
540
|
test_files: []
|