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.
Files changed (32) 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 +3 -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 +22 -18
  8. data/app/controllers/admin/tokens_controller.rb +2 -4
  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 -23
  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/20260501000000_add_last_sign_out_at_to_admin_users.rb +7 -0
  23. data/db/migrate/20260525041029_create_admin_sessions.rb +13 -0
  24. data/db/migrate/20260525111759_clean_up_admin_user_timestamps.rb +23 -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 -26
  32. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a64f8ea56e122d169ec52b8c304c79a1b81289bba216d89d2b97c9f0aaf9e24
4
- data.tar.gz: e07ac3a269c0cf2958f33fb9e3da25dec86a84d27f25f3d0314ecc4d4fed5f4f
3
+ metadata.gz: 3b2d934ca47c301704a15d567990aeca4d8e8fb4a2612de0c5cd163629b7aa28
4
+ data.tar.gz: 1bc8579dba8ec097e767410de41d3c46e0f559e62ba9bb65e5bb4fe1cfefeb71
5
5
  SHA512:
6
- metadata.gz: c337a6f84e5ce47739dc9242207b9eafd2a0fbf6c9bf3f5b89338910175b3598dc575037849fb1a844e1a1608b54cb9e08e891286047b1548bbe127484ae446a
7
- data.tar.gz: 84b32b36bd70147f24f0b975df0b722ba37f860185c1df483c1947c55ec17c74d1332e16b29d30b95f1f0f59021dced986db12ae005f7962349f3cd30df74246
6
+ metadata.gz: a8e4c2381708c16a86b340a2c7280ce515b7f34536c78fcf99a4752f58024c1d00c238611c900049903ffe5628a6e93d6bed9e899747fc244c40386b723ec138
7
+ data.tar.gz: 59739a3bf9a50646df7076a2a66ce86c0d2c751bcf4eabebf57a89cb5e70783afad8998920a7f385a9bc05856a2ca27bdd8261442eac77e3774492306680c7f3
@@ -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
@@ -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
@@ -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,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
- record_sign_out!(Koi::Current.admin_user)
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.current_sign_in_at)
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
- Koi::Current.admin_user = Admin::User.find_by(email: "#{ENV.fetch('USER', nil)}@katalyst.com.au")
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 unless admin_signed_in?
114
+ return if admin_user.nil?
109
115
 
110
- session[:admin_user_id] = Koi::Current.admin_user.id
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
- record_sign_in!(admin_user)
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
- record_sign_in!(admin_user)
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[:admin_user_id])
40
+ @admin_user = Admin::User.find(params.expect(:admin_user_id))
43
41
  end
44
42
  end
45
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,32 +3,22 @@
3
3
  module Koi
4
4
  module Controller
5
5
  module RecordsAuthentication
6
- def update_last_sign_in(admin_user)
7
- return if admin_user.current_sign_in_at.blank?
8
-
9
- admin_user.last_sign_in_at = admin_user.current_sign_in_at
10
- admin_user.last_sign_in_ip = admin_user.current_sign_in_ip
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 record_sign_in!(admin_user)
14
- update_last_sign_in(admin_user)
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
- admin_user.save!
20
+ Koi::Current.session = nil
21
+ cookies.delete(:admin_session_id)
32
22
  end
33
23
  end
34
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddLastSignOutAtToAdminUsers < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :admins, :last_sign_out_at, :datetime
6
+ end
7
+ end
@@ -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 += 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,17 +17,20 @@ 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:)
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
- session.delete(:admin_user_id) if session.has_key?(:admin_user_id) && !authenticated?
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.admin_user = nil
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 bearer_admin_user(request)
52
- token = bearer_token(request)
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 session_admin_user(session)
61
- Admin::User.find_by(id: session[:admin_user_id])
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.5.0
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.6
536
+ rubygems_version: 4.0.10
533
537
  specification_version: 4
534
538
  summary: Koi CMS admin framework
535
539
  test_files: []