katalyst-koi 5.4.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 559fd46fee1b7874a6593314f73ca7c2f205b31650457f35b171ba4165ff3e91
4
- data.tar.gz: 7a8ad3000aa8d852adf95943574b0a56aa1eafd72446a9f9cd0abe107d968072
3
+ metadata.gz: beb8b89b9542bb29583d8908cbc41746eaa2eaac7540ebd0c1ba0f7818e2a524
4
+ data.tar.gz: 0f80f33a3561c9913540115cff337c5df17ca50789435973ad1b1df11438cbf8
5
5
  SHA512:
6
- metadata.gz: 4f039daf2363cf733d2d271f6a544b843d0ab1b1746db24f2be2177065a8bd4d9495bb7570def596ee86b674558f7dd8632d846522fe6a00b678019752858a17
7
- data.tar.gz: d1c3be39ebf0fdfceb631a9fd86ffbf86e655f18b6b489b92243b261d4b52deb9165a99ea40b1c3ed62bb7c8fae5457fb5033fd2abd42c474320b3da04c2fdce
6
+ metadata.gz: dbcc40de9887a60357c6e03280c51e1172bebdbd72f34ef549a57b9ea31566cf7b79353e802a4678bf7d7580dc7a10e9a3095aabc6c44b08983b88027e1d59f4
7
+ data.tar.gz: 35debfa95facb9a6419d462352a42e08d8dfe6810ff0432b6b41d653ce1decf5373e27e25fc4c0ff17457c9949258228abe022c4b5e89e2ac5c642a8012ba4f5
data/README.md CHANGED
@@ -1,25 +1,19 @@
1
- # Koi
1
+ # Katalyst Koi
2
2
 
3
3
  Koi is a framework for building Rails admin functionality.
4
4
 
5
5
  ## Installation
6
6
 
7
- Add this line to your application's Gemfile:
7
+ In most cases, Koi is installed using `https://github.com/katalyst/koi-template`.
8
8
 
9
- ```ruby
10
- gem "katalyst-koi"
11
- ```
9
+ ## API Access
12
10
 
13
- And then execute:
14
-
15
- ```sh
16
- bundle install
17
- ```
11
+ See [docs/api-access.md](docs/api-access.md).
18
12
 
19
13
  ## Contributing
20
14
 
21
- Bug reports and pull requests are welcome on GitHub at https://github.com/katalyst/katalyst-tables.
15
+ Bug reports and pull requests are welcome on GitHub at https://github.com/katalyst/koi.
22
16
 
23
17
  ## License
24
18
 
25
- Katalyst Tables is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
19
+ Koi is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Admin
4
4
  class AdminUsersController < ApplicationController
5
+ before_action :requires_session_authentication!
5
6
  before_action :set_admin_user, only: %i[show edit update destroy]
6
7
 
7
8
  attr_reader :admin_user
@@ -93,6 +94,7 @@ module Admin
93
94
  attribute :name, :string
94
95
  attribute :email, :string
95
96
  attribute :last_sign_in_at, :date
97
+ attribute :last_sign_out_at, :date
96
98
  attribute :sign_in_count, :integer
97
99
  attribute :password_login, :enum, scope: :has_password_login, multiple: false
98
100
  attribute :passkey, :boolean, scope: :has_passkey
@@ -4,6 +4,7 @@ module Admin
4
4
  class CredentialsController < ApplicationController
5
5
  include Koi::Controller::HasWebauthn
6
6
 
7
+ before_action :requires_session_authentication!
7
8
  before_action :set_admin_user, only: %i[new create]
8
9
  before_action :set_credential, only: %i[show update destroy]
