Block repeated invite code tries by customer id

Stephen Paul Weber created

So it's not as trivial to brute-force the space and find an open one. Limit is
10 tries per hour.

Change summary

lib/registration.rb       | 21 ++++++++++-
test/test_registration.rb | 76 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 94 insertions(+), 3 deletions(-)

Detailed changes

lib/registration.rb 🔗

@@ -313,16 +313,33 @@ class Registration
 
 			def write
 				COMMAND_MANAGER.write(@reply).then do |iq|
-					verify(iq.form.field("code")&.value&.to_s).then {
+					guard_too_many_tries.then {
+						verify(iq.form.field("code")&.value&.to_s)
+					}.then {
 						Finish.new(iq, @customer, @tel)
 					}.catch_only(Invalid) { |e|
-						InviteCode.new(iq, @customer, @tel, error: e.message)
+						invalid_code(iq, e)
 					}.then(&:write)
 				end
 			end
 
 		protected
 
+			def guard_too_many_tries
+				REDIS.get("jmp_invite_tries-#{@customer.customer_id}").then do |t|
+					raise Invalid, "Too many wrong attempts" if t > 10
+				end
+			end
+
+			def invalid_code(iq, e)
+				EMPromise.all([
+					REDIS.incr("jmp_invite_tries-#{@customer.customer_id}").then do
+						REDIS.expire("jmp_invite_tries-#{@customer.customer_id}", 60 * 60)
+					end,
+					InviteCode.new(iq, @customer, @tel, error: e.message)
+				]).then(&:last)
+			end
+
 			def customer_id
 				@customer.customer_id
 			end

test/test_registration.rb 🔗

@@ -342,6 +342,8 @@ class RegistrationTest < Minitest::Test
 		class InviteCodeTest < Minitest::Test
 			Registration::Payment::InviteCode::DB =
 				Minitest::Mock.new
+			Registration::Payment::InviteCode::REDIS =
+				Minitest::Mock.new
 			Registration::Payment::InviteCode::COMMAND_MANAGER =
 				Minitest::Mock.new
 			Registration::Payment::InviteCode::Finish =
@@ -349,6 +351,11 @@ class RegistrationTest < Minitest::Test
 
 			def test_write
 				customer = Customer.new("test", plan_name: "test_usd")
+				Registration::Payment::InviteCode::REDIS.expect(
+					:get,
+					EMPromise.resolve(0),
+					["jmp_invite_tries-test"]
+				)
 				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
 					:write,
 					EMPromise.resolve(
@@ -380,12 +387,18 @@ class RegistrationTest < Minitest::Test
 				).write.sync
 				Registration::Payment::InviteCode::COMMAND_MANAGER.verify
 				Registration::Payment::InviteCode::DB.verify
+				Registration::Payment::InviteCode::REDIS.verify
 				Registration::Payment::InviteCode::Finish.verify
 			end
 			em :test_write
 
 			def test_write_bad_code
 				customer = Customer.new("test", plan_name: "test_usd")
+				Registration::Payment::InviteCode::REDIS.expect(
+					:get,
+					EMPromise.resolve(0),
+					["jmp_invite_tries-test"]
+				)
 				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
 					:write,
 					EMPromise.resolve(
@@ -401,6 +414,16 @@ class RegistrationTest < Minitest::Test
 				Registration::Payment::InviteCode::DB.expect(:transaction, []) do
 					raise Registration::Payment::InviteCode::Invalid, "wut"
 				end
+				Registration::Payment::InviteCode::REDIS.expect(
+					:incr,
+					EMPromise.resolve(nil),
+					["jmp_invite_tries-test"]
+				)
+				Registration::Payment::InviteCode::REDIS.expect(
+					:expire,
+					EMPromise.resolve(nil),
+					["jmp_invite_tries-test", 60 * 60]
+				)
 				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
 					:write,
 					EMPromise.reject(Promise::Error.new),
@@ -420,9 +443,60 @@ class RegistrationTest < Minitest::Test
 				end
 				Registration::Payment::InviteCode::COMMAND_MANAGER.verify
 				Registration::Payment::InviteCode::DB.verify
-				Registration::Payment::InviteCode::Finish.verify
+				Registration::Payment::InviteCode::REDIS.verify
 			end
 			em :test_write_bad_code
+
+			def test_write_bad_code_over_limit
+				customer = Customer.new("test", plan_name: "test_usd")
+				Registration::Payment::InviteCode::REDIS.expect(
+					:get,
+					EMPromise.resolve(11),
+					["jmp_invite_tries-test"]
+				)
+				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.resolve(
+						Blather::Stanza::Iq::Command.new.tap { |iq|
+							iq.form.fields = [{ var: "code", value: "abc" }]
+						}
+					),
+					[Matching.new do |reply|
+						assert_equal :form, reply.form.type
+						assert_nil reply.form.instructions
+					end]
+				)
+				Registration::Payment::InviteCode::REDIS.expect(
+					:incr,
+					EMPromise.resolve(nil),
+					["jmp_invite_tries-test"]
+				)
+				Registration::Payment::InviteCode::REDIS.expect(
+					:expire,
+					EMPromise.resolve(nil),
+					["jmp_invite_tries-test", 60 * 60]
+				)
+				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.reject(Promise::Error.new),
+					[Matching.new do |reply|
+						assert_equal :form, reply.form.type
+						assert_equal "Too many wrong attempts", reply.form.instructions
+					end]
+				)
+				iq = Blather::Stanza::Iq::Command.new
+				iq.from = "test@example.com"
+				assert_raises Promise::Error do
+					Registration::Payment::InviteCode.new(
+						iq,
+						customer,
+						"+15555550000"
+					).write.sync
+				end
+				Registration::Payment::InviteCode::COMMAND_MANAGER.verify
+				Registration::Payment::InviteCode::REDIS.verify
+			end
+			em :test_write_bad_code_over_limit
 		end
 	end