katalyst-koi 4.13.0 → 4.14.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.
@@ -4,14 +4,13 @@ module Admin
4
4
  class SessionsController < ApplicationController
5
5
  include Koi::Controller::HasWebauthn
6
6
 
7
- skip_before_action :authenticate_admin, only: %i[new create]
7
+ before_action :redirect_authenticated, only: %i[new], if: :admin_signed_in?
8
+ before_action :authenticate_local_admin, only: %i[new], if: :authenticate_local_admins?
8
9
 
9
10
  layout "koi/login"
10
11
 
11
12
  def new
12
- return redirect_to admin_dashboard_path if admin_signed_in?
13
-
14
- render :new, locals: { admin_user: Admin::User.new }
13
+ render locals: { admin_user: Admin::User.new }
15
14
  end
16
15
 
17
16
  def create
@@ -20,12 +19,12 @@ module Admin
20
19
 
21
20
  session[:admin_user_id] = admin_user.id
22
21
 
23
- redirect_to admin_dashboard_path, notice: I18n.t("koi.auth.login")
22
+ redirect_to(params[:redirect].presence || admin_dashboard_path, status: :see_other)
24
23
  else
25
24
  admin_user = Admin::User.new(session_params.slice(:email, :password))
26
25
  admin_user.errors.add(:email, "Invalid email or password")
27
26
 
28
- render :new, status: :unprocessable_content, locals: { admin_user: }
27
+ render(:new, status: :unprocessable_content, locals: { admin_user: })
29
28
  end
30
29
  end
31
30
 
@@ -34,11 +33,15 @@ module Admin
34
33
 
35
34
  session[:admin_user_id] = nil
36
35
 
37
- redirect_to admin_dashboard_path, notice: I18n.t("koi.auth.logout")
36
+ redirect_to new_admin_session_path
38
37
  end
39
38
 
40
39
  private
41
40
 
41
+ def redirect_authenticated
42
+ redirect_to(admin_dashboard_path, status: :see_other)
43
+ end
44
+
42
45
  def session_params
43
46
  params.require(:admin).permit(:email, :password, :response)
44
47
  end
@@ -65,6 +68,8 @@ module Admin
65
68
  end
66
69
 
67
70
  def record_sign_out!(admin_user)
71
+ return unless admin_user
72
+
68
73
  update_last_sign_in(admin_user)
69
74
 
70
75
  admin_user.current_sign_in_at = nil
@@ -4,49 +4,54 @@ module Admin
4
4
  class TokensController < ApplicationController
5
5
  include Koi::Controller::JsonWebToken
6
6
 
7
- skip_before_action :authenticate_admin, only: %i[show update]
7
+ before_action :set_admin, only: %i[create]
8
8
  before_action :set_token, only: %i[show update]
9
+ before_action :invalid_token, only: %i[show update], unless: :token_valid?
9
10
 
10
11
  def show
11
- return redirect_to new_admin_session_path, notice: I18n.t("koi.auth.token_invalid") if @token.blank?
12
-
13
- admin = Admin::User.find(@token[:admin_id])
14
-
15
- if token_utilised?(admin, @token)
16
- return redirect_to new_admin_session_path, notice: I18n.t("koi.auth.token_invalid")
17
- end
18
-
19
- render locals: { admin:, token: params[:token] }, layout: "koi/login"
12
+ render locals: { admin: @admin, token: params[:token] }, layout: "koi/login"
20
13
  end
21
14
 
22
15
  def create
23
- admin = Admin::User.find(params[:id])
24
- token = encode_token(admin_id: admin.id, exp: 5.minutes.from_now.to_i, iat: Time.current.to_i)
16
+ token = encode_token(admin_id: @admin.id, exp: 30.minutes.from_now.to_i, iat: Time.current.to_i)
25
17
 
26
18
  render locals: { token: }
27
19
  end
28
20
 
29
21
  def update
