diff options
| -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 |
