summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-24 13:53:13 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-24 13:53:13 +0200
commitdcb576618b868b888a5b1b31e35491f300ce4050 (patch)
tree3a970eae416fba2939cd366b1ba2b5d294154f2c
parentfefec929c59c72dc93e4be30e8f23cd8c5258b0a (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.rb13
-rw-r--r--app/controllers/otp_challenges_controller.rb54
-rw-r--r--app/controllers/sessions_controller.rb20
-rw-r--r--app/views/otp_challenges/new.html.erb13
-rw-r--r--test/controllers/admin_controller_test.rb7
-rw-r--r--test/controllers/otp_challenges_controller_test.rb39
-rw-r--r--test/controllers/sessions_controller_test.rb15
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
33end 46end
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.
5class 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
54end
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
41end 48end
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 @@
1require "test_helper"
2
3class 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
39end
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
26end 41end