9
10
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DeviceAuthorizationsController < ApplicationController
5
+ EXPIRES_IN = 10.minutes
6
+ INTERVAL = 5
7
+
8
+ rate_limit to: 3, within: 1.minute, only: :create
9
+ skip_before_action :verify_authenticity_token, only: :create
10
+
11
+ before_action :set_device_authorization, only: %i[show update]
12
+
13
+ attr_reader :device_authorization
14
+
15
+ delegate :admin_user, to: ::Koi::Current
16
+
17
+ def show
18
+ render locals: { device_authorization: }
19
+ end
20
+
21
+ def create
22
+ device_authorization, device_code = Admin::DeviceAuthorization.issue!(
23
+ requested_ip: request.remote_ip,
24
+ user_agent: request.user_agent,
25
+ )
26
+
27
+ render json: {
28
+ device_code:,
29
+ user_code: device_authorization.user_code,
30
+ verification_uri: admin_device_authorization_url(device_authorization.user_code),
31
+ verification_uri_complete: admin_device_authorization_url(device_authorization.user_code),
32
+ expires_in: EXPIRES_IN.to_i,
33
+ interval: INTERVAL,
34
+ }
35
+ end
36
+
37
+ def update
38
+ if device_authorization.actionable?
39
+ case params[:decision]
40
+ when "approve"
41
+ device_authorization.approve!(admin_user:)
42
+ else
43
+ device_authorization.deny!(admin_user:)
44
+ end
45
+
46
+ redirect_to(admin_device_authorization_path(device_authorization.user_code), status: :see_other)
47
+ else
48
+ render(:show, status: :unprocessable_content, locals: { device_authorization: })
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def set_device_authorization
55
+ @device_authorization = Admin::DeviceAuthorization.find_by!(user_code: params[:user_code])
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DeviceTokensController < ApplicationController
5
+ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
6
+
7
+ rate_limit to: 20, within: 1.minute, only: :create
8
+ skip_before_action :verify_authenticity_token, only: :create
9
+
10
+ def create
11
+ return render(json: { error: "invalid_request" }, status: :bad_request) unless params[:grant_type] == GRANT_TYPE
12
+
13
+ render json: Admin::DeviceAuthorization.issue_access_token!(device_code: params[:device_code])
14
+ rescue Admin::DeviceAuthorization::TokenError => e
15
+ render json: { error: e.code }, status: :bad_request
16
+ end
17
+ end
18
+ end
@@ -4,6 +4,8 @@ module Admin
4
4
  class OtpsController < ApplicationController
5
5
  alias_method :admin_user, :current_admin
6
6
 
7
+ before_action :requires_session_authentication!
8
+
7
9
  def new
8
10
  admin_user.otp_secret = ROTP::Base32.random
9
11
 
@@ -4,6 +4,8 @@ module Admin
4
4
  class ProfilesController < ApplicationController
5
5
  include Koi::Controller::HasWebauthn
6
6
 
7
+ before_action :requires_session_authentication!
8
+
7
9
  alias_method :admin_user, :current_admin
8
10
 
9
11
  def show
@@ -41,9 +41,7 @@ module Admin
41
41
  end
42
42
 
43
43
  def destroy
44
- record_sign_out!(current_admin_user)
45
-
46
- session[:admin_user_id] = nil
44
+ destroy_admin_session!
47
45
 
48
46
  redirect_to new_admin_session_path
49
47
  end
@@ -103,11 +101,14 @@ module Admin
103
101
  def authenticate_local_admin
104
102
  return if admin_signed_in? || !Rails.env.development?
105
103
 
106
- @current_admin_user = Admin::User.find_by(email: "#{ENV.fetch('USER', nil)}@katalyst.com.au")
104
+ Koi::Current.admin_user = Admin::User.find_by(email: [
105
+ ENV.fetch("EMAIL", nil),
106
+ "#{ENV.fetch('USER', nil)}@katalyst.com.au",
107
+ ].compact)
107
108
 
108
109
  return unless admin_signed_in?
109
110
 
110
- session[:admin_user_id] = current_admin_user.id
111
+ create_admin_session!
111
112
 
112
113
  flash.delete(:redirect) if (redirect = flash[:redirect])
113
114
 
@@ -115,9 +116,9 @@ module Admin
115
116
  end
116
117
 
117
118
  def admin_sign_in(admin_user)
118
- record_sign_in!(admin_user)
119
+ Koi::Current.admin_user = admin_user
119
120
 
120
- session[:admin_user_id] = admin_user.id
121
+ create_admin_session!
121
122
 
122
123
  redirect_to(url_from(params[:redirect].presence) || admin_dashboard_path, status: :see_other)
123
124
  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)
@@ -14,23 +14,20 @@ module Koi
14
14
  end
15
15
 
16
16
  def admin_signed_in?
17
- current_admin_user.present?
18
- rescue ActiveRecord::RecordNotFound
19
- false
17
+ Koi::Current.admin_user.present?
20
18
  end
21
19
 
22
20
  def current_admin_user
23
- return @current_admin_user if instance_variable_defined?(:@current_admin_user)
24
- return @current_admin_user = nil unless session.has_key?(:admin_user_id)
25
-
26
- @current_admin_user = Admin::User.find(session[:admin_user_id])
27
- ensure
28
- session.delete(:admin_user_id) unless @current_admin_user
21
+ Koi::Current.admin_user
29
22
  end
