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 ++++- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 app/controllers/otp_enrollments_controller.rb (limited to 'app/controllers') 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 -- cgit v1.3