From fefec929c59c72dc93e4be30e8f23cd8c5258b0a Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 24 Jul 2026 13:52:56 +0200 Subject: Add self-service TOTP enrollment UI and witnessed admin reset --- app/controllers/otp_enrollments_controller.rb | 48 ++++++++++++++++++++++ app/controllers/users_controller.rb | 9 +++- app/views/otp_enrollments/show.html.erb | 23 +++++++++++ app/views/users/edit.html.erb | 30 ++++++++++++++ config/routes.rb | 8 +++- .../controllers/otp_enrollments_controller_test.rb | 41 ++++++++++++++++++ test/controllers/users_controller_test.rb | 14 ++++++- 7 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 app/controllers/otp_enrollments_controller.rb create mode 100644 app/views/otp_enrollments/show.html.erb create mode 100644 test/controllers/otp_enrollments_controller_test.rb diff --git a/app/controllers/otp_enrollments_controller.rb b/app/controllers/otp_enrollments_controller.rb new file mode 100644 index 00000000..7a31d1e1 --- /dev/null +++ b/app/controllers/otp_enrollments_controller.rb @@ -0,0 +1,48 @@ +# Self-service TOTP enrollment, deliberately scoped to current_user only: +# an administrator must never hold another account's secret -- admins get +# the witnessed reset on the user page instead. +class OtpEnrollmentsController < ApplicationController + before_action :login_required + + layout 'admin' + + # QR plus confirmation form; only meaningful while a pending secret exists. + def show + redirect_to edit_user_path(current_user) if current_user.otp_pending_secret.blank? + end + + # Begins (or restarts) enrollment. Requires the current password so an + # unattended logged-in session cannot be enrolled onto a stranger's phone. + def create + unless User.authenticate(current_user.login, params[:current_password].to_s) + flash[:error] = "Wrong password." + return redirect_to edit_user_path(current_user) + end + current_user.begin_otp_enrollment! + redirect_to otp_enrollment_path + end + + # Confirms with the first generated code. + def update + if current_user.confirm_otp_enrollment!(params[:code]) + flash[:notice] = "Second factor enabled. The code you just entered is " \ + "spent -- wait for the next one before logging in with it." + redirect_to edit_user_path(current_user) + else + flash.now[:error] = "That code did not match. Rescan or wait for the next code." + render :show + end + end + + # Self-service disable: password AND a current code. + def destroy + unless User.authenticate(current_user.login, params[:current_password].to_s) && + current_user.verify_otp!(params[:code]) + flash[:error] = "Password or code wrong -- second factor unchanged." + return redirect_to edit_user_path(current_user) + end + current_user.disable_otp!(:actor => current_user) + flash[:notice] = "Second factor disabled." + redirect_to edit_user_path(current_user) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 6572b7a2..08541b0c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,7 +3,7 @@ class UsersController < ApplicationController # Private before_action :login_required - before_action :find_user, :only => [:show, :edit, :update, :destroy] + before_action :find_user, :only => [:show, :edit, :update, :destroy, :reset_otp] before_action :verify_status, :except => [:index, :show] layout 'admin' @@ -52,6 +52,13 @@ class UsersController < ApplicationController redirect_to users_path end + def reset_otp + return deny_user_access unless current_user.admin? + @user.disable_otp!(:actor => current_user) + flash[:notice] = "Second factor reset for #{@user.login}" + redirect_to edit_user_path(@user) + end + private def user_params diff --git a/app/views/otp_enrollments/show.html.erb b/app/views/otp_enrollments/show.html.erb new file mode 100644 index 00000000..9dfa3422 --- /dev/null +++ b/app/views/otp_enrollments/show.html.erb @@ -0,0 +1,23 @@ +

Enable second factor

