summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-08-01 01:10:35 +0200
committererdgeist <erdgeist@erdgeist.org>2026-08-01 01:10:35 +0200
commitf6c1f0f08f031778a491465d35ac694bfcdc12b0 (patch)
tree99aec0f419b1122821124fdb7ae8bdf4bc5831db
parentdf90138fb55f7d3652d3d69d58325d7329920f51 (diff)
Require a fresh second factor for user management
Administrative actions are gated behind a 30-minute elevation window: creating and retiring accounts, editing roles, clearing a second factor. Reading the list is not gated, and content work is untouched. elevated? is tied to is_admin?, so losing the role closes the window at once. The window opens when the second factor verifies at login, so an admin heading straight for user management is already elevated, and closes on logout with the other session state. Five wrong codes end the session, mirroring the login challenge. users#update carries no elevation filter, since self-service reaches it; the role field is gated in user_params instead and fails closed.
-rw-r--r--app/controllers/concerns/role_required.rb9
-rw-r--r--app/controllers/elevations_controller.rb44
-rw-r--r--app/controllers/otp_challenges_controller.rb3
-rw-r--r--app/controllers/users_controller.rb3
-rw-r--r--app/views/elevations/new.html.erb23
-rw-r--r--config/locales/de.yml11
-rw-r--r--config/locales/en.yml11
-rw-r--r--config/routes.rb1
-rw-r--r--lib/authenticated_system.rb27
-rw-r--r--lib/authenticated_test_helper.rb4
-rw-r--r--test/controllers/users_controller_test.rb22
11 files changed, 155 insertions, 3 deletions
diff --git a/app/controllers/concerns/role_required.rb b/app/controllers/concerns/role_required.rb
index b841b8cc..22ce625e 100644
--- a/app/controllers/concerns/role_required.rb
+++ b/app/controllers/concerns/role_required.rb
@@ -20,4 +20,13 @@ module RoleRequired
20 flash[:error] = t("flash.common.#{key}") 20 flash[:error] = t("flash.common.#{key}")
21 redirect_to admin_path 21 redirect_to admin_path
22 end 22 end
23
24 # require_admin must precede this in the filter chain, so a non-admin is
25 # denied by role before elevation is ever considered.
26 def require_elevation
27 return if elevated?
28
29 session[:elevation_return_to] = request.fullpath
30 redirect_to new_elevation_path
31 end
23end 32end
diff --git a/app/controllers/elevations_controller.rb b/app/controllers/elevations_controller.rb
new file mode 100644
index 00000000..804b8f95
--- /dev/null
+++ b/app/controllers/elevations_controller.rb
@@ -0,0 +1,44 @@
1# Step-up authentication for janitorial work. Mirrors the two-step login's
2# attempt cap: verify_otp! guards replay but not rate, and an attacker with a
3# stolen cookie would otherwise have unlimited tries at a six-digit code.
4class ElevationsController < ApplicationController
5 include RoleRequired
6
7 layout 'admin'
8
9 before_action :login_required
10 before_action :require_admin
11
12 MAX_ATTEMPTS = 5
13
14 def new
15 redirect_to admin_path if elevated?
16 end
17
18 def create
19 return render(:new) unless current_user.otp_enrolled?
20
21 session[:elevation_attempts] = session[:elevation_attempts].to_i + 1
22 if session[:elevation_attempts] > MAX_ATTEMPTS
23 logout_killing_session!
24 flash[:error] = t("flash.otp.too_many_attempts")
25 return redirect_to(login_path)
26 end
27
28 if current_user.verify_otp!(params[:code])
29 session.delete(:elevation_attempts)
30 elevate!
31 redirect_to safe_return_to(session.delete(:elevation_return_to),
32 :default => users_path)
33 else
34 flash.now[:error] = t("flash.otp.code_mismatch")
35 render :new
36 end
37 end
38
39 def destroy
40 drop_elevation!
41 flash[:notice] = t("flash.elevation.dropped")
42 redirect_to admin_path
43 end
44end
diff --git a/app/controllers/otp_challenges_controller.rb b/app/controllers/otp_challenges_controller.rb
index eeaeac20..87586241 100644
--- a/app/controllers/otp_challenges_controller.rb
+++ b/app/controllers/otp_challenges_controller.rb
@@ -28,6 +28,9 @@ class OtpChallengesController < ApplicationController
28 reset_session 28 reset_session
29 self.current_user = user 29 self.current_user = user
30 session[:logged_in_at] = Time.now.to_i 30 session[:logged_in_at] = Time.now.to_i
31 # an admin who logs in and goes straight to user management
32 # is already elevated
33 elevate! if user.is_admin?
31 flash[:notice] = t("flash.common.logged_in") 34 flash[:notice] = t("flash.common.logged_in")
32 redirect_to safe_return_to(return_to, :default => admin_path) 35 redirect_to safe_return_to(return_to, :default => admin_path)
33 else 36 else
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 052b2928..583ebac0 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -7,6 +7,7 @@ class UsersController < ApplicationController
7 before_action :login_required 7 before_action :login_required
8 before_action :find_user, :only => [:show, :edit, :update, :reset_otp, :deactivate, :reactivate] 8 before_action :find_user, :only => [:show, :edit, :update, :reset_otp, :deactivate, :reactivate]
9 before_action :require_admin, :only => [:index, :new, :create, :reset_otp, :deactivate, :reactivate] 9 before_action :require_admin, :only => [:index, :new, :create, :reset_otp, :deactivate, :reactivate]
10 before_action :require_elevation, :only => [:new, :create, :reset_otp, :deactivate, :reactivate]
10 before_action :verify_status, :except => [:index] 11 before_action :verify_status, :except => [:index]
11 12
12 layout 'admin' 13 layout 'admin'
@@ -87,7 +88,7 @@ class UsersController < ApplicationController
87 :roles => []) 88 :roles => [])
88 # Checkbox arrays post a leading blank from the hidden field. 89 # Checkbox arrays post a leading blank from the hidden field.
89 permitted[:roles] = Array(permitted[:roles]).reject(&:blank?) if permitted.key?(:roles) 90 permitted[:roles] = Array(permitted[:roles]).reject(&:blank?) if permitted.key?(:roles)
90 permitted.delete(:roles) unless current_user.is_admin? 91 permitted.delete(:roles) unless current_user.is_admin? && elevated?
91 permitted 92 permitted
92 end 93 end
93 94
diff --git a/app/views/elevations/new.html.erb b/app/views/elevations/new.html.erb
new file mode 100644
index 00000000..1091ff36
--- /dev/null
+++ b/app/views/elevations/new.html.erb
@@ -0,0 +1,23 @@
1<h1><%= t(".title") %></h1>
2
3<div id="admin_layout">
4 <% if current_user.otp_enrolled? %>
5 <p class="field_hint">
6 <%= t(".hint", :minutes => AuthenticatedSystem::ELEVATION_MAX_AGE.in_minutes.to_i) %>
7 </p>
8 <%= form_tag elevation_path do %>
9 <div class="layout_row_label"><%= t(".code") %></div>
10 <div class="layout_row_content">
11 <%= text_field_tag :code, nil, :autocomplete => "one-time-code",
12 :inputmode => "numeric", :autofocus => true %>
13 </div>
14 <div class="layout_row_label"></div>
15 <div class="layout_row_content"><%= submit_tag t(".elevate") %></div>
16 <% end %>
17 <% else %>
18 <%# An admin without an enrolled factor cannot elevate at all. Say so and
19 point at enrolment rather than showing a field that cannot work. %>
20 <p><%= t(".not_enrolled") %></p>
21 <%= link_to t(".enrol"), otp_enrollment_path, :class => "action_button" %>
22 <% end %>
23</div>
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 04eb738e..7edffe2d 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -628,6 +628,8 @@ de:
628 none_to_remove: "Es existiert keine %{lang}-Übersetzung zum Entfernen." 628 none_to_remove: "Es existiert keine %{lang}-Übersetzung zum Entfernen."
629 last_translation: "Die einzige verbliebene Übersetzung kann nicht entfernt werden." 629 last_translation: "Die einzige verbliebene Übersetzung kann nicht entfernt werden."
630 removed: "%{lang}-Übersetzung aus dem Entwurf entfernt. Veröffentlichen, um das dauerhaft zu machen." 630 removed: "%{lang}-Übersetzung aus dem Entwurf entfernt. Veröffentlichen, um das dauerhaft zu machen."
631 elevation:
632 dropped: "Administrative Rechte abgelegt."
631 633
632 assets: 634 assets:
633 index: 635 index:
@@ -721,6 +723,15 @@ de:
721 title_fields: 723 title_fields:
722 falls_back: "Menu-Titel. Leere Titel werden aus deutsch übernommen" 724 falls_back: "Menu-Titel. Leere Titel werden aus deutsch übernommen"
723 725
726 elevations:
727 new:
728 title: "Administrative Rechte anfordern"
729 hint: "Für Benutzerverwaltung ist ein aktueller zweiter Faktor nötig. Die Rechte gelten dann %{minutes} Minuten."
730 code: "Aktueller Code"
731 elevate: "Rechte anfordern"
732 not_enrolled: "Für administrative Arbeiten ist ein zweiter Faktor erforderlich. Bitte zuerst einen einrichten."
733 enrol: "Zweiten Faktor einrichten"
734
724 layouts: 735 layouts:
725 application: 736 application:
726 light_mode_aria: "Zwischen dunklem und hellem Modus wechseln" 737 light_mode_aria: "Zwischen dunklem und hellem Modus wechseln"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 2f7ac72f..9a8041bf 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -587,6 +587,8 @@ en:
587 none_to_remove: "No %{lang} translation exists to remove." 587 none_to_remove: "No %{lang} translation exists to remove."
588 last_translation: "Can't remove the only remaining translation." 588 last_translation: "Can't remove the only remaining translation."
589 removed: "%{lang} translation removed from the draft. Publish to make this permanent." 589 removed: "%{lang} translation removed from the draft. Publish to make this permanent."
590 elevation:
591 dropped: "Administrative rights dropped."
590 592
591 assets: 593 assets:
592 index: 594 index:
@@ -664,6 +666,15 @@ en:
664 title: "Compare" 666 title: "Compare"
665 default_marker: "default" 667 default_marker: "default"
666 668
669 elevations:
670 new:
671 title: "Request administrative rights"
672 hint: "User management needs a current second factor. The rights then last %{minutes} minutes."
673 code: "Current code"
674 elevate: "Request rights"
675 not_enrolled: "Administrative work requires a second factor. Please set one up first."
676 enrol: "Set up a second factor"
677
667 layouts: 678 layouts:
668 application: 679 application:
669 light_mode_aria: "Switch between dark and light mode" 680 light_mode_aria: "Switch between dark and light mode"
diff --git a/config/routes.rb b/config/routes.rb
index 1898dbbb..4c37e70f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -106,6 +106,7 @@ Cccms::Application.routes.draw do
106 end 106 end
107 resource :otp_enrollment, :only => [:show, :create, :update, :destroy] 107 resource :otp_enrollment, :only => [:show, :create, :update, :destroy]
108 resource :otp_challenge, :only => [:new, :create] 108 resource :otp_challenge, :only => [:new, :create]
109 resource :elevation, :only => [:new, :create, :destroy]
109 110
110 resources :menu_items, :except => :show do 111 resources :menu_items, :except => :show do
111 member do 112 member do
diff --git a/lib/authenticated_system.rb b/lib/authenticated_system.rb
index 4e70c28d..9a351dde 100644
--- a/lib/authenticated_system.rb
+++ b/lib/authenticated_system.rb
@@ -1,5 +1,6 @@
1module AuthenticatedSystem 1module AuthenticatedSystem
2 SESSION_MAX_AGE = 7.days 2 SESSION_MAX_AGE = 7.days
3 ELEVATION_MAX_AGE = 30.minutes
3 4
4 protected 5 protected
5 # Returns true or false if the user is logged in. 6 # Returns true or false if the user is logged in.
@@ -20,6 +21,26 @@ module AuthenticatedSystem
20 @current_user = new_user || false 21 @current_user = new_user || false
21 end 22 end
22 23
24 # Tied to is_admin? so losing the role closes the window at once, rather
25 # than leaving a timestamp that would count again if the role returned.
26 def elevated?
27 return false unless current_user&.is_admin?
28 session[:elevated_at].to_i > ELEVATION_MAX_AGE.ago.to_i
29 end
30
31 def elevation_expires_at
32 return nil unless elevated?
33 Time.at(session[:elevated_at].to_i) + ELEVATION_MAX_AGE
34 end
35
36 def elevate!
37 session[:elevated_at] = Time.now.to_i
38 end
39
40 def drop_elevation!
41 session.delete(:elevated_at)
42 end
43
23 # Check if the user is authorized 44 # Check if the user is authorized
24 # 45 #
25 # Override this method in your controllers if you want to restrict access 46 # Override this method in your controllers if you want to restrict access
@@ -91,7 +112,8 @@ module AuthenticatedSystem
91 # Inclusion hook to make #current_user and #logged_in? 112 # Inclusion hook to make #current_user and #logged_in?
92 # available as ActionView helper methods. 113 # available as ActionView helper methods.
93 def self.included(base) 114 def self.included(base)
94 base.send :helper_method, :current_user, :logged_in?, :authorized? if base.respond_to? :helper_method 115 base.send :helper_method, :current_user, :logged_in?, :authorized?,
116 :elevated?, :elevation_expires_at if base.respond_to? :helper_method
95 end 117 end
96 118
97 # 119 #
@@ -123,7 +145,8 @@ module AuthenticatedSystem
123 def logout_keeping_session! 145 def logout_keeping_session!
124 @current_user = false # not logged in, and don't do it for me 146 @current_user = false # not logged in, and don't do it for me
125 session[:user_id] = nil # keeps the session but kill our variable 147 session[:user_id] = nil # keeps the session but kill our variable
126 # explicitly kill any other session variables you set 148 session.delete(:elevated_at)
149 session.delete(:elevation_attempts)
127 end 150 end
128 151
129 # The session should only be reset at the tail end of a form POST -- 152 # The session should only be reset at the tail end of a form POST --
diff --git a/lib/authenticated_test_helper.rb b/lib/authenticated_test_helper.rb
index 8f3a3732..065a5f7d 100644
--- a/lib/authenticated_test_helper.rb
+++ b/lib/authenticated_test_helper.rb
@@ -4,4 +4,8 @@ module AuthenticatedTestHelper
4 @request.session[:user_id] = user ? users(user).id : nil 4 @request.session[:user_id] = user ? users(user).id : nil
5 @request.session[:logged_in_at] = Time.now.to_i 5 @request.session[:logged_in_at] = Time.now.to_i
6 end 6 end
7
8 def elevate_session!
9 session[:elevated_at] = Time.now.to_i
10 end
7end 11end
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb
index fe099928..b6f0970d 100644
--- a/test/controllers/users_controller_test.rb
+++ b/test/controllers/users_controller_test.rb
@@ -19,6 +19,7 @@ class UsersControllerTest < ActionController::TestCase
19 19
20 test "get new when logged in as admin" do 20 test "get new when logged in as admin" do
21 login_as :aaron 21 login_as :aaron
22 elevate_session!
22 get :new 23 get :new
23 assert_response :success 24 assert_response :success
24 end 25 end
@@ -36,6 +37,7 @@ class UsersControllerTest < ActionController::TestCase
36 37
37 test "creating new users being logged in as admin" do 38 test "creating new users being logged in as admin" do
38 login_as :aaron 39 login_as :aaron
40 elevate_session!
39 assert_difference "User.count", +1 do 41 assert_difference "User.count", +1 do
40 post :create, params: { 42 post :create, params: {
41 :user => { 43 :user => {
@@ -53,6 +55,7 @@ class UsersControllerTest < ActionController::TestCase
53 55
54 test "creating new admin users being logged in as admin" do 56 test "creating new admin users being logged in as admin" do
55 login_as :aaron 57 login_as :aaron
58 elevate_session!
56 assert_difference "User.count", +1 do 59 assert_difference "User.count", +1 do
57 post :create, params: { 60 post :create, params: {
58 :user => { 61 :user => {
@@ -154,6 +157,7 @@ class UsersControllerTest < ActionController::TestCase
154 157
155 test "an admin deactivates another user, who can no longer sign in" do 158 test "an admin deactivates another user, who can no longer sign in" do
156 login_as :aaron 159 login_as :aaron
160 elevate_session!
157 user = users(:quentin) 161 user = users(:quentin)
158 162
159 put :deactivate, params: { :id => user.id } 163 put :deactivate, params: { :id => user.id }
@@ -165,6 +169,7 @@ class UsersControllerTest < ActionController::TestCase
165 169
166 test "reactivation restores the other roles untouched" do 170 test "reactivation restores the other roles untouched" do
167 login_as :aaron 171 login_as :aaron
172 elevate_session!
168 user = users(:quentin) 173 user = users(:quentin)
169 user.update_column(:roles, ["alumni", "redaktion"]) 174 user.update_column(:roles, ["alumni", "redaktion"])
170 175
@@ -189,6 +194,7 @@ class UsersControllerTest < ActionController::TestCase
189 194
190 test "admin user can promote regular users to admins" do 195 test "admin user can promote regular users to admins" do
191 login_as :aaron 196 login_as :aaron
197 elevate_session!
192 user = users(:quentin) 198 user = users(:quentin)
193 put :update, params: { :id => user.id, :user => {:roles => ["admin", "redaktion"]} } 199 put :update, params: { :id => user.id, :user => {:roles => ["admin", "redaktion"]} }
194 200
@@ -212,6 +218,7 @@ class UsersControllerTest < ActionController::TestCase
212 assert user.reload.otp_enrolled?, "non-admin must be refused" 218 assert user.reload.otp_enrolled?, "non-admin must be refused"
213 219
214 login_as :aaron 220 login_as :aaron
221 elevate_session!
215 put :reset_otp, params: { :id => user.id } 222 put :reset_otp, params: { :id => user.id }
216 assert_not user.reload.otp_enrolled? 223 assert_not user.reload.otp_enrolled?
217 assert_equal "otp_reset", NodeAction.last.action 224 assert_equal "otp_reset", NodeAction.last.action
@@ -239,4 +246,19 @@ class UsersControllerTest < ActionController::TestCase
239 get :show, params: { :id => users(:aaron).id } 246 get :show, params: { :id => users(:aaron).id }
240 assert_redirected_to admin_path 247 assert_redirected_to admin_path
241 end 248 end
249
250 test "an admin without an open window is sent to elevation" do
251 login_as :aaron
252 get :new
253 assert_redirected_to new_elevation_path
254 end
255
256 test "an unelevated admin cannot change roles" do
257 login_as :aaron
258 user = users(:quentin)
259
260 put :update, params: { :id => user.id, :user => { :roles => ["admin"] } }
261
262 assert_not user.reload.is_admin?
263 end
242end 264end