katalyst-koi 4.14.3 → 4.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca202222a19590b4f0cba29726cd07fe14f1e76f96f539eb791601cc318ed2fc
4
- data.tar.gz: afd204af464f4f1d6fcd7d51396ad0579aed1c16d2f6078f6388199cc4d8abf4
3
+ metadata.gz: 7aec5f5d47bb5a92a43d88caa502800d7bf13926a49872ef56850fe14f40a7d8
4
+ data.tar.gz: 81eb88b30f1d7e4d098a176dd49c4083cc0b00cbb3e883094e1f099588d255fc
5
5
  SHA512:
6
- metadata.gz: 56e6b34f520bff068ae96808e7daa71baebca78b650a2b7a651bb46255e7dd299511f3ebf62f9b5545ca5ee5b1a34be9d5d08441141e4952c9b20d9c3af7a406
7
- data.tar.gz: 8af77dc82477591ce562ba497c208e48b1665d78e75d550db0c8b60dc464961a4bc7835c08a06da7a80612e42e0191f8fcbe37589751c5bcb3762900d28f308a
6
+ metadata.gz: c6a696cddf20fe65037a052c08564dc668408ad6cc7c3bd333ecee29e1ce260f1ef49e9ed0dacb4c61e7619b6561627143ebb3e035dae6329da3d2395cdbfd12
7
+ data.tar.gz: 2f3f2e33b5c16da0a4dd517791c5580b791178f0fe30dca7d39cb2c94c0d0c1896fa227a535fe92fb32c81cc72c4b861e3ce4aa53b90b74c6a0c39f2450e6bee
@@ -88,6 +88,8 @@ module Admin
88
88
  attribute :email, :string
89
89
  attribute :last_sign_in_at, :date
90
90
  attribute :sign_in_count, :integer
91
+ attribute :passkey, :boolean, scope: :has_passkey
92
+ attribute :mfa, :boolean, scope: :has_otp
91
93
  end
92
94
  end
93
95
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class OtpsController < ApplicationController
5
+ before_action :set_admin_user
6
+
7
+ def new
8
+ @admin_user.otp_secret = ROTP::Base32.random
9
+
10
+ render :new, locals: { admin: @admin_user }
11
+ end
12
+
13
+ def create
14
+ @admin_user.otp_secret = otp_params[:otp_secret]
15
+
16
+ if @admin_user.otp.verify(otp_params[:token])
17
+ @admin_user.save
18
+
19
+ redirect_to admin_admin_user_path(@admin_user), status: :see_other
20
+ else
21
+ @admin_user.errors.add(:token, :invalid)
22
+
23
+ respond_to do |format|
24
+ format.html { redirect_to admin_admin_user_path(@admin_user), status: :see_other }
25
+ format.turbo_stream { render locals: { admin: @admin_user } }
26
+ end
27
+ end
28
+ end
29
+
30
+ def destroy
31
+ @admin_user.update!(otp_secret: nil)
32
+
33
+ redirect_to admin_admin_user_path(@admin_user), status: :see_other
34
+ end
35
+
36
+ private
37
+
38
+ def otp_params
39
+ params.require(:admin).permit(:otp_secret, :token)
40
+ end
41
+
42
+ def set_admin_user
43
+ @admin_user = Admin::User.find(params[:admin_user_id])
44
+
45
+ if current_admin == @admin_user
46
+ request.variant = :self
47
+ else
48
+ head(:forbidden)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -14,15 +14,20 @@ module Admin
14
14
  end
15
15
 
16
16
  def create
17
- if (admin_user = webauthn_authenticate! || params_authenticate!)
18
- record_sign_in!(admin_user)
19
-
20
- session[:admin_user_id] = admin_user.id
21
-
22
- redirect_to(url_from(params[:redirect].presence) || admin_dashboard_path, status: :see_other)
17
+ if session_params[:response].present?
18
+ create_session_with_webauthn
19
+ elsif session_params[:token].present?
20
+ create_session_with_token
21
+ elsif session_params[:password].present?
22
+ create_session_with_password
23
+ elsif session_params[:email].present?
24
+ # conversational flow, ask for password regardless of email
25
+ admin_user = Admin::User.new(session_params.slice(:email))
26
+
27
+ render(:password, status: :unprocessable_content, locals: { admin_user: })
23
28
  else
