diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-24 13:52:56 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-24 13:52:56 +0200 |
| commit | fefec929c59c72dc93e4be30e8f23cd8c5258b0a (patch) | |
| tree | e35d2e050a9052384e52a5ccb623d8dd193c2370 | |
| parent | cd36a46bbb5679ded1653f65bf4e8a74ae55100c (diff) | |
Add self-service TOTP enrollment UI and witnessed admin reset
| -rw-r--r-- | app/controllers/otp_enrollments_controller.rb | 48 | ||||
| -rw-r--r-- | app/controllers/users_controller.rb | 9 | ||||
| -rw-r--r-- | app/views/otp_enrollments/show.html.erb | 23 | ||||
| -rw-r--r-- | app/views/users/edit.html.erb | 30 | ||||
| -rw-r--r-- | config/routes.rb | 8 | ||||
| -rw-r--r-- | test/controllers/otp_enrollments_controller_test.rb | 41 | ||||
| -rw-r--r-- | test/controllers/users_controller_test.rb | 14 |
7 files changed, 170 insertions, 3 deletions
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 @@ | |||
| 1 | # Self-service TOTP enrollment, deliberately scoped to current_user only: | ||
| 2 | # an administrator must never hold another account's secret -- admins get | ||
| 3 | # the witnessed reset on the user page instead. | ||
| 4 | class OtpEnrollmentsController < ApplicationController | ||
| 5 | before_action :login_required | ||
| 6 | |||
| 7 | layout 'admin' | ||
| 8 | |||
| 9 | # QR plus confirmation form; only meaningful while a pending secret exists. | ||
| 10 | def show | ||
| 11 | redirect_to edit_user_path(current_user) if current_user.otp_pending_secret.blank? | ||
| 12 | end | ||
| 13 | |||
| 14 | # Begins (or restarts) enrollment. Requires the current password so an | ||
| 15 | # unattended logged-in session cannot be enrolled onto a stranger's phone. | ||
| 16 | def create | ||
| 17 | unless User.authenticate(current_user.login, params[:current_password].to_s) | ||
| 18 | flash[:error] = "Wrong password." | ||
| 19 | return redirect_to edit_user_path(current_user) | ||
| 20 | end | ||
| 21 | current_user.begin_otp_enrollment! | ||
| 22 | redirect_to otp_enrollment_path | ||
| 23 | end | ||
| 24 | |||
| 25 | # Confirms with the first generated code. | ||
| 26 | def update | ||
| 27 | if current_user.confirm_otp_enrollment!(params[:code]) | ||
| 28 | flash[:notice] = "Second factor enabled. The code you just entered is " \ | ||
| 29 | "spent -- wait for the next one before logging in with it." | ||
| 30 | redirect_to edit_user_path(current_user) | ||
| 31 | else | ||
| 32 | flash.now[:error] = "That code did not match. Rescan or wait for the next code." | ||
| 33 | render :show | ||
| 34 | end | ||
| 35 | end | ||
| 36 | |||
| 37 | # Self-service disable: password AND a current code. | ||
| 38 | def destroy | ||
| 39 | unless User.authenticate(current_user.login, params[:current_password].to_s) && | ||
| 40 | current_user.verify_otp!(params[:code]) | ||
| 41 | flash[:error] = "Password or code wrong -- second factor unchanged." | ||
| 42 | return redirect_to edit_user_path(current_user) | ||
| 43 | end | ||
| 44 | current_user.disable_otp!(:actor => current_user) | ||
| 45 | flash[:notice] = "Second factor disabled." | ||
| 46 | redirect_to edit_user_path(current_user) | ||
| 47 | end | ||
| 48 | 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 | |||
| 3 | # Private | 3 | # Private |
| 4 | 4 | ||
| 5 | before_action :login_required | 5 | before_action :login_required |
| 6 | before_action :find_user, :only => [:show, :edit, :update, :destroy] | 6 | before_action :find_user, :only => [:show, :edit, :update, :destroy, :reset_otp] |
| 7 | before_action :verify_status, :except => [:index, :show] | 7 | before_action :verify_status, :except => [:index, :show] |
| 8 | 8 | ||
| 9 | layout 'admin' | 9 | layout 'admin' |
| @@ -52,6 +52,13 @@ class UsersController < ApplicationController | |||
| 52 | redirect_to users_path | 52 | redirect_to users_path |
| 53 | end | 53 | end |
| 54 | 54 | ||
| 55 | def reset_otp | ||
| 56 | return deny_user_access unless current_user.admin? | ||
| 57 | @user.disable_otp!(:actor => current_user) | ||
| 58 | flash[:notice] = "Second factor reset for #{@user.login}" | ||
| 59 | redirect_to edit_user_path(@user) | ||
| 60 | end | ||
| 61 | |||
| 55 | private | 62 | private |
| 56 | 63 | ||
| 57 | def user_params | 64 | 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 @@ | |||
| 1 | <h1>Enable second factor</h1> | ||
| 2 | |||
| 3 | <div id="page_editor"> | ||
| 4 | <div class="node_description">Scan</div> | ||
| 5 | <div class="node_content"> | ||
| 6 | <%= raw RQRCode::QRCode.new(current_user.pending_otp_provisioning_uri) | ||
| 7 | .as_svg(:module_size => 4, :viewbox => true, | ||
| 8 | :color => "000", :fill => "fff") %> | ||
| 9 | <span class="field_hint">Or enter the secret manually: | ||
| 10 | <code><%= current_user.otp_pending_secret %></code></span> | ||
| 11 | </div> | ||
| 12 | |||
| 13 | <div class="node_description">Confirm</div> | ||
| 14 | <div class="node_content"> | ||
| 15 | <%= form_tag otp_enrollment_path, :method => :put do %> | ||
| 16 | <%= text_field_tag :code, nil, :autofocus => true, | ||
| 17 | :autocomplete => "one-time-code", :inputmode => "numeric" %> | ||
| 18 | <%= submit_tag "Confirm" %> | ||
| 19 | <% end %> | ||
| 20 | <span class="field_hint">Enter the six-digit code your app shows | ||
| 21 | for “<%= OTP_ISSUER %>”.</span> | ||
| 22 | </div> | ||
| 23 | </div> | ||
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 @@ | |||
| 30 | <div class="node_content"><%= f.submit "Update" %></div> | 30 | <div class="node_content"><%= f.submit "Update" %></div> |
| 31 | <% end %> | 31 | <% end %> |
| 32 | </div> | 32 | </div> |
| 33 | |||
| 34 | <% if @user == current_user %> | ||
| 35 | <div class="node_description">Second factor</div> | ||
| 36 | <div class="node_content"> | ||
| 37 | <% if current_user.otp_enrolled? %> | ||
| 38 | <p>Enabled.</p> | ||
| 39 | <%= form_tag otp_enrollment_path, :method => :delete do %> | ||
| 40 | <%= password_field_tag :current_password, nil, :placeholder => "Current password" %> | ||
| 41 | <%= text_field_tag :code, nil, :placeholder => "Current code", | ||
| 42 | :autocomplete => "one-time-code", :inputmode => "numeric" %> | ||
| 43 | <%= submit_tag "Disable second factor" %> | ||
| 44 | <% end %> | ||
| 45 | <% else %> | ||
| 46 | <p>Not enrolled.</p> | ||
| 47 | <%= form_tag otp_enrollment_path, :method => :post do %> | ||
| 48 | <%= password_field_tag :current_password, nil, :placeholder => "Current password" %> | ||
| 49 | <%= submit_tag "Enable second factor" %> | ||
| 50 | <% end %> | ||
| 51 | <% end %> | ||
| 52 | </div> | ||
| 53 | <% elsif current_user.admin? && @user.otp_enrolled? %> | ||
| 54 | <div class="node_description">Second factor</div> | ||
| 55 | <div class="node_content"> | ||
| 56 | Enabled. | ||
| 57 | <%= button_to "Reset second factor", reset_otp_user_path(@user), :method => :put, | ||
| 58 | :form_class => "button_to destructive", | ||
| 59 | :form => { :data => { :confirm => | ||
| 60 | "Reset #{@user.login}'s second factor? They will log in with password only afterwards." } } %> | ||
| 61 | </div> | ||
| 62 | <% end %> | ||
| 33 | </div> | 63 | </div> |
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 | |||
| 97 | match '/login' => 'sessions#new', :as => :login, :via => :get | 97 | match '/login' => 'sessions#new', :as => :login, :via => :get |
| 98 | match 'search' => 'search#index', :as => :search, :via => :get | 98 | match 'search' => 'search#index', :as => :search, :via => :get |
| 99 | 99 | ||
| 100 | resources :users | 100 | resources :users do |
| 101 | member do | ||
| 102 | put :reset_otp | ||
| 103 | end | ||
| 104 | end | ||
| 105 | resource :otp_enrollment, :only => [:show, :create, :update, :destroy] | ||
| 106 | resource :otp_challenge, :only => [:new, :create] | ||
| 101 | 107 | ||
| 102 | resources :menu_items do | 108 | resources :menu_items do |
| 103 | member do | 109 | 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 @@ | |||
| 1 | require "test_helper" | ||
| 2 | |||
| 3 | class OtpEnrollmentsControllerTest < ActionController::TestCase | ||
| 4 | include AuthenticatedTestHelper | ||
| 5 | fixtures :users | ||
| 6 | |||
| 7 | def setup | ||
| 8 | login_as :quentin | ||
| 9 | @user = users(:quentin) | ||
| 10 | User.authenticate("quentin", "monkey") # ensure digest is migrated | ||
| 11 | end | ||
| 12 | |||
| 13 | test "create requires the current password" do | ||
| 14 | post :create, params: { :current_password => "wrong" } | ||
| 15 | assert_nil @user.reload.otp_pending_secret | ||
| 16 | end | ||
| 17 | |||
| 18 | test "create with the password begins enrollment" do | ||
| 19 | post :create, params: { :current_password => "monkey" } | ||
| 20 | assert @user.reload.otp_pending_secret.present? | ||
| 21 | assert_redirected_to otp_enrollment_path | ||
| 22 | end | ||
| 23 | |||
| 24 | test "update with the first code completes enrollment" do | ||
| 25 | @user.begin_otp_enrollment! | ||
| 26 | code = ROTP::TOTP.new(@user.reload.otp_pending_secret).now | ||
| 27 | put :update, params: { :code => code } | ||
| 28 | assert @user.reload.otp_enrolled? | ||
| 29 | end | ||
| 30 | |||
| 31 | test "destroy needs password and a current code" do | ||
| 32 | @user.update!(:otp_secret => ROTP::Base32.random) | ||
| 33 | |||
| 34 | delete :destroy, params: { :current_password => "monkey", :code => "000000" } | ||
| 35 | assert @user.reload.otp_enrolled? | ||
| 36 | |||
| 37 | code = ROTP::TOTP.new(@user.otp_secret).now | ||
| 38 | delete :destroy, params: { :current_password => "monkey", :code => code } | ||
| 39 | assert_not @user.reload.otp_enrolled? | ||
| 40 | end | ||
| 41 | 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 | |||
| 182 | assert_equal false, user.is_admin? | 182 | assert_equal false, user.is_admin? |
| 183 | end | 183 | end |
| 184 | 184 | ||
| 185 | 185 | test "reset_otp is admin-only and witnessed" do | |
| 186 | user = users(:quentin) | ||
| 187 | user.update!(:otp_secret => ROTP::Base32.random) | ||
| 188 | |||
| 189 | login_as :quentin | ||
| 190 | put :reset_otp, params: { :id => user.id } | ||
| 191 | assert user.reload.otp_enrolled?, "non-admin must be refused" | ||
| 192 | |||
| 193 | login_as :aaron | ||
| 194 | put :reset_otp, params: { :id => user.id } | ||
| 195 | assert_not user.reload.otp_enrolled? | ||
| 196 | assert_equal "otp_reset", NodeAction.last.action | ||
| 197 | end | ||
| 186 | end | 198 | end |