30
- return redirect_to admin_dashboard_path, status: :see_other if admin_signed_in?
31
-
32
- if @token.blank?
33
- return redirect_to new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid")
34
- end
35
-
36
- admin = Admin::User.find(@token[:admin_id])
37
- sign_in_admin(admin)
22
+ sign_in_admin(@admin)
38
23
 
39
- redirect_to admin_admin_user_path(admin)
24
+ redirect_to admin_admin_user_path(@admin), status: :see_other, notice: t("koi.auth.token_consumed")
40
25
  end
41
26
 
42
27
  private
43
28
 
29
+ def set_admin
30
+ @admin = Admin::User.find(params[:admin_user_id])
31
+ end
32
+
44
33
  def set_token
45
34
  @token = decode_token(params[:token])
35
+
36
+ # constant time token validation requires that we always try to retrieve a user
37
+ @admin = Admin::User.find_by(id: @token&.fetch(:admin_id) || "")
38
+ end
39
+
40
+ def token_valid?
41
+ return false unless @token.present? && @admin.present?
42
+
43
+ # Ensure that the user has not signed in since the token was generated
44
+ if @admin.current_sign_in_at.present?
45
+ @admin.current_sign_in_at.to_i < @token[:iat]
46
+ elsif @admin.last_sign_in_at.present?
47
+ @admin.last_sign_in_at.to_i < @token[:iat]
48
+ else
49
+ true # first sign in
50
+ end
46
51
  end
47
52
 
48
- def token_utilised?(admin, token)
49
- admin.current_sign_in_at.present? || (admin.last_sign_in_at.present? && admin.last_sign_in_at.to_i > token[:iat])
53
+ def invalid_token
54
+ redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
50
55
  end
51
56
 
52
57
  def sign_in_admin(admin)
@@ -31,9 +31,6 @@ module Koi
31
31
  helper :all
32
32
 
33
33
  layout -> { turbo_frame_layout || "koi/application" }
34
-
35
- before_action :authenticate_local_admin, if: -> { Koi::Controller::IsAdminController.authenticate_local_admins }
36
- before_action :authenticate_admin, unless: :admin_signed_in?
37
34
  end
38
35
 
39
36
  class << self
@@ -47,10 +44,14 @@ module Koi
47
44
 
48
45
  session[:admin_user_id] =
49
46
  Admin::User.where(email: %W[#{ENV.fetch('USER', nil)}@katalyst.com.au admin@katalyst.com.au]).first&.id
47
+
48
+ flash.delete(:redirect) if (redirect = flash[:redirect])
49
+
50
+ redirect_to(redirect || admin_dashboard_path, status: :see_other)
50
51
  end
51
52
 
52
- def authenticate_admin
53
- redirect_to new_admin_session_path, status: :temporary_redirect
53
+ def authenticate_local_admins?
54
+ IsAdminController.authenticate_local_admins
54
55
  end
55
56
 
56
57
  def turbo_frame_layout
@@ -17,7 +17,7 @@
17
17
  method: :delete,
18
18
  form: { data: { turbo_confirm: "Are you sure?" } } %>
19
19
  <% end %>
20
- <%= button_to "Generate login link", invite_admin_admin_user_path(admin), class: "button button--primary", form: { id: "invite" } %>
20
+ <%= button_to "Generate login link", admin_admin_user_tokens_path(admin), class: "button button--primary", form: { id: "invite" } %>
21
21
  </div>
22
22
 
23
23
  <h2>Authentication</h2>
@@ -7,19 +7,22 @@
7
7
  webauthn_authentication_options_value: { publicKey: webauthn_auth_options },
8
8
  },
9
9
  ) do |f| %>
10
+ <% (redirect = flash[:redirect] || params[:redirect]) && flash.delete(:redirect) %>
10
11
  <% unless flash.empty? %>
11
12
  <div class="govuk-error-summary">
12
13
  <ul class="govuk-error-summary__list">
13
- <% flash.each do |_, message| %>
14
+ <% flash.each do |type, message| %>
14
15
  <%= tag.li message %>
15
16
  <% end %>
16
17
  </ul>
17
18
  </div>
18
19
  <% end %>