24
- admin_user = Admin::User.new(session_params.slice(:email, :password))
25
- admin_user.errors.add(:email, "Invalid email or password")
29
+ # invalid request, re-render new
30
+ admin_user = Admin::User.new
26
31
 
27
32
  render(:new, status: :unprocessable_content, locals: { admin_user: })
28
33
  end
@@ -38,16 +43,66 @@ module Admin
38
43
 
39
44
  private
40
45
 
46
+ def create_session_with_password
47
+ # constant time lookup for user with password verification
48
+ admin_user = Admin::User.authenticate_by(session_params.slice(:email, :password))
49
+
50
+ if admin_user.present? && admin_user.requires_otp?
51
+ session[:pending_admin_user_id] = admin_user.id
52
+
53
+ render(:otp, status: :unprocessable_content, locals: { admin_user: })
54
+ elsif admin_user.present?
55
+ admin_sign_in(admin_user)
56
+ else
57
+ admin_user = Admin::User.new(session_params.slice(:email, :password))
58
+ admin_user.errors.add(:email, :invalid)
59
+
60
+ render(:new, status: :unprocessable_content, locals: { admin_user: })
61
+ end
62
+ end
63
+
64
+ def create_session_with_token
65
+ # assume that the previous step injected the user's ID into the session and remove it regardless of outcome
66
+ admin_user = Admin::User.find_by(id: session.delete(:pending_admin_user_id))
67
+
68
+ if admin_user&.otp&.verify(session_params[:token],
69
+ drift_ahead: 15,
70
+ drift_behind: 15,
71
+ after: admin_user.current_sign_in_at)
72
+ admin_sign_in(admin_user)
73
+ else
74
+ admin_user = Admin::User.new
75
+ admin_user.errors.add(:email, :invalid)
76
+
77
+ render(:new, status: :unprocessable_content, locals: { admin_user: })
78
+ end
79
+ end
80
+
81
+ def create_session_with_webauthn
82
+ if (admin_user = webauthn_authenticate!)
83
+ admin_sign_in(admin_user)
84
+ else
85
+ admin_user = Admin::User.new
86
+ admin_user.errors.add(:email, :invalid)
87
+
88
+ render(:new, status: :unprocessable_content, locals: { admin_user: })
89
+ end
90
+ end
91
+
41
92
  def redirect_authenticated
42
93
  redirect_to(admin_dashboard_path, status: :see_other)
43
94
  end
44
95
 
45
- def session_params
46
- params.require(:admin).permit(:email, :password, :response)
96
+ def admin_sign_in(admin_user)
97
+ record_sign_in!(admin_user)
98
+
99
+ session[:admin_user_id] = admin_user.id
100
+
101
+ redirect_to(url_from(params[:redirect].presence) || admin_dashboard_path, status: :see_other)
47
102
  end
48
103
 
49
- def params_authenticate!
50
- Admin::User.authenticate_by(session_params.slice(:email, :password))
104
+ def session_params
105
+ params.require(:admin).permit(:email, :password, :token, :response)
51
106
  end
52
107
 
53
108
  def update_last_sign_in(admin_user)
@@ -3,6 +3,7 @@
3
3
  module Admin
4
4
  class User < ApplicationRecord
5
5
  include Koi::Model::Archivable
6
+ include Koi::Model::OTP
6
7
 
7
8
  def self.model_name
8
9
  ActiveModel::Name.new(self, nil, "Admin")
@@ -27,5 +28,26 @@ module Admin
27
28
  where("email LIKE :query OR name LIKE :query", query: "%#{query}%")
28
29
  end
29
30
  end
31
+
32
+ scope :has_otp, ->(otp) do
33
+ if otp
34
+ where.not(otp_secret: nil)
35
+ else
36
+ where(otp_secret: nil)
37
+ end
38
+ end
39
+
40
+ scope :has_passkey, ->(passkey) do
41
+ if passkey
42
+ where(id: Admin::Credential.select(:admin_id))
43
+ else
44
+ where.not(id: Admin::Credential.select(:admin_id))
45
+ end
46
+ end
47
+
48
+ def passkey
49
+ credentials.any?
50
+ end
51
+ alias passkey? passkey
30
52
  end
