summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-24 13:52:56 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-24 13:52:56 +0200
commitfefec929c59c72dc93e4be30e8f23cd8c5258b0a (patch)
treee35d2e050a9052384e52a5ccb623d8dd193c2370
parentcd36a46bbb5679ded1653f65bf4e8a74ae55100c (diff)
Add self-service TOTP enrollment UI and witnessed admin reset
-rw-r--r--app/controllers/otp_enrollments_controller.rb48
-rw-r--r--app/controllers/users_controller.rb9
-rw-r--r--app/views/otp_enrollments/show.html.erb23
-rw-r--r--app/views/users/edit.html.erb30
-rw-r--r--config/routes.rb8
-rw-r--r--test/controllers/otp_enrollments_controller_test.rb41
-rw-r--r--test/controllers/users_controller_test.rb14
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.
4class 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
48end
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 @@
1require "test_helper"
2
3class 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
41end
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
186end 198end