diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-08-01 01:10:35 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-08-01 01:10:35 +0200 |
| commit | f6c1f0f08f031778a491465d35ac694bfcdc12b0 (patch) | |
| tree | 99aec0f419b1122821124fdb7ae8bdf4bc5831db | |
| parent | df90138fb55f7d3652d3d69d58325d7329920f51 (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.rb | 9 | ||||
| -rw-r--r-- | app/controllers/elevations_controller.rb | 44 | ||||
| -rw-r--r-- | app/controllers/otp_challenges_controller.rb | 3 | ||||
| -rw-r--r-- | app/controllers/users_controller.rb | 3 | ||||
| -rw-r--r-- | app/views/elevations/new.html.erb | 23 | ||||
| -rw-r--r-- | config/locales/de.yml | 11 | ||||
| -rw-r--r-- | config/locales/en.yml | 11 | ||||
| -rw-r--r-- | config/routes.rb | 1 | ||||
| -rw-r--r-- | lib/authenticated_system.rb | 27 | ||||
| -rw-r--r-- | lib/authenticated_test_helper.rb | 4 | ||||
| -rw-r--r-- | test/controllers/users_controller_test.rb | 22 |
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 | ||
| 23 | end | 32 | end |
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. | ||
| 4 | class 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 | ||
| 44 | end | ||
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 @@ | |||
| 1 | module AuthenticatedSystem | 1 | module 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 | ||
| 7 | end | 11 | end |
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 | ||
| 242 | end | 264 | end |
