diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-24 13:53:13 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-24 13:53:13 +0200 |
| commit | dcb576618b868b888a5b1b31e35491f300ce4050 (patch) | |
| tree | 3a970eae416fba2939cd366b1ba2b5d294154f2c | |
| parent | fefec929c59c72dc93e4be30e8f23cd8c5258b0a (diff) | |
Complete the login only after the second factor
Enrolled users get a pending marker instead of a session after the
password step; a valid code through the challenge writes the real
session via reset_session. otp_required without enrollment funnels
into setup everywhere except the enrollment, user, and login
machinery.
| -rw-r--r-- | app/controllers/application_controller.rb | 13 | ||||
| -rw-r--r-- | app/controllers/otp_challenges_controller.rb | 54 | ||||
| -rw-r--r-- | app/controllers/sessions_controller.rb | 20 | ||||
| -rw-r--r-- | app/views/otp_challenges/new.html.erb | 13 | ||||
| -rw-r--r-- | test/controllers/admin_controller_test.rb | 7 | ||||
| -rw-r--r-- | test/controllers/otp_challenges_controller_test.rb | 39 | ||||
| -rw-r--r-- | test/controllers/sessions_controller_test.rb | 15 |
7 files changed, 158 insertions, 3 deletions
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d8de9750..6d46d522 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb | |||
| @@ -4,6 +4,7 @@ class ApplicationController < ActionController::Base | |||
| 4 | protect_from_forgery | 4 | protect_from_forgery |
| 5 | 5 | ||
| 6 | before_action :set_locale | 6 | before_action :set_locale |
| 7 | before_action :enforce_otp_enrollment | ||
| 7 | 8 | ||
| 8 | helper_method :safe_return_to | 9 | helper_method :safe_return_to |
| 9 | 10 | ||
| @@ -30,4 +31,16 @@ class ApplicationController < ActionController::Base | |||
| 30 | rescue URI::InvalidURIError | 31 | rescue URI::InvalidURIError |
| 31 | default | 32 | default |
| 32 | end | 33 | end |
| 34 | |||
| 35 | # The hard gate for the slow transition: a user flagged otp_required | ||
| 36 | # who has not enrolled can reach only enrollment, their own user page, | ||
| 37 | # the login machinery, and the challenge -- everything else funnels | ||
| 38 | # into setup. Anonymous visitors are untouched (not logged_in?). | ||
| 39 | def enforce_otp_enrollment | ||
| 40 | return unless logged_in? | ||
| 41 | return unless current_user.otp_required? && !current_user.otp_enrolled? | ||
| 42 | return if %w[otp_enrollments otp_challenges sessions users].include?(controller_name) | ||
| 43 | flash[:error] = "Your account requires a second factor -- set it up to continue." | ||
| 44 | redirect_to edit_user_path(current_user) | ||
| 45 | end | ||
| 33 | end | 46 | end |
diff --git a/app/controllers/otp_challenges_controller.rb b/app/controllers/otp_challenges_controller.rb new file mode 100644 index 00000000..892503a8 --- /dev/null +++ b/app/controllers/otp_challenges_controller.rb | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | # The second half of a two-step login. A pending marker (set by | ||
| 2 | # sessions#create after a correct password) plus deadline and attempt | ||
| 3 | # counter live in the session; the real user_id is only written after a | ||
| 4 | # valid code, through a fresh session. | ||
| 5 | class OtpChallengesController < ApplicationController | ||
| 6 | |||
| 7 | layout 'admin' | ||
| 8 | |||
| 9 | MAX_ATTEMPTS = 5 | ||
| 10 | |||
| 11 | def new | ||
| 12 | redirect_to login_path unless pending_user | ||
| 13 | end | ||
| 14 | |||
| 15 | def create | ||
| 16 | user = pending_user | ||
| 17 | return redirect_to login_path unless user | ||
| 18 | |||
| 19 | session[:otp_attempts] = session[:otp_attempts].to_i + 1 | ||
| 20 | if session[:otp_attempts] > MAX_ATTEMPTS | ||
| 21 | clear_pending | ||
| 22 | flash[:error] = "Too many attempts -- log in again." | ||
| 23 | return redirect_to login_path | ||
| 24 | end | ||
| 25 | |||
| 26 | if user.verify_otp!(params[:code]) | ||
| 27 | return_to = session[:return_to] | ||
| 28 | reset_session | ||
| 29 | self.current_user = user | ||
| 30 | flash[:notice] = "Logged in successfully" | ||
| 31 | redirect_to safe_return_to(return_to, :default => admin_path) | ||
| 32 | else | ||
| 33 | flash.now[:error] = "That code did not match." | ||
| 34 | render :new | ||
| 35 | end | ||
| 36 | end | ||
| 37 | |||
| 38 | private | ||
| 39 | |||
| 40 | def pending_user | ||
| 41 | return nil if session[:pending_otp_user_id].blank? | ||
| 42 | if session[:otp_deadline].to_i < Time.now.to_i | ||
| 43 | clear_pending | ||
| 44 | return nil | ||
| 45 | end | ||
| 46 | @pending_user ||= User.find_by(:id => session[:pending_otp_user_id]) | ||
| 47 | end | ||
| 48 | |||
| 49 | def clear_pending | ||
| 50 | session.delete(:pending_otp_user_id) | ||
| 51 | session.delete(:otp_deadline) | ||
| 52 | session.delete(:otp_attempts) | ||
| 53 | end | ||
| 54 | end | ||
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 64bf951a..f0d5cf9b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb | |||
| @@ -20,9 +20,23 @@ class SessionsController < ApplicationController | |||
| 20 | # button. Uncomment if you understand the tradeoffs. | 20 | # button. Uncomment if you understand the tradeoffs. |
| 21 | reset_session | 21 | reset_session |
| 22 | 22 | ||
| 23 | self.current_user = user | 23 | if user.otp_enrolled? |
| 24 | redirect_to safe_return_to(return_to, :default => admin_path) | 24 | # Half-completed login: no user_id yet, only the pending marker. |
| 25 | flash[:notice] = "Logged in successfully" | 25 | session[:pending_otp_user_id] = user.id |
| 26 | session[:otp_deadline] = 2.minutes.from_now.to_i | ||
| 27 | session[:otp_attempts] = 0 | ||
| 28 | session[:return_to] = return_to | ||
| 29 | redirect_to new_otp_challenge_path | ||
| 30 | else | ||
| 31 | self.current_user = user | ||
| 32 | if user.otp_required? | ||
| 33 | flash[:error] = "Your account requires a second factor -- set it up now." | ||
| 34 | redirect_to edit_user_path(user) | ||
| 35 | else | ||
| 36 | flash[:notice] = "Logged in successfully" | ||
| 37 | redirect_to safe_return_to(return_to, :default => admin_path) | ||
| 38 | end | ||
| 39 | end | ||
| 26 | else | 40 | else |
| 27 | note_failed_signin | 41 | note_failed_signin |
| 28 | @login = params[:login] | 42 | @login = params[:login] |
diff --git a/app/views/otp_challenges/new.html.erb b/app/views/otp_challenges/new.html.erb new file mode 100644 index 00000000..a9c7a15c --- /dev/null +++ b/app/views/otp_challenges/new.html.erb | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | <h1>Second factor</h1> | ||
| 2 | |||
| 3 | <div id="page_editor"> | ||
| 4 | <div class="node_description">Code</div> | ||
| 5 | <div class="node_content"> | ||
| 6 | <%= form_tag otp_challenge_path, :method => :post do %> | ||
| 7 | <%= text_field_tag :code, nil, :autofocus => true, | ||
| 8 | :autocomplete => "one-time-code", :inputmode => "numeric" %> | ||
| 9 | <%= submit_tag "Log in" %> | ||
| 10 | <% end %> | ||
| 11 | <span class="field_hint">Enter the six-digit code from your authenticator app.</span> | ||
| 12 | </div> | ||
| 13 | </div> | ||
diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index cba4a59b..a177851f 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb | |||
| @@ -38,4 +38,11 @@ class AdminControllerTest < ActionController::TestCase | |||
| 38 | assert_equal [], json["tags"] | 38 | assert_equal [], json["tags"] |
| 39 | assert_equal [], json["nodes"] | 39 | assert_equal [], json["nodes"] |
| 40 | end | 40 | end |
| 41 | |||
| 42 | test "otp_required users without enrollment are funneled to setup" do | ||
| 43 | users(:quentin).update!(:otp_required => true) | ||
| 44 | login_as :quentin | ||
| 45 | get :index | ||
| 46 | assert_redirected_to edit_user_path(users(:quentin)) | ||
| 47 | end | ||
| 41 | end | 48 | end |
diff --git a/test/controllers/otp_challenges_controller_test.rb b/test/controllers/otp_challenges_controller_test.rb new file mode 100644 index 00000000..470e84a2 --- /dev/null +++ b/test/controllers/otp_challenges_controller_test.rb | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | require "test_helper" | ||
| 2 | |||
| 3 | class OtpChallengesControllerTest < ActionController::TestCase | ||
| 4 | fixtures :users | ||
| 5 | |||
| 6 | def setup | ||
| 7 | @user = users(:quentin) | ||
| 8 | @user.update!(:otp_secret => ROTP::Base32.random) | ||
| 9 | @request.session[:pending_otp_user_id] = @user.id | ||
| 10 | @request.session[:otp_deadline] = 2.minutes.from_now.to_i | ||
| 11 | @request.session[:otp_attempts] = 0 | ||
| 12 | end | ||
| 13 | |||
| 14 | test "a valid code completes the login" do | ||
| 15 | post :create, params: { :code => ROTP::TOTP.new(@user.otp_secret).now } | ||
| 16 | assert_equal @user.id, session[:user_id] | ||
| 17 | assert_nil session[:pending_otp_user_id] | ||
| 18 | end | ||
| 19 | |||
| 20 | test "a wrong code does not log in" do | ||
| 21 | post :create, params: { :code => "000000" } | ||
| 22 | assert_nil session[:user_id] | ||
| 23 | assert_response :success | ||
| 24 | end | ||
| 25 | |||
| 26 | test "the pending window expires" do | ||
| 27 | @request.session[:otp_deadline] = 1.minute.ago.to_i | ||
| 28 | post :create, params: { :code => ROTP::TOTP.new(@user.otp_secret).now } | ||
| 29 | assert_nil session[:user_id] | ||
| 30 | assert_redirected_to login_path | ||
| 31 | end | ||
| 32 | |||
| 33 | test "attempts are limited" do | ||
| 34 | 5.times { post :create, params: { :code => "000000" } } | ||
| 35 | post :create, params: { :code => ROTP::TOTP.new(@user.otp_secret).now } | ||
| 36 | assert_nil session[:user_id] | ||
| 37 | assert_redirected_to login_path | ||
| 38 | end | ||
| 39 | end | ||
diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index a5f511f5..62acd28a 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb | |||
| @@ -23,4 +23,19 @@ class SessionsControllerTest < ActionController::TestCase | |||
| 23 | assert_nil session[:user_id] | 23 | assert_nil session[:user_id] |
| 24 | assert_response :redirect | 24 | assert_response :redirect |
| 25 | end | 25 | end |
| 26 | |||
| 27 | test "login with password only is withheld for enrolled users" do | ||
| 28 | users(:quentin).update!(:otp_secret => ROTP::Base32.random) | ||
| 29 | post :create, params: { login: 'quentin', password: 'monkey' } | ||
| 30 | assert_nil session[:user_id] | ||
| 31 | assert_equal users(:quentin).id, session[:pending_otp_user_id] | ||
| 32 | assert_redirected_to new_otp_challenge_path | ||
| 33 | end | ||
| 34 | |||
| 35 | test "otp_required without enrollment logs in but funnels into setup" do | ||
| 36 | users(:quentin).update!(:otp_required => true) | ||
| 37 | post :create, params: { login: 'quentin', password: 'monkey' } | ||
| 38 | assert session[:user_id] | ||
| 39 | assert_redirected_to edit_user_path(users(:quentin)) | ||
| 40 | end | ||
| 26 | end | 41 | end |
