diff --git a/app/views/admin/admin_users/show.html.erb b/app/views/admin/admin_users/show.html.erb
index 8318c05f5..76c7fc2e8 100644
--- a/app/views/admin/admin_users/show.html.erb
+++ b/app/views/admin/admin_users/show.html.erb
@@ -4,18 +4,16 @@
<%= render Koi::Header::ShowComponent.new(resource: admin) %>
<% end %>
-<%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %>
+<%= render Koi::SummaryTableComponent.new(model: admin, class: "item-table") do |builder| %>
<%= builder.text :name %>
<%= builder.text :email %>
<%= builder.date :created_at %>
- <%= builder.date :last_sign_in_at, label: { text: "Last sign in" } %>
+ <%= builder.date :last_sign_in_at, label: "Last sign in" %>
+ <%= builder.boolean :passkey %>
+ <%= builder.boolean :otp, label: "MFA" %>
<%= builder.boolean :archived? %>
<% end %>
-
<% if admin.archived? %>
<%= button_to "Delete", admin_admin_user_path(admin),
diff --git a/app/views/admin/otps/_form.html.erb b/app/views/admin/otps/_form.html.erb
new file mode 100644
index 000000000..bcec86259
--- /dev/null
+++ b/app/views/admin/otps/_form.html.erb
@@ -0,0 +1,31 @@
+<%# locals: (admin:) %>
+
+<%= form_with(id: dom_id(admin, :otp),
+ model: admin,
+ url: admin_admin_user_otp_path(admin),
+ method: :post,
+ class: "flow") do |form| %>
+
+ MFA protects your account by requiring you to enter a six-digit
+ token that changes every 30 seconds. If someone knows or guesses your
+ password they also need to know the current token to log in.
+ In general, we recommend using Passkeys over MFA. Passkeys offer better
+ security than a password + MFA, and they are easier to use.
+ Add an MFA authenticator to your account
+
+ - Install an MFA app. Most password managers support MFA.
+ - Scan this code using your mobile device or password manager:
+ <%== RQRCode::QRCode.new(admin.otp.provisioning_uri(admin.email)).as_svg(
+ color: "000",
+ shape_rendering: "crispEdges",
+ module_size: 3,
+ use_path: true,
+ ) %>
+
+ - Enter the token shown in your app into the field below:
+
+
+ <%= form.hidden_field :otp_secret %>
+ <%= form.govuk_text_field :token %>
+ <%= form.admin_save %>
+<% end %>
diff --git a/app/views/admin/otps/create.turbo_stream.erb b/app/views/admin/otps/create.turbo_stream.erb
new file mode 100644
index 000000000..24efc5858
--- /dev/null
+++ b/app/views/admin/otps/create.turbo_stream.erb
@@ -0,0 +1,5 @@
+<%# locals: (admin:) %>
+
+<%= turbo_stream.replace(dom_id(admin, :otp)) do %>
+ <%= render("form", admin:) %>
+<% end %>
diff --git a/app/views/admin/otps/new.html.erb b/app/views/admin/otps/new.html.erb
new file mode 100644
index 000000000..a89aa2a60
--- /dev/null
+++ b/app/views/admin/otps/new.html.erb
@@ -0,0 +1,5 @@
+<%# locals: (admin:) %>
+
+<%= render Kpop::ModalComponent.new(title: "Configure MFA") do %>
+ <%= render("form", admin:) %>
+<% end %>
diff --git a/app/views/admin/sessions/new.html.erb b/app/views/admin/sessions/new.html.erb
index 35dda4322..ae21f74fc 100644
--- a/app/views/admin/sessions/new.html.erb
+++ b/app/views/admin/sessions/new.html.erb
@@ -1,4 +1,7 @@
+<%# locals: (admin_user:) %>
+
<%= render "layouts/koi/navigation_header" %>
+
<%= form_with(
model: admin_user,
url: admin_session_path,
@@ -6,7 +9,7 @@
controller: "webauthn-authentication",
webauthn_authentication_options_value: { publicKey: webauthn_auth_options },
},
- ) do |f| %>
+ ) do |form| %>
<% (redirect = flash[:redirect] || params[:redirect]) && flash.delete(:redirect) %>
<% unless flash.empty? %>
@@ -17,15 +20,12 @@
<% end %>
- <%= f.govuk_fieldset legend: nil do %>
- <%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
- <%= f.govuk_email_field :email, autofocus: true, autocomplete: "off" %>
- <%= f.govuk_password_field :password, autocomplete: "off" %>
- <%= f.hidden_field :response, data: { webauthn_authentication_target: "response" } %>
- <%= hidden_field_tag(:redirect, redirect) %>
- <% end %>
+ <%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
+ <%= form.govuk_email_field :email, autofocus: true, autocomplete: "off" %>
+ <%= form.hidden_field :response, data: { webauthn_authentication_target: "response" } %>
+ <%= hidden_field_tag(:redirect, redirect) %>
- <%= f.admin_save "Log in" %>
- <%= f.button "🔑", type: :button, class: "button button--secondary", data: { action: "webauthn-authentication#authenticate" } %>
+ <%= form.admin_save "Next" %>
+ <%= form.button "🔑", type: :button, class: "button button--secondary", data: { action: "webauthn-authentication#authenticate" } %>
<% end %>
diff --git a/app/views/admin/sessions/otp.html.erb b/app/views/admin/sessions/otp.html.erb
new file mode 100644
index 000000000..8cef55d50
--- /dev/null
+++ b/app/views/admin/sessions/otp.html.erb
@@ -0,0 +1,10 @@
+<%# locals: (admin_user:) %>
+
+<%= render "layouts/koi/navigation_header" %>
+
+<%= form_with(model: admin_user, url: admin_session_path, method: :post) do |form| %>
+ <%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
+ <%= form.govuk_text_field :token, autofocus: true, autocomplete: "off" %>
+ <%= hidden_field_tag(:redirect, params[:redirect]) %>
+ <%= form.admin_save "Next" %>
+<% end %>
diff --git a/app/views/admin/sessions/password.html.erb b/app/views/admin/sessions/password.html.erb
new file mode 100644
index 000000000..44a7b7d21
--- /dev/null
+++ b/app/views/admin/sessions/password.html.erb
@@ -0,0 +1,11 @@
+<%# locals: (admin_user:) %>
+
+<%= render "layouts/koi/navigation_header" %>
+
+<%= form_with(model: admin_user, url: admin_session_path) do |form| %>
+ <%= form.hidden_field(:email) %>
+ <%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
+ <%= form.govuk_password_field :password, autofocus: true, autocomplete: "off" %>
+ <%= hidden_field_tag(:redirect, params[:redirect]) %>
+ <%= form.admin_save "Next" %>
+<% end %>
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
new file mode 100644
index 000000000..fd162a813
--- /dev/null
+++ b/config/initializers/inflections.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+ActiveSupport::Inflector.inflections do |inflect|
+ inflect.acronym "MFA"
+ inflect.acronym "OTP"
+end
diff --git a/config/locales/koi.en.yml b/config/locales/koi.en.yml
index c92de571c..2071802de 100644
--- a/config/locales/koi.en.yml
+++ b/config/locales/koi.en.yml
@@ -14,6 +14,11 @@ en:
labels:
new: New
search: Search
+ activerecord:
+ errors:
+ models:
+ admin:
+ invalid: "Invalid login credentials"
helpers:
hint:
default:
diff --git a/config/routes.rb b/config/routes.rb
index e7c515c4c..ce5486689 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -10,6 +10,7 @@
resources :url_rewrites
resources :admin_users do
resources :credentials, only: %i[new create destroy]
+ resource :otp, only: %i[new create destroy]
resources :tokens, only: %i[create]
get :archived, on: :collection
put :archive, on: :collection
diff --git a/db/migrate/20241214060913_add_otp_secret_to_admin_users.rb b/db/migrate/20241214060913_add_otp_secret_to_admin_users.rb
new file mode 100644
index 000000000..23019c148
--- /dev/null
+++ b/db/migrate/20241214060913_add_otp_secret_to_admin_users.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddOTPSecretToAdminUsers < ActiveRecord::Migration[8.0]
+ def change
+ add_column :admins, :otp_secret, :string
+ end
+end
diff --git a/katalyst-koi.gemspec b/katalyst-koi.gemspec
index 89fb8304c..56e10be16 100644
--- a/katalyst-koi.gemspec
+++ b/katalyst-koi.gemspec
@@ -28,6 +28,8 @@ Gem::Specification.new do |s|
# Authorization
s.add_dependency "bcrypt"
+ s.add_dependency "rotp"
+ s.add_dependency "rqrcode"
s.add_dependency "webauthn"
# Third party libraries for admin pages
diff --git a/lib/koi/engine.rb b/lib/koi/engine.rb
index 5d60bcdbe..c9bc4af9f 100644
--- a/lib/koi/engine.rb
+++ b/lib/koi/engine.rb
@@ -7,6 +7,8 @@
require "katalyst/navigation"
require "katalyst/tables"
require "pagy"
+require "rotp"
+require "rqrcode"
require "stimulus-rails"
require "turbo-rails"
require "webauthn"
diff --git a/spec/factories/admins.rb b/spec/factories/admins.rb
index 0837e1683..6451801fe 100644
--- a/spec/factories/admins.rb
+++ b/spec/factories/admins.rb
@@ -5,5 +5,6 @@
email { Faker::Internet.email }
name { Faker::Name.name }
password { Faker::Internet.password }
+ otp_secret { ROTP::Base32.random }
end
end
diff --git a/spec/requests/admin/otps_controller_spec.rb b/spec/requests/admin/otps_controller_spec.rb
new file mode 100644
index 000000000..2bbcf39ed
--- /dev/null
+++ b/spec/requests/admin/otps_controller_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Admin::OtpsController do
+ subject { action && response }
+
+ let(:admin) { create(:admin, otp_secret: nil) }
+
+ include_context "with admin session"
+
+ describe "GET /admin/admin_users/:admin_user_id/otp/new" do
+ let(:action) { get new_admin_admin_user_otp_path(admin), as: :turbo_stream }
+
+ it_behaves_like "requires admin"
+
+ it "renders successfully" do
+ action
+ expect(response).to have_http_status(:success)
+ end
+
+ context "with another user's id" do
+ let(:session_for) { create(:admin) }
+
+ it "returns an error" do
+ action
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
+ end
+
+ describe "POST /admin/admin_users/:admin_user_id/otp" do
+ let(:action) do
+ admin.otp_secret = ROTP::Base32.random
+
+ post admin_admin_user_otp_path(admin),
+ params: { admin: { otp_secret: admin.otp_secret, token: admin.otp.now } },
+ as: :turbo_stream
+ end
+
+ it_behaves_like "requires admin"
+
+ it "redirects to user" do
+ action
+ expect(response).to have_http_status(:see_other).and(redirect_to(admin_admin_user_path(admin)))
+ end
+
+ it "sets otp secret" do
+ expect { action }.to(change { admin.reload.otp_secret })
+ end
+
+ context "with token mismatch" do
+ let(:action) do
+ admin.otp_secret = ROTP::Base32.random
+
+ post admin_admin_user_otp_path(admin),
+ params: { admin: { otp_secret: admin.otp_secret, token: "000000" } },
+ as: :turbo_stream
+ end
+
+ it "updates the form" do
+ action
+ html = Nokogiri::HTML.fragment(response.body)
+ root = Capybara::Node::Simple.new(html)
+ expect(root).to have_css("turbo-stream[action='replace'][target='otp_admin_#{admin.id}']")
+ end
+
+ it "uses the same secret when re-rendering" do
+ action
+ html = Nokogiri::HTML.fragment(response.body)
+ secret = html.at_css("input[name='admin[otp_secret]']")
+ expect(secret.attributes["value"].value).to eq(admin.otp_secret)
+ end
+
+ it "does not set the otp secret" do
+ expect { action }.not_to(change { admin.reload.otp_secret })
+ end
+ end
+ end
+
+ describe "DELETE /admin/admin_users/:id/otp" do
+ let(:action) do
+ delete admin_admin_user_otp_path(admin), as: :turbo_stream
+ end
+
+ let(:admin) { create(:admin) }
+
+ it_behaves_like "requires admin"
+
+ it "removes the otp secret" do
+ expect { action }.to(change { admin.reload.otp_secret }.to(nil))
+ end
+ end
+end
diff --git a/spec/requests/admin/sessions_controller_spec.rb b/spec/requests/admin/sessions_controller_spec.rb
index f115aaa97..b3caeecfb 100644
--- a/spec/requests/admin/sessions_controller_spec.rb
+++ b/spec/requests/admin/sessions_controller_spec.rb
@@ -32,65 +32,74 @@
as: :turbo_stream
end
- context "with username/password" do
- let(:session_params) do
- {
- email: admin.email,
- password: admin.password,
- }
- end
+ it "accepts email and prompts for password" do
+ post admin_session_path, params: { admin: { email: admin.email } }, as: :turbo_stream
+ expect(response).to have_http_status(:unprocessable_content).and(have_rendered("password"))
+ end
- it "renders successfully" do
- action
- expect(response).to redirect_to(admin_dashboard_path)
- end
+ it "accepts invalid emails without leaking account existence" do
+ post admin_session_path, params: { admin: { email: "invalid@example.com" } }, as: :turbo_stream
+ expect(response).to have_http_status(:unprocessable_content).and(have_rendered("password"))
+ end
- it "creates the admin session" do
- action
- expect(session[:admin_user_id]).to be_present
- end
+ it "fails on invalid email" do
+ post admin_session_path, params: { admin: { email: "invalid@example.com" } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { email: "invalid@example.com", password: admin.password } },
+ as: :turbo_stream
+ expect(response).to have_http_status(:unprocessable_content).and(have_rendered("new"))
+ end
- it "updates login metadata" do
- expect { action }.to(change { admin.reload.current_sign_in_at })
- end
+ it "fails on invalid password" do
+ post admin_session_path, params: { admin: { email: admin.email } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { email: admin.email, password: "invalid" } }, as: :turbo_stream
+ expect(response).to have_http_status(:unprocessable_content).and(have_rendered("new"))
end
- context "with archived user" do
- let(:admin) { create(:admin, archived: true) }
- let(:session_params) do
- {
- email: admin.email,
- password: admin.password,
- }
- end
+ it "accepts email and password and prompts for otp" do
+ post admin_session_path, params: { admin: { email: admin.email } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { email: admin.email, password: admin.password } }, as: :turbo_stream
+ expect(response).to have_http_status(:unprocessable_content).and(have_rendered("otp"))
+ end
- it "renders an error" do
- action
- expect(response).to have_http_status(:unprocessable_content)
- end
+ it "fails on invalid otp" do
+ post admin_session_path, params: { admin: { email: admin.email } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { email: admin.email, password: admin.password } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { token: "000000" } }, as: :turbo_stream
+ expect(response).to have_http_status(:unprocessable_content).and(have_rendered("new"))
+ end
- it "does not create the admin session" do
- action
- expect(session[:admin_user_id]).not_to be_present
- end
+ it "accepts otp and redirects to dashboard" do
+ post admin_session_path, params: { admin: { email: admin.email } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { email: admin.email, password: admin.password } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { token: admin.otp.now } }, as: :turbo_stream
+ expect(response).to have_http_status(:see_other).and(redirect_to(admin_dashboard_path))
end
- context "with invalid credentials" do
- let(:session_params) do
- {
- email: admin.email,
- password: "invalid",
- }
- end
+ it "accepts otp and updates login metadata" do
+ expect do
+ post admin_session_path, params: { admin: { email: admin.email } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { email: admin.email, password: admin.password } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { token: admin.otp.now } }, as: :turbo_stream
+ end.to(change { admin.reload.current_sign_in_at })
+ end
- it "renders an error" do
- action
- expect(response).to have_http_status(:unprocessable_content)
+ context "with no otp present" do
+ let(:admin) { create(:admin, otp_secret: "") }
+
+ it "accepts otp and redirects to dashboard" do
+ post admin_session_path, params: { admin: { email: admin.email } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { email: admin.email, password: admin.password } }, as: :turbo_stream
+ expect(response).to have_http_status(:see_other).and(redirect_to(admin_dashboard_path))
end
+ end
- it "does not create the admin session" do
- action
- expect(session[:admin_user_id]).not_to be_present
+ context "with archived user" do
+ let(:admin) { create(:admin, archived: true) }
+
+ it "fails after email and password" do
+ post admin_session_path, params: { admin: { email: admin.email } }, as: :turbo_stream
+ post admin_session_path, params: { admin: { email: admin.email, password: admin.password } }, as: :turbo_stream
+ expect(response).to have_http_status(:unprocessable_content).and(have_rendered("new"))
end
end
diff --git a/spec/system/admin/authentication_spec.rb b/spec/system/admin/authentication_spec.rb
index 0a9e7e621..6896c839f 100644
--- a/spec/system/admin/authentication_spec.rb
+++ b/spec/system/admin/authentication_spec.rb
@@ -3,13 +3,31 @@
require "rails_helper"
RSpec.describe "admin/authentication" do
- it "supports password login" do
+ it "supports password login with 2fa" do
admin = create(:admin)
visit "/admin"
fill_in "Email", with: admin.email
+ click_on "Next"
+
fill_in "Password", with: admin.password
- click_on "Log in"
+ click_on "Next"
+
+ fill_in "Token", with: admin.otp.now
+ click_on "Next"
+
+ expect(page).to have_current_path("/admin/dashboard")
+ end
+
+ it "supports password login without 2fa" do
+ admin = create(:admin, otp_secret: "")
+ visit "/admin"
+
+ fill_in "Email", with: admin.email
+ click_on "Next"
+
+ fill_in "Password", with: admin.password
+ click_on "Next"
expect(page).to have_current_path("/admin/dashboard")
end
@@ -19,8 +37,13 @@
visit "/admin/admin_users"
fill_in "Email", with: admin.email
+ click_on "Next"
+
fill_in "Password", with: admin.password
- click_on "Log in"
+ click_on "Next"
+
+ fill_in "Token", with: admin.otp.now
+ click_on "Next"
expect(page).to have_current_path("/admin/admin_users")
end
diff --git a/spec/system/admin/invitation_spec.rb b/spec/system/admin/invitation_spec.rb
index b790f5f56..6eba709e5 100644
--- a/spec/system/admin/invitation_spec.rb
+++ b/spec/system/admin/invitation_spec.rb
@@ -9,13 +9,16 @@ def encode_token(**args)
it "creates an invitation" do
admin = create(:admin)
- visit "/admin"
+ visit "/admin/admin_users/new"
fill_in "Email", with: admin.email
+ click_on "Next"
+
fill_in "Password", with: admin.password
- click_on "Log in"
+ click_on "Next"
- visit "/admin/admin_users/new"
+ fill_in "Token", with: admin.otp.now
+ click_on "Next"
fill_in "Email", with: "john.doe@gmail.com"
fill_in "Name", with: "John Doe"
diff --git a/spec/system/index/filtering_spec.rb b/spec/system/index/filtering_spec.rb
index c5bb47ba8..40fbe931b 100644
--- a/spec/system/index/filtering_spec.rb
+++ b/spec/system/index/filtering_spec.rb
@@ -10,8 +10,13 @@
visit "/admin"
fill_in "Email", with: admin.email
+ click_on "Next"
+
fill_in "Password", with: admin.password
- click_on "Log in"
+ click_on "Next"
+
+ fill_in "Token", with: admin.otp.now
+ click_on "Next"
%i[first second third].map do |n|
create(:post, name: n, title: n.to_s.titleize)
diff --git a/spec/system/index/ordinal_spec.rb b/spec/system/index/ordinal_spec.rb
index faa02282a..71411ee03 100644
--- a/spec/system/index/ordinal_spec.rb
+++ b/spec/system/index/ordinal_spec.rb
@@ -9,8 +9,13 @@
visit "/admin"
fill_in "Email", with: admin.email
+ click_on "Next"
+
fill_in "Password", with: admin.password
- click_on "Log in"
+ click_on "Next"
+
+ fill_in "Token", with: admin.otp.now
+ click_on "Next"
%i[first second third].each_with_index do |n, i|
create(:banner, name: n, ordinal: i)
diff --git a/spec/system/index/pagination_spec.rb b/spec/system/index/pagination_spec.rb
index 040feec0b..52c0ff6df 100644
--- a/spec/system/index/pagination_spec.rb
+++ b/spec/system/index/pagination_spec.rb
@@ -12,8 +12,13 @@
visit "/admin"
fill_in "Email", with: admin.email
+ click_on "Next"
+
fill_in "Password", with: admin.password
- click_on "Log in"
+ click_on "Next"
+
+ fill_in "Token", with: admin.otp.now
+ click_on "Next"
end
context "when there are more than 20 results" do
diff --git a/spec/system/index/redirect_spec.rb b/spec/system/index/redirect_spec.rb
index df3132cec..a0e26eab1 100644
--- a/spec/system/index/redirect_spec.rb
+++ b/spec/system/index/redirect_spec.rb
@@ -10,8 +10,13 @@
visit "/admin"
fill_in "Email", with: admin.email
+ click_on "Next"
+
fill_in "Password", with: admin.password
- click_on "Log in"
+ click_on "Next"
+
+ fill_in "Token", with: admin.otp.now
+ click_on "Next"
end
it "can redirect to index via turbo" do
diff --git a/spec/system/index/sorting_spec.rb b/spec/system/index/sorting_spec.rb
index 6d60a6288..117c31915 100644
--- a/spec/system/index/sorting_spec.rb
+++ b/spec/system/index/sorting_spec.rb
@@ -9,8 +9,13 @@
visit "/admin"
fill_in "Email", with: admin.email
+ click_on "Next"
+
fill_in "Password", with: admin.password
- click_on "Log in"
+ click_on "Next"
+
+ fill_in "Token", with: admin.otp.now
+ click_on "Next"
%i[first second third].map do |n|
create(:post, name: n, title: n.to_s.titleize)
diff --git a/spec/system/index/table_spec.rb b/spec/system/index/table_spec.rb
index 149aca85f..4e890f9c4 100644
--- a/spec/system/index/table_spec.rb
+++ b/spec/system/index/table_spec.rb
@@ -9,8 +9,13 @@
visit "/admin"
fill_in "Email", with: admin.email
+ click_on "Next"
+
fill_in "Password", with: admin.password
- click_on "Log in"
+ click_on "Next"
+
+ fill_in "Token", with: admin.otp.now
+ click_on "Next"
end
it "renders a table" do