31
53
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Model
5
+ module OTP
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ attribute :token, :string
10
+ end
11
+
12
+ def requires_otp?
13
+ otp_secret.present?
14
+ end
15
+
16
+ def otp
17
+ ROTP::TOTP.new(otp_secret) if otp_secret.present?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -18,6 +18,9 @@
18
18
  <% row.boolean :credentials, label: "Passkey" do |cell| %>
19
19
  <%= cell.value.any? ? "Yes" : "No" %>
20
20
  <% end %>
21
+ <% row.boolean :otp, label: "MFA" do |cell| %>
22
+ <%= cell.value.present? ? "Yes" : "No" %>
23
+ <% end %>
21
24
  <% end %>
22
25
 
23
26
  <%= table_pagination_with(collection:) %>
@@ -4,11 +4,25 @@
4
4
  <%= render Koi::Header::ShowComponent.new(resource: admin) %>
5
5
  <% end %>
6
6
 
7
- <%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %>
7
+ <%= render Koi::SummaryTableComponent.new(model: admin) do |builder| %>
8
8
  <%= builder.text :name %>
9
9
  <%= builder.text :email %>
10
10
  <%= builder.date :created_at %>
11
- <%= builder.date :last_sign_in_at, label: { text: "Last sign in" } %>
11
+ <%= builder.date :last_sign_in_at, label: "Last sign in" %>
12
+ <%= builder.boolean :passkey %>
13
+ <%= builder.boolean :otp, label: "MFA" do |otp| %>
14
+ <span class="repel">
15
+ <%= otp %>
16
+ <% if otp.value %>
17
+ <%= button_to("Remove", admin_admin_user_otp_path(admin),
18
+ class: "button button--text",
19
+ method: :delete,
20
+ form: { data: { turbo_confirm: "Are you sure?" } }) %>
21
+ <% else %>
22
+ <%= kpop_link_to "Add", new_admin_admin_user_otp_path(admin) %>
23
+ <% end %>
24
+ </span>
25
+ <% end %>
12
26
  <% end %>
13
27
 
14
28
  <div class="repel">
@@ -4,18 +4,16 @@
4
4
  <%= render Koi::Header::ShowComponent.new(resource: admin) %>
5
5
  <% end %>
6
6
 
7
- <%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %>
7
+ <%= render Koi::SummaryTableComponent.new(model: admin, class: "item-table") do |builder| %>
8
8
  <%= builder.text :name %>
9
9
  <%= builder.text :email %>
10
10
  <%= builder.date :created_at %>
11
- <%= builder.date :last_sign_in_at, label: { text: "Last sign in" } %>
11
+ <%= builder.date :last_sign_in_at, label: "Last sign in" %>
12
+ <%= builder.boolean :passkey %>
13
+ <%= builder.boolean :otp, label: "MFA" %>
12
14
  <%= builder.boolean :archived? %>
13
15
  <% end %>
14
16
 
15
- <h3>Passkeys</h3>
16
-
17
- <%= render "admin/credentials/credentials", admin: %>
18
-
19
17
  <div class="actions">
20
18
  <% if admin.archived? %>
21
19
  <%= button_to "Delete", admin_admin_user_path(admin),
@@ -0,0 +1,32 @@
1
+ <%# locals: (admin:) %>
2
+
3
+ <%= form_with(id: dom_id(admin, :otp),
4
+ model: admin,
5
+ url: admin_admin_user_otp_path(admin),
6
+ method: :post,
7
+ class: "flow") do |form| %>
8
+ <section class="flow prose">
9
+ <p>MFA protects your account by requiring you to enter a six-digit
10
+ token that changes every 30 seconds. If someone knows or guesses your
11
+ password they also need to know the current token to log in.</p>
12
+ <p>In general, we recommend using Passkeys over MFA. Passkeys offer better
13
+ security than a password + MFA, and they are easier to use.</p>
14
+ <p><strong>Add an MFA authenticator to your account</strong></p>
15
+ <ol class="flow">
16
+ <li>Install an MFA app. Most password managers support MFA.</li>
17
+ <li>Scan this code using your mobile device or password manager:<br>
18
+ <% otp_app_name = t("koi.auth.otp_app_name", host: URI.parse(root_url).host, email: admin.email) %>
19
+ <%== RQRCode::QRCode.new(admin.otp.provisioning_uri(otp_app_name)).as_svg(
20
+ color: "000",
21
+ shape_rendering: "crispEdges",
22
+ module_size: 3,
23
+ use_path: true,
24
+ ) %>
25
+ </li>
26
+ <li>Enter the token shown in your app into the field below:</li>
27
+ </ol>
28
+ </section>
29
+ <%= form.hidden_field :otp_secret %>
30
+ <%= form.govuk_text_field :token %>
31
+ <%= form.admin_save %>
32
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <%# locals: (admin:) %>
2
+
3
+ <%= turbo_stream.replace(dom_id(admin, :otp)) do %>
4
+ <%= render("form", admin:) %>
5
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <%# locals: (admin:) %>
2
+
3
+ <%= render Kpop::ModalComponent.new(title: "Configure MFA") do %>
4
+ <%= render("form", admin:) %>
5
+ <% end %>
@@ -1,4 +1,7 @@
1
+ <%# locals: (admin_user:) %>
2
+
1
3
  <%= render "layouts/koi/navigation_header" %>
