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 +4 -4
- data/README.md +6 -12
- data/app/controllers/admin/admin_users_controller.rb +2 -0
- data/app/controllers/admin/credentials_controller.rb +1 -0
- data/app/controllers/admin/device_authorizations_controller.rb +58 -0
- data/app/controllers/admin/device_tokens_controller.rb +18 -0
- data/app/controllers/admin/otps_controller.rb +2 -0
- data/app/controllers/admin/profiles_controller.rb +2 -0
- data/app/controllers/admin/sessions_controller.rb +8 -7
- data/app/controllers/admin/tokens_controller.rb +1 -3
- data/app/controllers/concerns/koi/controller/has_admin_users.rb +8 -11
- data/app/controllers/concerns/koi/controller/has_webauthn.rb +3 -2
- data/app/controllers/concerns/koi/controller/records_authentication.rb +22 -9
- data/app/controllers/concerns/koi/controller.rb +7 -0
- data/app/jobs/admin/device_authorizations_cleanup_job.rb +9 -0
- data/app/models/admin/device_authorization.rb +113 -0
- data/app/models/admin/user.rb +1 -0
- data/app/models/koi/current.rb +8 -0
- data/app/views/admin/device_authorizations/show.html.erb +38 -0
- data/app/views/layouts/koi/_application_navigation.html.erb +2 -2
- data/config/routes.rb +3 -0
- data/db/migrate/20260413014834_create_admin_device_authorizations.rb +20 -0
- data/db/migrate/20260501000000_add_last_sign_out_at_to_admin_users.rb +7 -0
- data/lib/koi/middleware/admin_authentication.rb +70 -10
- data/spec/factories/admin_device_authorizations.rb +29 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: beb8b89b9542bb29583d8908cbc41746eaa2eaac7540ebd0c1ba0f7818e2a524
|
|
4
|
+
data.tar.gz: 0f80f33a3561c9913540115cff337c5df17ca50789435973ad1b1df11438cbf8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
7
|
+
In most cases, Koi is installed using `https://github.com/katalyst/koi-template`.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
gem "katalyst-koi"
|
|
11
|
-
```
|
|
9
|
+
## API Access
|
|
12
10
|
|
|
13
|
-
|
|
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/
|
|
15
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/katalyst/koi.
|
|
22
16
|
|
|
23
17
|
## License
|
|
24
18
|
|
|
25
|
-
|
|
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
|
|
@@ -41,9 +41,7 @@ module Admin
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def destroy
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
Koi::Current.admin_user = admin_user
|
|
119
120
|
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
rescue ActiveRecord::RecordNotFound
|
|
19
|
-
false
|
|
17
|
+
Koi::Current.admin_user.present?
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def current_admin_user
|
|
23
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
7
|
-
|
|
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 =
|
|
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
|
|
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,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
|
data/app/models/admin/user.rb
CHANGED
|
@@ -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,38 @@
|
|
|
1
|
+
<%# locals: (device_authorization:) %>
|
|
2
|
+
|
|
3
|
+
<%= content_for(:roadblock) do %>
|
|
4
|
+
<header>
|
|
5
|
+
<icon aria-hidden="true" class="icon" data-icon="koi"> </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
|
|
17
|
+
<% if Koi::Current.admin_user %>
|
|
18
18
|
<ul class="navigation-group | flow" role="list">
|
|
19
19
|
<li>
|
|
20
|
-
<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
|
|
@@ -19,27 +19,87 @@ module Koi
|
|
|
19
19
|
request = ActionDispatch::Request.new(env)
|
|
20
20
|
session = ActionDispatch::Request::Session.find(request)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
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
|
+
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.
|
|
533
|
+
rubygems_version: 4.0.6
|
|
525
534
|
specification_version: 4
|
|
526
535
|
summary: Koi CMS admin framework
|
|
527
536
|
test_files: []
|