19
20
  <%= f.govuk_fieldset legend: nil do %>
20
- <%= f.govuk_email_field :email, autofocus: true, autocomplete: "email" %>
21
- <%= f.govuk_password_field :password, autocomplete: "current-password" %>
21
+ <%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
22
+ <%= f.govuk_email_field :email, autofocus: true, autocomplete: "off" %>
23
+ <%= f.govuk_password_field :password, autocomplete: "off" %>
22
24
  <%= f.hidden_field :response, data: { webauthn_authentication_target: "response" } %>
25
+ <%= hidden_field_tag(:redirect, redirect) %>
23
26
  <% end %>
24
27
  <div class="actions-group">
25
28
  <%= f.admin_save "Log in" %>
@@ -1,6 +1,6 @@
1
1
  <%= turbo_stream.replace "invite" do %>
2
2
  <div class="action copy-to-clipboard govuk-input__wrapper" data-controller="clipboard" data-clipboard-supported-class="clipboard--supported">
3
- <%= text_field_tag :invite_link, admin_token_url(token), readonly: true, data: { clipboard_target: "source" } %>
3
+ <%= text_field_tag :invite_link, admin_session_token_url(token), readonly: true, data: { clipboard_target: "source" } %>
4
4
  <button class="govuk-input__suffix clipboard-button" aria-hidden="true" data-action="clipboard#copy">
5
5
  Copy link
6
6
  </button>
@@ -1,7 +1,6 @@
1
1
  <%= render "layouts/koi/navigation_header" %>
2
2
 
3
- <%= form_with(url: accept_admin_session_path) do |form| %>
4
- <%= tag.input name: :token, type: :hidden, value: token %>
3
+ <%= form_with(url: admin_session_token_path(token), method: :patch) do |form| %>
5
4
  <p>Welcome to Koi Admin</p>
6
5
  <%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %>
7
6
  <%= builder.text :name %>
@@ -9,6 +9,7 @@
9
9
 
10
10
  <!-- META -->
11
11
  <%= csrf_meta_tags %>
12
+ <%= csp_meta_tag %>
12
13
  <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=0.6667">
13
14
  <meta name="robots" content="noindex,nofollow">
14
15
  <%= Koi::Release.meta_tags(self) %>
@@ -1,5 +1,9 @@
1
1
  <html>
2
2
  <head>
3
+ <%# Workaround for https://github.com/hotwired/turbo-rails/issues/669 %>
4
+ <%= csrf_meta_tags %>
5
+ <%= csp_meta_tag %>
6
+
3
7
  <%# include all turbo-track elements so turbo knows it can cache frame visits %>
4
8
  <%= stylesheet_link_tag Koi.config.admin_stylesheet, "data-turbo-track": "reload" %>
5
9
  <%= javascript_importmap_tags "koi/admin" %>
@@ -9,9 +9,8 @@ en:
9
9
  admin: "%e %B %Y"
10
10
  koi:
11
11
  auth:
12
- login: "You have been logged in"
13
- logout: "You have been logged out"
14
12
  token_invalid: "Token invalid or consumed already"
13
+ token_consumed: "Please create a password or passkey"
15
14
  labels:
16
15
  new: New
17
16
  search: Search
data/config/routes.rb CHANGED
@@ -3,22 +3,19 @@
3
3
  Rails.application.routes.draw do
4
4
  namespace :admin do
5
5
  resource :session, only: %i[new create destroy] do
6
- post :accept, to: "tokens#update"
6
+ # JWT tokens contain periods
7
+ resources :tokens, param: :token, only: %i[show update], token: /[^\/]+/
7
8
  end
8
9
 
9
10
  resources :url_rewrites
10
11
  resources :admin_users do
11
12
  resources :credentials, only: %i[new create destroy]
12
- post :invite, on: :member, to: "tokens#create"
13
+ resources :tokens, only: %i[create]
13
14
  get :archived, on: :collection
14
15
  put :archive, on: :collection
15
16
  put :restore, on: :collection
16
17
  end
17
18
 