4
+
2
5
  <%= form_with(
3
6
  model: admin_user,
4
7
  url: admin_session_path,
@@ -6,7 +9,7 @@
6
9
  controller: "webauthn-authentication",
7
10
  webauthn_authentication_options_value: { publicKey: webauthn_auth_options },
8
11
  },
9
- ) do |f| %>
12
+ ) do |form| %>
10
13
  <% (redirect = flash[:redirect] || params[:redirect]) && flash.delete(:redirect) %>
11
14
  <% unless flash.empty? %>
12
15
  <div class="govuk-error-summary">
@@ -17,15 +20,12 @@
17
20
  </ul>
18
21
  </div>
19
22
  <% end %>
20
- <%= f.govuk_fieldset legend: nil do %>
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" %>
24
- <%= f.hidden_field :response, data: { webauthn_authentication_target: "response" } %>
25
- <%= hidden_field_tag(:redirect, redirect) %>
26
- <% end %>
23
+ <%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
24
+ <%= form.govuk_email_field :email, autofocus: true, autocomplete: "off" %>
25
+ <%= form.hidden_field :response, data: { webauthn_authentication_target: "response" } %>
26
+ <%= hidden_field_tag(:redirect, redirect) %>
27
27
  <div class="actions-group">
28
- <%= f.admin_save "Log in" %>
29
- <%= f.button "🔑", type: :button, class: "button button--secondary", data: { action: "webauthn-authentication#authenticate" } %>
28
+ <%= form.admin_save "Next" %>
29
+ <%= form.button "🔑", type: :button, class: "button button--secondary", data: { action: "webauthn-authentication#authenticate" } %>
30
30
  </div>
31
31
  <% end %>
@@ -0,0 +1,10 @@
1
+ <%# locals: (admin_user:) %>
2
+
3
+ <%= render "layouts/koi/navigation_header" %>
4
+
5
+ <%= form_with(model: admin_user, url: admin_session_path, method: :post) do |form| %>
6
+ <%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
7
+ <%= form.govuk_text_field :token, autofocus: true, autocomplete: "off" %>
8
+ <%= hidden_field_tag(:redirect, params[:redirect]) %>
9
+ <%= form.admin_save "Next" %>
10
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <%# locals: (admin_user:) %>
2
+
3
+ <%= render "layouts/koi/navigation_header" %>
4
+
5
+ <%= form_with(model: admin_user, url: admin_session_path) do |form| %>
6
+ <%= form.hidden_field(:email) %>
7
+ <%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
8
+ <%= form.govuk_password_field :password, autofocus: true, autocomplete: "off" %>
9
+ <%= hidden_field_tag(:redirect, params[:redirect]) %>
10
+ <%= form.admin_save "Next" %>
11
+ <% end %>
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveSupport::Inflector.inflections do |inflect|
4
+ inflect.acronym "MFA"
5
+ inflect.acronym "OTP"
6
+ end
@@ -9,11 +9,17 @@ en:
9
9
  admin: "%e %B %Y"
10
10
  koi:
11
11
  auth:
12
+ otp_app_name: "%{host}/admin"
12
13
  token_invalid: "Token invalid or consumed already"
13
14
  token_consumed: "Please create a password or passkey"
14
15
  labels:
15
16
  new: New
16
17
  search: Search
18
+ activerecord:
19
+ errors:
20
+ models:
21
+ admin:
22
+ invalid: "Invalid login credentials"
17
23
  helpers:
