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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/koi/header/edit_component.rb +1 -1
  3. data/app/components/koi/tables/cells/link_component.rb +2 -2
  4. data/app/controllers/admin/admin_users_controller.rb +2 -1
  5. data/app/controllers/admin/credentials_controller.rb +1 -1
  6. data/app/controllers/admin/device_authorizations_controller.rb +2 -2
  7. data/app/controllers/admin/sessions_controller.rb +19 -16
  8. data/app/controllers/admin/tokens_controller.rb +1 -1
  9. data/app/controllers/admin/url_rewrites_controller.rb +1 -1
  10. data/app/controllers/admin/well_knowns_controller.rb +1 -1
  11. data/app/controllers/concerns/koi/controller/has_admin_users.rb +21 -3
  12. data/app/controllers/concerns/koi/controller/records_authentication.rb +13 -36
  13. data/app/controllers/concerns/koi/controller.rb +1 -1
  14. data/app/models/admin/device_authorization.rb +9 -3
  15. data/app/models/admin/session.rb +33 -0
  16. data/app/models/admin/user.rb +34 -8
  17. data/app/models/koi/current.rb +10 -2
  18. data/app/views/admin/admin_users/index.html.erb +6 -5
  19. data/app/views/admin/admin_users/show.html.erb +9 -7
  20. data/app/views/admin/profiles/show.html.erb +2 -1
  21. data/config/locales/koi.en.yml +1 -0
  22. data/db/migrate/20230531063707_update_admin_users.rb +2 -0
  23. data/db/migrate/20260525041029_create_admin_sessions.rb +13 -0
  24. data/db/migrate/20260525111759_clean_up_admin_user_timestamps.rb +25 -0
  25. data/lib/generators/koi/admin/admin_generator.rb +1 -1
  26. data/lib/generators/koi/admin_controller/admin_controller_generator.rb +1 -1
  27. data/lib/generators/koi/admin_route/admin_route_generator.rb +5 -3
  28. data/lib/generators/koi/helpers/attribute_helpers.rb +2 -2
  29. data/lib/koi/config.rb +8 -8
  30. data/lib/koi/form/builder.rb +2 -2
  31. data/lib/koi/middleware/admin_authentication.rb +24 -42
  32. data/spec/factories/admin_sessions.rb +9 -0
  33. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: beb8b89b9542bb29583d8908cbc41746eaa2eaac7540ebd0c1ba0f7818e2a524
4
- data.tar.gz: 0f80f33a3561c9913540115cff337c5df17ca50789435973ad1b1df11438cbf8
3
+ metadata.gz: eebb6eb78b256d385d6ac52f7868d61ccf877177c69d18095eee36e69d7ce710
4
+ data.tar.gz: 450a8d50dd9f5139690f10d394eb3bc7e7228ae5890070620ff8e7535d2c1c72
5
5
  SHA512:
6
- metadata.gz: dbcc40de9887a60357c6e03280c51e1172bebdbd72f34ef549a57b9ea31566cf7b79353e802a4678bf7d7580dc7a10e9a3095aabc6c44b08983b88027e1d59f4
7
- data.tar.gz: 35debfa95facb9a6419d462352a42e08d8dfe6810ff0432b6b41d653ce1decf5373e27e25fc4c0ff17457c9949258228abe022c4b5e89e2ac5c642a8012ba4f5
6
+ metadata.gz: ee7575375983c5b232c063560741ad608aadbc22705f1158942944c8dcdc16638ad72adc2e193f6a2e6ebead9386e020ead1eb7d5d392a3f4b816857011545fe
7
+ data.tar.gz: 21994dd6bd359b5064f3f07dd923adfd74cf5e1822a5be93bda786c3dd2f550f7410548dbd939120f8910aaba0435c537d4b6f139add00ca19254e0879268626
@@ -11,7 +11,7 @@ module Koi
11
11
  super()
12
12
 
13
13
  @resource = resource
14
- @title = title
14
+ @title = title
15
15
 
16
16
  @header = HeaderComponent.new(title: self.title)
17
17
  end
@@ -26,8 +26,8 @@ module Koi
26
26
  def initialize(cell:, url:, default_url:, **)
27
27
  super(**)
28
28
 
29
- @cell = cell
30
- @url = url
29
+ @cell = cell
30
+ @url = url
31
31
  @default_url = default_url
32
32
  end
33
33
 
@@ -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[:id])
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
@@ -55,7 +55,7 @@ module Admin
55
55
  end
56
56
 
57
57
  def set_credential
58
- @credential = Credential.find(params[:id])
58
+ @credential = Credential.find(params.expect(:id))
59
59
  @admin_user = @credential.admin
60
60
  end
61
61
 
@@ -3,7 +3,7 @@
3
3
  module Admin
4
4
  class DeviceAuthorizationsController < ApplicationController
5
5
  EXPIRES_IN = 10.minutes