18
- # JWT tokens have dots(represents the 3 parts of data) in them, so we need to allow them in the URL
19
- # can by pass if we use token as a query param
20
- get "token/:token", to: "tokens#show", as: :token, token: /[^\/]+/
21
-
22
19
  resource :cache, only: %i[destroy]
23
20
  resource :dashboard, only: %i[show]
24
21
 
@@ -26,10 +23,8 @@ Rails.application.routes.draw do
26
23
  end
27
24
 
28
25
  scope :admin do
29
- constraints ->(req) { req.session[:admin_user_id].present? } do
30
- mount Katalyst::Content::Engine, at: "content"
31
- mount Katalyst::Navigation::Engine, at: "navigation"
32
- mount Flipper::UI.app(Flipper) => "flipper" if Object.const_defined?("Flipper::UI")
33
- end
26
+ mount Katalyst::Content::Engine, at: "content"
27
+ mount Katalyst::Navigation::Engine, at: "navigation"
28
+ mount Flipper::UI.app(Flipper) => "flipper" if Object.const_defined?("Flipper::UI")
34
29
  end
35
30
  end
@@ -124,6 +124,7 @@ RSpec.describe <%= controller_class_name %>Controller do
124
124
 
125
125
  describe "DELETE /admin/<%= plural_name %>/:id" do
126
126
  let(:action) { delete polymorphic_path([:admin, model]) }
127
+ let!(:model) { create(:<%= singular_name %>) }
127
128
 
128
129
  it_behaves_like "requires admin"
129
130
 
@@ -131,5 +132,9 @@ RSpec.describe <%= controller_class_name %>Controller do
131
132
  action
132
133
  expect(response).to redirect_to(polymorphic_path([:admin, <%= class_name %>]))
133
134
  end
135
+
136
+ it "deletes the <%= singular_name %>" do
137
+ expect { action }.to change(<%= class_name %>, :count).by(-1)
138
+ end
134
139
  end
135
140
  end
data/lib/koi/engine.rb CHANGED
@@ -58,6 +58,7 @@ module Koi
58
58
  end
59
59
 
60
60
  initializer "koi.middleware" do |app|
61
+ app.middleware.use Koi::Middleware::AdminAuthentication
61
62
  app.middleware.use ::ActionDispatch::Static, root.join("public").to_s
62
63
  app.middleware.insert_before Rack::Sendfile, Koi::Middleware::UrlRedirect
63
64
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Middleware
5
+ class AdminAuthentication
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ if env["PATH_INFO"].starts_with?("/admin")
12
+ admin_call(env)
13
+ else
14
+ @app.call(env)
15
+ end
16
+ end
17
+
18
+ def admin_call(env)
19
+ request = ActionDispatch::Request.new(env)
20
+ session = ActionDispatch::Request::Session.find(request)
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
28
+
29
+ [303, { "Location" => "/admin/session/new" }, []]
30
+ else
31
+ @app.call(env)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def requires_authentication?(request)
38
+ !request.path.starts_with?("/admin/session")
39
+ end
40
+
41
+ def authenticated?(session)
42
+ session[:admin_user_id].present?
43
+ end
44
+ end
45
+ end
46
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: katalyst-koi
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.13.0
4
+ version: 4.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-09-17 00:00:00.000000000 Z
11
+ date: 2024-11-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -457,6 +457,7 @@ files:
457
457
  - lib/koi/form_builder.rb
458
458
  - lib/koi/menu.rb
459
459
  - lib/koi/menu/builder.rb
460
+ - lib/koi/middleware/admin_authentication.rb
460
461
  - lib/koi/middleware/url_redirect.rb
461
462
  - lib/koi/release.rb
462
463
  - spec/factories/admins.rb
@@ -481,7 +482,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
481
482
  - !ruby/object:Gem::Version
482
483
  version: '0'
483
484
  requirements: []
484
- rubygems_version: 3.5.16
485
+ rubygems_version: 3.5.22
485
486
  signing_key:
486
487
  specification_version: 4
487
488
  summary: Koi CMS admin framework