18
24
  hint:
19
25
  default:
data/config/routes.rb CHANGED
@@ -10,6 +10,7 @@ Rails.application.routes.draw do
10
10
  resources :url_rewrites
11
11
  resources :admin_users do
12
12
  resources :credentials, only: %i[new create destroy]
13
+ resource :otp, only: %i[new create destroy]
13
14
  resources :tokens, only: %i[create]
14
15
  get :archived, on: :collection
15
16
  put :archive, on: :collection
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddOTPSecretToAdminUsers < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :admins, :otp_secret, :string
6
+ end
7
+ end
data/lib/koi/engine.rb CHANGED
@@ -7,6 +7,8 @@ require "katalyst/kpop"
7
7
  require "katalyst/navigation"
8
8
  require "katalyst/tables"
9
9
  require "pagy"
10
+ require "rotp"
11
+ require "rqrcode"
10
12
  require "stimulus-rails"
11
13
  require "turbo-rails"
12
14
  require "webauthn"
@@ -5,5 +5,6 @@ FactoryBot.define do
5
5
  email { Faker::Internet.email }
6
6
  name { Faker::Name.name }
7
7
  password { Faker::Internet.password }
8
+ otp_secret { ROTP::Base32.random }
8
9
  end
9
10
  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.14.3
4
+ version: 4.15.1
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-12-11 00:00:00.000000000 Z
11
+ date: 2024-12-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -80,6 +80,34 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rotp
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rqrcode
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
83
111
  - !ruby/object:Gem::Dependency
84
112
  name: webauthn
85
113
  requirement: !ruby/object:Gem::Requirement
@@ -343,6 +371,7 @@ files:
343
371
  - app/controllers/admin/caches_controller.rb
344
372
  - app/controllers/admin/credentials_controller.rb
345
373
  - app/controllers/admin/dashboards_controller.rb
374
+ - app/controllers/admin/otps_controller.rb
346
375
  - app/controllers/admin/sessions_controller.rb
347
376
  - app/controllers/admin/tokens_controller.rb
348
377
  - app/controllers/admin/url_rewrites_controller.rb
@@ -363,6 +392,7 @@ files:
363
392
  - app/models/admin/user.rb
364
393
  - app/models/application_record.rb
365
394
  - app/models/concerns/koi/model/archivable.rb
395
+ - app/models/concerns/koi/model/otp.rb
366
396
  - app/models/url_rewrite.rb
367
397
  - app/views/admin/admin_users/_fields.html+self.erb
368
398
  - app/views/admin/admin_users/_fields.html.erb
@@ -378,7 +408,12 @@ files:
378
408
  - app/views/admin/credentials/destroy.turbo_stream.erb
379
409
  - app/views/admin/credentials/new.html.erb
380
410
  - app/views/admin/dashboards/show.html.erb
411
+ - app/views/admin/otps/_form.html.erb
412
+ - app/views/admin/otps/create.turbo_stream.erb
413
+ - app/views/admin/otps/new.html.erb
381
414
  - app/views/admin/sessions/new.html.erb
415
+ - app/views/admin/sessions/otp.html.erb
416
+ - app/views/admin/sessions/password.html.erb
382
417
  - app/views/admin/shared/icons/_close.html.erb
383
418
  - app/views/admin/shared/icons/_cross.html.erb
384
419
  - app/views/admin/shared/icons/_menu.html.erb
@@ -418,6 +453,7 @@ files:
418
453
  - config/importmap.rb
419
454
  - config/initializers/extensions.rb
420
455
  - config/initializers/flipper.rb
456
+ - config/initializers/inflections.rb
421
457
  - config/locales/koi.en.yml
422
458
  - config/locales/pagy.en.yml
423
459
  - config/routes.rb
@@ -428,6 +464,7 @@ files:
428
464
  - db/migrate/20230531063707_update_admin_users.rb
429
465
  - db/migrate/20230602033610_add_archived_to_admin_users.rb
430
466
  - db/migrate/20231211005214_add_status_code_to_url_rewrites.rb
467
+ - db/migrate/20241214060913_add_otp_secret_to_admin_users.rb
431
468
  - db/seeds.rb
432
469
  - lib/generators/koi/active_record/active_record_generator.rb
433
470
  - lib/generators/koi/admin/USAGE