+ +
+
Scan
+
+ <%= raw RQRCode::QRCode.new(current_user.pending_otp_provisioning_uri) + .as_svg(:module_size => 4, :viewbox => true, + :color => "000", :fill => "fff") %> + Or enter the secret manually: + <%= current_user.otp_pending_secret %> +
+ +
Confirm
+
+ <%= form_tag otp_enrollment_path, :method => :put do %> + <%= text_field_tag :code, nil, :autofocus => true, + :autocomplete => "one-time-code", :inputmode => "numeric" %> + <%= submit_tag "Confirm" %> + <% end %> + Enter the six-digit code your app shows + for “<%= OTP_ISSUER %>”. +
+
diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 77b33c6a..8d14a058 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -30,4 +30,34 @@
<%= f.submit "Update" %>
<% end %> + + <% if @user == current_user %> +
Second factor
+
+ <% if current_user.otp_enrolled? %> +

Enabled.

+ <%= form_tag otp_enrollment_path, :method => :delete do %> + <%= password_field_tag :current_password, nil, :placeholder => "Current password" %> + <%= text_field_tag :code, nil, :placeholder => "Current code", + :autocomplete => "one-time-code", :inputmode => "numeric" %> + <%= submit_tag "Disable second factor" %> + <% end %> + <% else %> +

Not enrolled.

+ <%= form_tag otp_enrollment_path, :method => :post do %> + <%= password_field_tag :current_password, nil, :placeholder => "Current password" %> + <%= submit_tag "Enable second factor" %> + <% end %> + <% end %> +
+ <% elsif current_user.admin? && @user.otp_enrolled? %> +
Second factor
+
+ Enabled. + <%= button_to "Reset second factor", reset_otp_user_path(@user), :method => :put, + :form_class => "button_to destructive", + :form => { :data => { :confirm => + "Reset #{@user.login}'s second factor? They will log in with password only afterwards." } } %> +
+ <% end %> diff --git a/config/routes.rb b/config/routes.rb index 6223a408..862647df 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -97,7 +97,13 @@ Cccms::Application.routes.draw do match '/login' => 'sessions#new', :as => :login, :via => :get match 'search' => 'search#index', :as => :search, :via => :get - resources :users + resources :users do + member do + put :reset_otp + end + end + resource :otp_enrollment, :only => [:show, :create, :update, :destroy] + resource :otp_challenge, :only => [:new, :create] resources :menu_items do member do diff --git a/test/controllers/otp_enrollments_controller_test.rb b/test/controllers/otp_enrollments_controller_test.rb new file mode 100644 index 00000000..3bfab8e3 --- /dev/null +++ b/test/controllers/otp_enrollments_controller_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class OtpEnrollmentsControllerTest < ActionController::TestCase + include AuthenticatedTestHelper + fixtures :users + + def setup + login_as :quentin + @user = users(:quentin) + User.authenticate("quentin", "monkey") # ensure digest is migrated + end + + test "create requires the current password" do + post :create, params: { :current_password => "wrong" } + assert_nil @user.reload.otp_pending_secret + end + + test "create with the password begins enrollment" do + post :create, params: { :current_password => "monkey" } + assert @user.reload.otp_pending_secret.present? + assert_redirected_to otp_enrollment_path + end + + test "update with the first code completes enrollment" do + @user.begin_otp_enrollment! + code = ROTP::TOTP.new(@user.reload.otp_pending_secret).now + put :update, params: { :code => code } + assert @user.reload.otp_enrolled? + end + + test "destroy needs password and a current code" do + @user.update!(:otp_secret => ROTP::Base32.random) + + delete :destroy, params: { :current_password => "monkey", :code => "000000" } + assert @user.reload.otp_enrolled? + + code = ROTP::TOTP.new(@user.otp_secret).now + delete :destroy, params: { :current_password => "monkey", :code => code } + assert_not @user.reload.otp_enrolled? + end +end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 58f8a86b..946b5414 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -182,5 +182,17 @@ class UsersControllerTest < ActionController::TestCase assert_equal false, user.is_admin? end - + test "reset_otp is admin-only and witnessed" do + user = users(:quentin) + user.update!(:otp_secret => ROTP::Base32.random) + + login_as :quentin + put :reset_otp, params: { :id => user.id } + assert user.reload.otp_enrolled?, "non-admin must be refused" + + login_as :aaron + put :reset_otp, params: { :id => user.id } + assert_not user.reload.otp_enrolled? + assert_equal "otp_reset", NodeAction.last.action + end end -- cgit v1.3