30
23
 
31
24
  # @deprecated Use current_admin_user instead
32
25
  alias_method :current_admin, :current_admin_user
33
26
 
27
+ def requires_session_authentication!
28
+ head(:forbidden) if session[:admin_user_id].blank?
29
+ end
30
+
34
31
  module Test
35
32
  # Include in view specs to stub out the current admin user
36
33
  module ViewHelper
@@ -40,11 +37,11 @@ module Koi
40
37
  before do
41
38
  view.singleton_class.module_eval do
42
39
  def admin_signed_in?
43
- current_admin_user.present?
40
+ Koi::Current.admin_user.present?
44
41
  end
45
42
 
46
43
  def current_admin_user
47
- respond_to?(:admin_user) ? admin_user : nil
44
+ Koi::Current.admin_user = (respond_to?(:admin_user) ? admin_user : nil)
48
45
  end
49
46
 
50
47
  alias_method :current_admin, :current_admin_user
@@ -27,7 +27,7 @@ module Koi
27
27
  end
28
28
 
29
29
  def webauthn_registration_options_value
30
- user = current_admin_user.tap do |u|
30
+ user = Koi::Current.admin_user.tap do |u|
31
31
  u.update!(webauthn_id: WebAuthn.generate_user_id) unless u.webauthn_id
32
32
  end
33
33
 
@@ -74,7 +74,8 @@ module Koi
74
74
  session.delete(:registration_challenge),
75
75
  )
76
76
 
77
- current_admin_user
77
+ Koi::Current
78
+ .admin_user
78
79
  .credentials
