Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion lib/bitsmithy/auth/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require_relative "errors"
require_relative "stores/memory_store"
require_relative "stores/rails_cache_store"

module Bitsmithy
module Auth
Expand All @@ -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,
Expand All @@ -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!
Expand Down
25 changes: 25 additions & 0 deletions lib/bitsmithy/auth/stores/rails_cache_store.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions test/dummy/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions test/engine/test_rails_cache_store.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions test/engine_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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