6
- INTERVAL = 5
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[:user_code])
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
- destroy_admin_session!
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.current_sign_in_at)
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
- Koi::Current.admin_user = Admin::User.find_by(email: [
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 unless admin_signed_in?
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
- Koi::Current.admin_user = admin_user
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
@@ -37,7 +37,7 @@ module Admin
37
37
  private
38
38
 
39
39
  def set_admin_user
40
- @admin_user = Admin::User.find(params[:admin_user_id])
40
+ @admin_user = Admin::User.find(params.expect(:admin_user_id))
41
41
  end
42
42
  end
43
43
  end
@@ -53,7 +53,7 @@ module Admin
53
53
  private
54
54
 
55
55
  def set_url_rewrite
56
- @url_rewrite = ::UrlRewrite.find(params[:id])
56
+ @url_rewrite = ::UrlRewrite.find(params.expect(:id))
57
57
  end
58
58
 
59
59
  def url_rewrite_params
@@ -53,7 +53,7 @@ module Admin
53
53
  private
54
54
 
55
55
  def set_well_known
56
- @well_known = ::WellKnown.find(params[:id])
56
+ @well_known = ::WellKnown.find(params.expect(:id))
57
57
  end
58
58
 
59
59
  def well_known_params
@@ -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) if session[:admin_user_id].blank?
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
- Koi::Current.admin_user.present?
54
+ resume_admin_session.present?
41
55
  end
42
56
 
43
57
  def current_admin_user
44
- Koi::Current.admin_user = (respond_to?(:admin_user) ? admin_user : nil)
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 = Koi::Current.admin_user)
7
- sign_in_at = Time.current
8
-
9
- update_last_sign_in(admin_user)
10
-
11
- admin_user.current_sign_in_at = sign_in_at
12
- admin_user.current_sign_in_ip = request.remote_ip
13
- admin_user.sign_in_count += 1
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
- private
39
-
40
- def update_last_sign_in(admin_user)
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
- admin_user.last_sign_in_at = admin_user.current_sign_in_at
44
- admin_user.last_sign_in_ip = admin_user.current_sign_in_ip
20
+ Koi::Current.session = nil
21
+ cookies.delete(:admin_session_id)
45
22
  end
46
23
  end
47
24
  end
@@ -9,7 +9,7 @@ module Koi
9
9
  include HasAttachments
10
10
  include Katalyst::Tables::Backend
11
11
 
12
- if (pagy = "Pagy::Method".safe_constantize)
12
+ if (pagy = "Pagy::Method".safe_constantize)
13
13
  include pagy
14
14
  elsif (pagy = "Pagy::Backend".safe_constantize)
15
15
  # @deprecated Pagy <43
@@ -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.admin_user.generate_token_for(:api_access)
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
@@ -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
@@ -2,7 +2,15 @@
2
2
 
3
3
  module Koi
4
4
  class Current < ActiveSupport::CurrentAttributes
5
- # @return [Admin::User]
6
- attribute :admin_user
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 :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| %>
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 :last_sign_in_at, label: "Last active" %>
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 :name %>
20
- <% row.text :email %>
21
- <% row.date :created_at %>
22
- <% row.date :last_sign_in_at, label: "Last access" %>
23
- <% row.boolean :passkey %>
24
- <% row.boolean :otp, label: "MFA" %>
25
- <% row.boolean :archived? %>
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.date(:last_sign_in_at, label: "Last access") %>
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">
@@ -11,6 +11,7 @@ en:
11
11
  auth:
12
12
  otp_app_name: "%{host}/admin"
13
13
  token_invalid: "Token invalid or consumed already"
14
+ too_many_requests: "Try again later."
14
15
  labels:
15
16
  new: New
16
17
  search: Search
@@ -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 += attachments.map { |name| "#{name}: []" }
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 = rebase_indentation(routing_code, base_indent + 2).gsub(existing_line_pattern, "")
90
- namespace_pattern = /#{Regexp.escape namespace_match.to_s}/
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 = rebase_indentation(routing_code, existing_block_indent + 2)
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
@@ -28,8 +28,8 @@ module Koi
28
28
  super(name, type, **)
29
29
 
30
30
  @association = association
31
- @attachment = attachment
32
- @enum = enum
31
+ @attachment = attachment
32
+ @enum = enum
33
33
  end
34
34
 
35
35
  def association?
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 = "Koi"
20
- @authenticate_local_admins = Rails.env.development?
21
- @resource_name_candidates = %i[title name]
22
- @admin_stylesheet = "admin"
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 = %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
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
@@ -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 = hint.fetch(:max_size, Koi.config.document_size_limit)
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 = hint.fetch(:max_size, Koi.config.image_size_limit)
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
- session = ActionDispatch::Request::Session.find(request)
20
+ cookies = request.cookie_jar
21
21
 
22
22
  # Always retrieve user to ensure we are not vulnerable to timing attacks
23
- Koi::Current.admin_user = if bearer_token(request).present?
24
- bearer_admin_user(request)
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
- # Remove from session if not found
30
- if session.has_key?(:admin_user_id) && !authenticated?
31
- session.delete(:admin_user_id)
32
- session.delete(:admin_user_signed_in_at)
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.admin_user = nil
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 bearer_admin_user(request)
55
- token = bearer_token(request)
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 session_signed_in_at(session)
75
- Time.zone.parse(session[:admin_user_signed_in_at].to_s)
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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :admin_session, class: "Admin::Session" do
5
+ admin
6
+ ip_address { "127.0.0.1" }
7
+ user_agent { "RSpec" }
8
+ end
9
+ 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.6.0
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.6
537
+ rubygems_version: 4.0.10
534
538
  specification_version: 4
535
539
  summary: Koi CMS admin framework
536
540
  test_files: []