summaryrefslogtreecommitdiff
path: root/app
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 /app
parentcd36a46bbb5679ded1653f65bf4e8a74ae55100c (diff)
Add self-service TOTP enrollment UI and witnessed admin reset
Diffstat (limited to 'app')
-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
4 files changed, 109 insertions, 1 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>