diff --git a/lib/bitsmithy/auth/config.rb b/lib/bitsmithy/auth/config.rb index 4ba940c..28edd7a 100644 --- a/lib/bitsmithy/auth/config.rb +++ b/lib/bitsmithy/auth/config.rb @@ -2,6 +2,7 @@ require_relative "errors" require_relative "stores/memory_store" +require_relative "stores/rails_cache_store" module Bitsmithy module Auth @@ -10,6 +11,8 @@ class Config JWT_ALGORITHM = "HS256" DEFAULT_SESSION_DURATION = 86_400 # 24h per ADR-0001 DEFAULT_RATE_LIMIT = { per_phone: 5, window: 3_600 }.freeze + CACHE_MISSING_WARNING = "[bitsmithy-auth] Rails.cache is nil — configure config.cache_store " \ + "for cross-worker rate limiting. Falling back to MemoryStore." attr_accessor :signing_key, :otp_adapter, :session_duration, :rate_limit, :twilio_account_sid, :twilio_auth_token, :twilio_verify_service_sid, @@ -26,7 +29,16 @@ def initialize end def rate_limit_store - @rate_limit_store ||= Stores::MemoryStore.new + @rate_limit_store ||= if defined?(Rails) && Rails.respond_to?(:cache) + if Rails.cache + Stores::RailsCacheStore.new(Rails.cache) + else + warn CACHE_MISSING_WARNING + Stores::MemoryStore.new + end + else + Stores::MemoryStore.new + end end def validate! diff --git a/lib/bitsmithy/auth/stores/rails_cache_store.rb b/lib/bitsmithy/auth/stores/rails_cache_store.rb new file mode 100644 index 0000000..9136c4c --- /dev/null +++ b/lib/bitsmithy/auth/stores/rails_cache_store.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + module Stores + # Wraps an ActiveSupport::Cache::Store (typically Rails.cache) so the + # rate limiter works across worker processes when a distributed cache + # (Redis, Memcached) is configured. + # + # Falls back gracefully: if Rails.cache is nil the caller should use + # MemoryStore instead — see Config#rate_limit_store. + class RailsCacheStore + def initialize(cache) + @cache = cache + end + + # Increment the counter for +key+ and return the new value. + # Expires the entry after +window_seconds+. + def increment(key, window_seconds) + @cache.increment(key, 1, expires_in: window_seconds) + end + end + end + end +end diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb index e66d51d..0602335 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -13,6 +13,8 @@ class Application < Rails::Application config.hosts.clear config.action_controller.allow_forgery_protection = false + config.cache_store = :memory_store + # Point Rails.root at the dummy app so config/routes.rb is found config.root = File.expand_path("..", __dir__) end diff --git a/test/engine/test_rails_cache_store.rb b/test/engine/test_rails_cache_store.rb new file mode 100644 index 0000000..150a887 --- /dev/null +++ b/test/engine/test_rails_cache_store.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "../engine_helper" + +class RailsCacheStoreTest < ActiveSupport::TestCase + setup do + @store = Bitsmithy::Auth::Stores::RailsCacheStore.new(Rails.cache) + end + + test "increment returns 1 on first call" do + assert_equal 1, @store.increment("counter:a", 60) + end + + test "increment returns cumulative count on subsequent calls" do + @store.increment("counter:b", 60) + @store.increment("counter:b", 60) + + assert_equal 3, @store.increment("counter:b", 60) + end + + test "different keys are independent" do + @store.increment("counter:c", 60) + @store.increment("counter:c", 60) + + assert_equal 1, @store.increment("counter:d", 60) + end +end diff --git a/test/engine_helper.rb b/test/engine_helper.rb index bb14562..df2c230 100644 --- a/test/engine_helper.rb +++ b/test/engine_helper.rb @@ -3,3 +3,9 @@ require_relative "dummy/config/environment" require "rails/test_help" require "mocha/minitest" + +module ActiveSupport + class TestCase + setup { Rails.cache.clear } + end +end