79
80
  .create_with(nickname: webauthn_nickname,
80
81
  public_key: webauthn_credential.public_key,
@@ -3,33 +3,46 @@
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?
6
+ def create_admin_session!(admin_user = Koi::Current.admin_user)
7
+ sign_in_at = Time.current
8
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
11
- end
12
-
13
- def record_sign_in!(admin_user)
14
9
  update_last_sign_in(admin_user)
15
10
 
16
- admin_user.current_sign_in_at = Time.current
11
+ admin_user.current_sign_in_at = sign_in_at
17
12
  admin_user.current_sign_in_ip = request.remote_ip
18
13
  admin_user.sign_in_count += 1
19
14
 
20
15
  admin_user.save!
16
+
17
+ session[:admin_user_id] = admin_user.id
18
+ session[:admin_user_signed_in_at] = sign_in_at.iso8601
21
19
  end
22
20
 
23
- def record_sign_out!(admin_user)
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
+
24
25
  return unless admin_user
25
26
 
27
+ sign_out_at = Time.current
28
+
26
29
  update_last_sign_in(admin_user)
27
30
 
31
+ admin_user.last_sign_out_at = sign_out_at
28
32
  admin_user.current_sign_in_at = nil
29
33
  admin_user.current_sign_in_ip = nil
30
34
 
31
35
  admin_user.save!
32
36
  end
37
+
38
+ private
39
+
40
+ def update_last_sign_in(admin_user)
41
+ return if admin_user.current_sign_in_at.blank?
42
+
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
45
+ end
33
46
  end
34
47
  end
35
48
  end
@@ -38,6 +38,13 @@ module Koi
38
38
  layout -> { turbo_frame_request? ? "koi/frame" : "koi/application" }
39
39
 
40
40
  protect_from_forgery with: :exception
41
+ skip_forgery_protection if: :bearer_token_request?
42
+ end
43
+
44
+ private
45
+
46
+ def bearer_token_request?
47
+ request.authorization.to_s.match?(/\ABearer .+\z/)
41
48
  end
42
49
  end
43
50
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DeviceAuthorizationsCleanupJob < Koi::ApplicationJob
5
+ def perform
6
+ Admin::DeviceAuthorization.where(created_at: ...7.days.ago).delete_all
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DeviceAuthorization < ApplicationRecord
5
+ EXPIRES_IN = 10.minutes
6
+
7
+ class TokenError < StandardError
8
+ attr_reader :code
9
+
10
+ def initialize(code)
11
+ @code = code
12
+ super
13
+ end
14
+ end
15
+
16
+ self.table_name = :admin_device_authorizations
17
+
18
+ belongs_to :admin_user, class_name: "Admin::User", optional: true
19
+
20
+ enum :status, %w[pending approved denied consumed].index_with(&:to_s)
21
+
22
+ validates :device_code_digest, presence: true, uniqueness: true
23
+ validates :request_expires_at, presence: true
24
+ validates :status, presence: true, inclusion: { in: statuses.values }
25
+ validates :user_code, presence: true, uniqueness: true
26
+
27
+ def self.issue!(requested_ip:, user_agent:)
28
+ device_code = SecureRandom.urlsafe_base64(32)
29
+
30
+ device_authorization = create!(
31
+ device_code_digest: digest(device_code),
32
+ user_code: generate_user_code,
33
+ request_expires_at: EXPIRES_IN.from_now,
34
+ requested_ip:,
35
+ user_agent:,
36
+ )
37
+
38
+ [device_authorization, device_code]
39
+ end
40
+
41
+ def self.digest(device_code)
42
+ Digest::SHA256.hexdigest(device_code)
43
+ end
44
+
45
+ def self.generate_user_code
46
+ "#{SecureRandom.alphanumeric(4).upcase}-#{SecureRandom.alphanumeric(4).upcase}"
47
+ end
48
+
49
+ def self.issue_access_token!(device_code:, token_expires_in: 12.hours)
50
+ device_authorization = find_by(device_code_digest: digest(device_code.to_s))
51
+ raise TokenError.new("invalid_grant") unless device_authorization
52
+
53
+ device_authorization.with_lock do
54
+ device_authorization.reload
55
+
56
+ if (error = device_authorization.token_error)
57
+ raise TokenError.new(error)
58
+ end
59
+
60
+ access_token = device_authorization.admin_user.generate_token_for(:api_access)
61
+ device_authorization.consume!(token_expires_in:)
62
+
63
+ {
64
+ access_token:,
65
+ token_type: "Bearer",
66
+ expires_in: token_expires_in.to_i,
67
+ }
68
+ end
69
+ end
70
+
71
+ def expired?
72
+ request_expires_at <= Time.current
73
+ end
74
+
75
+ def issuable?
76
+ approved? && !expired?
77
+ end
78
+
79
+ def token_error
80
+ return "authorization_pending" if pending?
81
+ return "access_denied" if denied?
82
+
83
+ "invalid_grant" if consumed? || expired?
84
+ end
85
+
86
+ def consume!(token_expires_in:)
87
+ update!(
88
+ status: "consumed",
89
+ consumed_at: Time.current,
90
+ token_expires_at: token_expires_in.from_now,
91
+ )
92
+ end
93
+
94
+ def approve!(admin_user:)
95
+ update!(
96
+ status: "approved",
97
+ approved_at: Time.current,
98
+ admin_user:,
99
+ )
100
+ end
101
+
102
+ def deny!(admin_user:)
103
+ update!(
104
+ status: "denied",
105
+ admin_user:,
106
+ )
107
+ end
108
+
109
+ def actionable?
110
+ pending? && !expired?
111
+ end
112
+ end
113
+ end
@@ -14,6 +14,7 @@ module Admin
14
14
  # disable validations for password_digest
15
15
  has_secure_password validations: false
16
16
 
17
+ generates_token_for(:api_access, expires_in: 12.hours) { current_sign_in_at }
17
18
  generates_token_for(:password_reset, expires_in: 30.minutes) { current_sign_in_at }
18
19
 
19
20
  has_many :credentials, inverse_of: :admin, class_name: "Admin::Credential", dependent: :destroy
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ # @return [Admin::User]
6
+ attribute :admin_user
7
+ end
8
+ end
@@ -0,0 +1,38 @@
1
+ <%# locals: (device_authorization:) %>
2
+
3
+ <%= content_for(:roadblock) do %>
4
+ <header>
5
+ <icon aria-hidden="true" class="icon" data-icon="koi">&nbsp;</icon>
6
+ <h1>API access request</h1>
7
+ </header>
8
+
9
+ <% if device_authorization.pending? && !device_authorization.expired? %>
10
+ <p>
11
+ A device is requesting a short-lived API token.
12
+ </p>
13
+ <dl>
14
+ <dt>Code</dt><dd><%= device_authorization.user_code %></dd>
15
+ </dl>
16
+
17
+ <div class="actions">
18
+ <%= button_to("Approve",
19
+ admin_device_authorization_path(device_authorization.user_code),
20
+ method: :patch,
21
+ params: { decision: "approve" },
22
+ class: "button") %>
23
+ <%= button_to("Deny",
24
+ admin_device_authorization_path(device_authorization.user_code),
25
+ method: :patch,
26
+ params: { decision: "deny" },
27
+ class: "button button--secondary") %>
28
+ </div>
29
+ <% end %>
30
+
31
+ <%= summary_table_with(model: device_authorization) do |row| %>
32
+ <% row.enum(:status) %>
33
+ <% row.text(:requested_ip) %>
34
+ <% row.text(:user_agent) %>
35
+ <% row.datetime(:request_expires_at) %>
36
+ <% row.datetime(:token_expires_at) %>
37
+ <% end %>
38
+ <% end %>
@@ -14,10 +14,10 @@
14
14
  keydown.enter->navigation#go keydown.esc->navigation#clear">
15
15
  </div>
16
16
 
17
- <% if current_admin_user %>
17
+ <% if Koi::Current.admin_user %>
18
18
  <ul class="navigation-group | flow" role="list">
19
19
  <li>
20
- <span><%= current_admin_user.name %></span>
20
+ <span><%= Koi::Current.admin_user.name %></span>
21
21
  <ul class="navigation-list | flow" role="list">
22
22
  <li><%= link_to("Profile", main_app.admin_profile_path) %></li>
23
23
  <li><%= link_to("Log out", main_app.admin_session_path, data: { turbo_method: :delete }) %></li>
data/config/routes.rb CHANGED
@@ -13,6 +13,9 @@ Rails.application.routes.draw do
13
13
  resource :cache, only: %i[destroy]
14
14
  resource :dashboard, only: %i[show]
15
15
 
16
+ resources :device_authorizations, param: :user_code, only: %i[create show update]
17
+ resources :device_tokens, only: %i[create]
18
+
16
19
  resource :profile, only: %i[show edit update], shallow: true do
17
20
  resources :credentials, only: %i[show new create update destroy]
18
21
  resource :otp, only: %i[new create destroy]
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAdminDeviceAuthorizations < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :admin_device_authorizations do |t|
6
+ t.string :device_code_digest, null: false, index: { unique: true }
7
+ t.string :user_code, null: false, index: { unique: true }
8
+ t.string :status, null: false, default: "pending"
9
+ t.datetime :request_expires_at, null: false
10
+ t.datetime :approved_at
11
+ t.datetime :consumed_at
12
+ t.datetime :token_expires_at
13
+ t.references :admin_user, null: true, foreign_key: { to_table: :admins }
14
+ t.string :requested_ip
15
+ t.string :user_agent
16
+
17
+ t.timestamps
18
+ end
19
+ end
20
+ end
@@ -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
@@ -19,27 +19,87 @@ module Koi
19
19
  request = ActionDispatch::Request.new(env)
20
20
  session = ActionDispatch::Request::Session.find(request)
21
21
 
22
- if requires_authentication?(request) && !authenticated?(session)
23
- # Set the redirection path for returning the user to their requested path after login
24
- if request.get?
25
- request.flash[:redirect] = request.fullpath
26
- request.commit_flash
27
- end
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
28
28
 
29
- [303, { "Location" => "/admin/session/new" }, []]
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)
33
+ end
34
+
35
+ if requires_authentication?(request) && !authenticated?
36
+ unauthorized_response(request)
30
37
  else
