From 02e1aefa24cdd0339995d14431713822f4bf4718 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 24 Jul 2026 13:11:51 +0200 Subject: Add TOTP enrollment and verification to User, witnessed in the action log --- test/models/user_otp_test.rb | 83 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 test/models/user_otp_test.rb (limited to 'test') diff --git a/test/models/user_otp_test.rb b/test/models/user_otp_test.rb new file mode 100644 index 00000000..81f25575 --- /dev/null +++ b/test/models/user_otp_test.rb @@ -0,0 +1,83 @@ +require "test_helper" + +class UserOtpTest < ActiveSupport::TestCase + fixtures :users + + def setup + @user = users(:quentin) + end + + test "begin_otp_enrollment! stores a pending secret and yields a provisioning URI" do + uri = @user.begin_otp_enrollment! + assert @user.otp_pending_secret.present? + assert_not @user.otp_enrolled? + assert_match %r{\Aotpauth://totp/}, uri + assert_includes uri, "issuer=" + end + + test "confirm_otp_enrollment! promotes the pending secret and witnesses it" do + @user.begin_otp_enrollment! + code = ROTP::TOTP.new(@user.otp_pending_secret).now + + assert @user.confirm_otp_enrollment!(code) + assert @user.otp_enrolled? + assert_nil @user.otp_pending_secret + + action = NodeAction.where(:action => "otp_enroll").last + assert_equal @user, action.user + assert_equal [["User", @user.id]], + action.action_participants.map { |p| [p.subject_type, p.subject_id] } + end + + test "confirm_otp_enrollment! rejects a wrong code and stays unenrolled" do + @user.begin_otp_enrollment! + assert_not @user.confirm_otp_enrollment!("000000") + assert_not @user.otp_enrolled? + assert @user.otp_pending_secret.present? + end + + test "verify_otp! accepts a current code exactly once" do + @user.update!(:otp_secret => ROTP::Base32.random) + code = ROTP::TOTP.new(@user.otp_secret).now + + assert @user.verify_otp!(code) + assert_not @user.verify_otp!(code), "replayed code must be rejected" + end + + test "the confirmation code cannot be replayed at login" do + @user.begin_otp_enrollment! + code = ROTP::TOTP.new(@user.otp_pending_secret).now + @user.confirm_otp_enrollment!(code) + assert_not @user.verify_otp!(code) + end + + test "verify_otp! rejects wrong codes and unenrolled users" do + assert_not @user.verify_otp!("123456") + enroll!(@user) + assert_not @user.verify_otp!("000000") + end + + test "disable_otp! by the user themselves is witnessed as otp_disable" do + enroll!(@user) + assert @user.disable_otp!(:actor => @user) + assert_not @user.otp_enrolled? + assert_equal "otp_disable", NodeAction.last.action + end + + test "an admin clearing another user's factor is witnessed as otp_reset" do + enroll!(@user) + admin = users(:aaron) + assert @user.disable_otp!(:actor => admin) + + action = NodeAction.where(:action => "otp_reset").last + assert_equal admin, action.user + assert_equal @user.login, action.metadata["target_login"] + end + + private + + def enroll!(user) + user.begin_otp_enrollment! + user.confirm_otp_enrollment!(ROTP::TOTP.new(user.otp_pending_secret).now) + end +end -- cgit v1.3