diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-08-01 18:38:42 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-08-01 18:38:42 +0200 |
| commit | 45432cba9f524c99015c90b6b6aa381fe2b04984 (patch) | |
| tree | 35345a3efedeaa1bd63db09ff99ac23187c081e3 | |
| parent | 4175f26cc8b22ff5a07929fa2436c561022cccd2 (diff) | |
Require a second factor for elevation, not for holding admin
| -rw-r--r-- | app/models/user.rb | 1 | ||||
| -rw-r--r-- | config/locales/de.yml | 2 | ||||
| -rw-r--r-- | config/locales/en.yml | 2 | ||||
| -rw-r--r-- | lib/authenticated_system.rb | 7 | ||||
| -rw-r--r-- | lib/authenticated_test_helper.rb | 6 | ||||
| -rw-r--r-- | test/controllers/users_controller_test.rb | 12 | ||||
| -rw-r--r-- | test/models/user_otp_test.rb | 32 |
7 files changed, 56 insertions, 6 deletions
diff --git a/app/models/user.rb b/app/models/user.rb index c3035a02..4b70ebe5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb | |||
| @@ -242,6 +242,7 @@ class User < ApplicationRecord | |||
| 242 | def admin_needs_second_factor | 242 | def admin_needs_second_factor |
| 243 | return unless roles.include?("admin") | 243 | return unless roles.include?("admin") |
| 244 | return if otp_secret.present? | 244 | return if otp_secret.present? |
| 245 | return if persisted? && roles_in_database.to_a.include?("admin") | ||
| 245 | errors.add(:roles, :admin_needs_otp) | 246 | errors.add(:roles, :admin_needs_otp) |
| 246 | end | 247 | end |
| 247 | end | 248 | end |
diff --git a/config/locales/de.yml b/config/locales/de.yml index fcceb023..93712574 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml | |||
| @@ -598,7 +598,7 @@ de: | |||
| 598 | code_mismatch_rescan: "Der Code hat nicht gepasst. Neu scannen oder auf den nächsten Code warten." | 598 | code_mismatch_rescan: "Der Code hat nicht gepasst. Neu scannen oder auf den nächsten Code warten." |
| 599 | wrong_password: "Falsches Passwort." | 599 | wrong_password: "Falsches Passwort." |
| 600 | wrong_credentials: "Passwort oder Code falsch — zweiter Faktor unverändert." | 600 | wrong_credentials: "Passwort oder Code falsch — zweiter Faktor unverändert." |
| 601 | enabled: "Zweiter Faktor aktiviert. Der eben eingegebene Code ist verbraucht — für die Anmeldung auf den nächsten warten." | 601 | enabled: "Zweiter Faktor aktiviert. (Der eben eingegebene Code ist jetzt verbraucht.)" |
| 602 | disabled: "Zweiter Faktor entfernt." | 602 | disabled: "Zweiter Faktor entfernt." |
| 603 | users: | 603 | users: |
| 604 | created: "Benutzer %{login} angelegt" | 604 | created: "Benutzer %{login} angelegt" |
diff --git a/config/locales/en.yml b/config/locales/en.yml index 81d9f4fb..cd502b31 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml | |||
| @@ -558,7 +558,7 @@ en: | |||
| 558 | code_mismatch_rescan: "That code did not match. Rescan or wait for the next code." | 558 | code_mismatch_rescan: "That code did not match. Rescan or wait for the next code." |
| 559 | wrong_password: "Wrong password." | 559 | wrong_password: "Wrong password." |
| 560 | wrong_credentials: "Password or code wrong -- second factor unchanged." | 560 | wrong_credentials: "Password or code wrong -- second factor unchanged." |
| 561 | enabled: "Second factor enabled. The code you just entered is spent -- wait for the next one before logging in with it." | 561 | enabled: "Second factor enabled. (The OTP code is spent now.)" |
| 562 | disabled: "Second factor disabled." | 562 | disabled: "Second factor disabled." |
| 563 | users: | 563 | users: |
| 564 | created: "User created %{login}" | 564 | created: "User created %{login}" |
diff --git a/lib/authenticated_system.rb b/lib/authenticated_system.rb index 04d8051f..668436b5 100644 --- a/lib/authenticated_system.rb +++ b/lib/authenticated_system.rb | |||
| @@ -25,9 +25,10 @@ module AuthenticatedSystem | |||
| 25 | # Tied to is_admin? so losing the role closes the window at once, rather | 25 | # Tied to is_admin? so losing the role closes the window at once, rather |
| 26 | # than leaving a timestamp that would count again if the role returned. | 26 | # than leaving a timestamp that would count again if the role returned. |
| 27 | def elevated? | 27 | def elevated? |
| 28 | return false unless current_user&.is_admin? | 28 | return false unless current_user&.is_admin? |
| 29 | session[:elevated_at].to_i > ELEVATION_MAX_AGE.ago.to_i | 29 | return false unless current_user.otp_enrolled? |
| 30 | end | 30 | session[:elevated_at].to_i > ELEVATION_MAX_AGE.ago.to_i |
| 31 | end | ||
| 31 | 32 | ||
| 32 | def elevation_expires_at | 33 | def elevation_expires_at |
| 33 | return nil unless elevated? | 34 | return nil unless elevated? |
diff --git a/lib/authenticated_test_helper.rb b/lib/authenticated_test_helper.rb index 065a5f7d..483e6c88 100644 --- a/lib/authenticated_test_helper.rb +++ b/lib/authenticated_test_helper.rb | |||
| @@ -6,6 +6,10 @@ module AuthenticatedTestHelper | |||
| 6 | end | 6 | end |
| 7 | 7 | ||
| 8 | def elevate_session! | 8 | def elevate_session! |
| 9 | session[:elevated_at] = Time.now.to_i | 9 | user = User.find_by(:id => @request.session[:user_id]) |
| 10 | if user && !user.otp_enrolled? | ||
| 11 | user.update_column(:otp_secret, ROTP::Base32.random) | ||
| 12 | end | ||
| 13 | @request.session[:elevated_at] = Time.now.to_i | ||
| 10 | end | 14 | end |
| 11 | end | 15 | end |
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 2dd0759a..0c517647 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb | |||
| @@ -273,4 +273,16 @@ class UsersControllerTest < ActionController::TestCase | |||
| 273 | 273 | ||
| 274 | assert_not user.reload.is_admin? | 274 | assert_not user.reload.is_admin? |
| 275 | end | 275 | end |
| 276 | |||
| 277 | test "clearing the factor closes an open elevation window" do | ||
| 278 | login_as :aaron | ||
| 279 | elevate_session! | ||
| 280 | get :new, params: { :locale => "de" } | ||
| 281 | assert_response :success | ||
| 282 | |||
| 283 | users(:aaron).update_column(:otp_secret, nil) | ||
| 284 | |||
| 285 | get :new, params: { :locale => "de" } | ||
| 286 | assert_redirected_to new_elevation_path | ||
| 287 | end | ||
| 276 | end | 288 | end |
diff --git a/test/models/user_otp_test.rb b/test/models/user_otp_test.rb index 81f25575..2044ac18 100644 --- a/test/models/user_otp_test.rb +++ b/test/models/user_otp_test.rb | |||
| @@ -74,6 +74,38 @@ class UserOtpTest < ActiveSupport::TestCase | |||
| 74 | assert_equal @user.login, action.metadata["target_login"] | 74 | assert_equal @user.login, action.metadata["target_login"] |
| 75 | end | 75 | end |
| 76 | 76 | ||
| 77 | test "an admin without a factor can start enrollment" do | ||
| 78 | admin = users(:aaron) | ||
| 79 | assert admin.is_admin? | ||
| 80 | assert_not admin.otp_enrolled? | ||
| 81 | |||
| 82 | uri = admin.begin_otp_enrollment! | ||
| 83 | |||
| 84 | assert admin.reload.otp_pending_secret.present? | ||
| 85 | assert uri.present? | ||
| 86 | end | ||
| 87 | |||
| 88 | test "an admin disabling their factor keeps the role" do | ||
| 89 | admin = users(:aaron) | ||
| 90 | admin.update_column(:otp_secret, ROTP::Base32.random) | ||
| 91 | |||
| 92 | admin.disable_otp!(:actor => admin) | ||
| 93 | |||
| 94 | assert_not admin.reload.otp_enrolled? | ||
| 95 | assert admin.is_admin? | ||
| 96 | end | ||
| 97 | |||
| 98 | test "a fellow admin can reset an admin's factor" do | ||
| 99 | admin = users(:aaron) | ||
| 100 | admin.update_column(:otp_secret, ROTP::Base32.random) | ||
| 101 | actor = users(:redella) | ||
| 102 | |||
| 103 | admin.disable_otp!(:actor => actor) | ||
| 104 | |||
| 105 | assert_not admin.reload.otp_enrolled? | ||
| 106 | assert_equal "otp_reset", NodeAction.last.action | ||
| 107 | end | ||
| 108 | |||
| 77 | private | 109 | private |
| 78 | 110 | ||
| 79 | def enroll!(user) | 111 | def enroll!(user) |