31
38
  @app.call(env)
32
39
  end
40
+ ensure
41
+ Koi::Current.admin_user = nil
33
42
  end
34
43
 
35
44
  private
36
45
 
37
46
  def requires_authentication?(request)
38
- !request.path.starts_with?("/admin/session")
47
+ !request.path.starts_with?("/admin/session") && !device_flow_request?(request)
48
+ end
49
+
50
+ def authenticated?
51
+ Koi::Current.admin_user.present?
52
+ end
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
72
+ end
73
+
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)
84
+ end
85
+
86
+ def unauthorized_response(request)
87
+ if bearer_token(request).present?
88
+ # If the user provided a token, it was not valid, and the request requires authentication
89
+ [401, {}, []]
90
+ else
91
+ if request.get?
92
+ # Set the redirection path for returning the user to their requested path after login
93
+ request.flash[:redirect] = request.fullpath
94
+ request.commit_flash
95
+ end
96
+
97
+ [303, { "Location" => "/admin/session/new" }, []]
98
+ end
39
99
  end
40
100
 
41
- def authenticated?(session)
42
- session[:admin_user_id].present?
101
+ def device_flow_request?(request)
102
+ request.post? && %w[/admin/device_authorizations /admin/device_tokens].include?(request.path)
43
103
  end
