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 /lib | |
| 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.
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/authenticated_system.rb | 27 | ||||
| -rw-r--r-- | lib/authenticated_test_helper.rb | 4 |
2 files changed, 29 insertions, 2 deletions
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 |