44
104
  end
45
105
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :admin_device_authorization, class: "Admin::DeviceAuthorization" do
5
+ sequence(:device_code_digest) { |n| "digest-#{n}" }
6
+ sequence(:user_code) { |n| "CODE-#{n.to_s.rjust(4, '0')}" }
7
+ status { :pending }
8
+ request_expires_at { 10.minutes.from_now }
9
+ requested_ip { "127.0.0.1" }
10
+ user_agent { "RSpec" }
11
+
12
+ trait :approved do
13
+ status { :approved }
14
+ approved_at { Time.current }
15
+ admin_user
16
+ end
17
+
18
+ trait :denied do
19
+ status { :denied }
20
+ admin_user
21
+ end
22
+
23
+ trait :consumed do
24
+ status { :consumed }
25
+ consumed_at { Time.current }
26
+ admin_user
27
+ end
28
+ end
29
+ 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.0
4
+ version: 5.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive
@@ -356,6 +356,8 @@ files:
356
356
  - app/controllers/admin/caches_controller.rb
357
357
  - app/controllers/admin/credentials_controller.rb
358
358
  - app/controllers/admin/dashboards_controller.rb
359
+ - app/controllers/admin/device_authorizations_controller.rb
360
+ - app/controllers/admin/device_tokens_controller.rb
359
361
  - app/controllers/admin/otps_controller.rb
360
362
  - app/controllers/admin/profiles_controller.rb
361
363
  - app/controllers/admin/sessions_controller.rb
@@ -389,13 +391,16 @@ files:
389
391
  - app/javascript/koi/elements/index.js
390
392
  - app/javascript/koi/elements/toolbar.js
391
393
  - app/javascript/koi/utils/transition.js
394
+ - app/jobs/admin/device_authorizations_cleanup_job.rb
392
395
  - app/jobs/koi/application_job.rb
393
396
  - app/models/admin/collection.rb
394
397
  - app/models/admin/credential.rb
398
+ - app/models/admin/device_authorization.rb
395
399
  - app/models/admin/user.rb
396
400
  - app/models/application_record.rb
397
401
  - app/models/concerns/koi/model/archivable.rb
398
402
  - app/models/concerns/koi/model/otp.rb
403
+ - app/models/koi/current.rb
399
404
  - app/models/url_rewrite.rb
400
405
  - app/models/well_known.rb
401
406
  - app/views/admin/admin_users/_form.html.erb
@@ -408,6 +413,7 @@ files:
408
413
  - app/views/admin/credentials/new.html.erb
409
414
  - app/views/admin/credentials/show.html.erb
410
415
  - app/views/admin/dashboards/show.html.erb
416
+ - app/views/admin/device_authorizations/show.html.erb
411
417
  - app/views/admin/otps/_form.html.erb
412
418
  - app/views/admin/otps/create.turbo_stream.erb
413
419
  - app/views/admin/otps/new.html.erb
@@ -463,6 +469,8 @@ files:
463
469
  - db/migrate/20231211005214_add_status_code_to_url_rewrites.rb
464
470
  - db/migrate/20241214060913_add_otp_secret_to_admin_users.rb
465
471
  - db/migrate/20250204060748_create_well_knowns.rb
472
+ - db/migrate/20260413014834_create_admin_device_authorizations.rb
473
+ - db/migrate/20260501000000_add_last_sign_out_at_to_admin_users.rb
466
474
  - db/seeds.rb
467
475
  - lib/generators/koi/admin/USAGE
468
476
  - lib/generators/koi/admin/admin_generator.rb
@@ -499,6 +507,7 @@ files:
499
507
  - lib/koi/middleware/admin_authentication.rb
500
508
  - lib/koi/middleware/url_redirect.rb
501
509
  - lib/koi/release.rb
510
+ - spec/factories/admin_device_authorizations.rb
502
511
  - spec/factories/admins.rb
503
512
  - spec/factories/url_rewrites.rb
504
513
  - spec/factories/well_knowns.rb
@@ -521,7 +530,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
521
530
  - !ruby/object:Gem::Version
522
531
  version: '0'
523
532
  requirements: []
524
- rubygems_version: 4.0.3
533
+ rubygems_version: 4.0.6
525
534
  specification_version: 4
526
535
  summary: Koi CMS admin framework
527
536
  test_files: []