beskar 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +177 -19
  3. data/README.md +142 -122
  4. data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
  5. data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
  6. data/app/controllers/beskar/application_controller.rb +69 -25
  7. data/app/controllers/beskar/banned_ips_controller.rb +116 -141
  8. data/app/controllers/beskar/dashboard_controller.rb +20 -28
  9. data/app/controllers/beskar/security_events_controller.rb +37 -55
  10. data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
  11. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
  12. data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
  13. data/app/jobs/beskar/notification_job.rb +33 -0
  14. data/app/mailers/beskar/security_mailer.rb +59 -0
  15. data/app/models/beskar/administrative_action.rb +41 -0
  16. data/app/models/beskar/banned_ip.rb +91 -132
  17. data/app/models/beskar/security_event.rb +37 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +16 -6
  20. data/app/views/beskar/administrative_actions/index.html.erb +33 -0
  21. data/app/views/beskar/administrative_actions/show.html.erb +21 -0
  22. data/app/views/beskar/banned_ips/edit.html.erb +25 -89
  23. data/app/views/beskar/banned_ips/index.html.erb +20 -62
  24. data/app/views/beskar/banned_ips/new.html.erb +18 -138
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +18 -15
  27. data/app/views/beskar/dashboard/index.html.erb +6 -6
  28. data/app/views/beskar/security_events/index.html.erb +18 -16
  29. data/app/views/beskar/security_events/show.html.erb +10 -20
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/beskar/shared/_waf_analysis.html.erb +25 -0
  32. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  33. data/app/views/layouts/beskar/application.html.erb +9 -76
  34. data/config/routes.rb +10 -21
  35. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  36. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  37. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  38. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  39. data/docs/README.md +73 -0
  40. data/docs/archive/project-documentation.md +659 -0
  41. data/docs/audits/project-review.md +437 -0
  42. data/docs/audits/repair-status.md +216 -0
  43. data/docs/guides/audit-and-waf.md +188 -0
  44. data/docs/guides/audit-lifecycle.md +172 -0
  45. data/docs/guides/authentication.md +213 -0
  46. data/docs/guides/configuration.md +182 -0
  47. data/docs/guides/dashboard-and-search.md +251 -0
  48. data/docs/guides/notifications-and-recovery.md +157 -0
  49. data/docs/guides/risk-scoring.md +116 -0
  50. data/docs/operations/monitor-only-mode.md +85 -0
  51. data/docs/operations/security-hardening.md +167 -0
  52. data/docs/operations/state-storage.md +144 -0
  53. data/docs/research/rust-performance-assessment.md +69 -0
  54. data/lib/beskar/configuration.rb +84 -13
  55. data/lib/beskar/configuration_validator.rb +188 -0
  56. data/lib/beskar/devise_authentication.rb +24 -0
  57. data/lib/beskar/engine.rb +21 -88
  58. data/lib/beskar/logger.rb +30 -35
  59. data/lib/beskar/middleware/request_analyzer.rb +49 -82
  60. data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
  61. data/lib/beskar/models/security_trackable_devise.rb +32 -23
  62. data/lib/beskar/models/security_trackable_generic.rb +169 -212
  63. data/lib/beskar/risk_level.rb +22 -0
  64. data/lib/beskar/services/account_locker.rb +85 -76
  65. data/lib/beskar/services/administrative_audit.rb +36 -0
  66. data/lib/beskar/services/administrative_bans.rb +104 -0
  67. data/lib/beskar/services/audit_data.rb +72 -0
  68. data/lib/beskar/services/authentication.rb +31 -0
  69. data/lib/beskar/services/authentication_attempt.rb +148 -0
  70. data/lib/beskar/services/ban_expiry.rb +28 -0
  71. data/lib/beskar/services/device_detector.rb +32 -41
  72. data/lib/beskar/services/event_search.rb +58 -0
  73. data/lib/beskar/services/geolocation_service.rb +83 -114
  74. data/lib/beskar/services/ip_whitelist.rb +31 -40
  75. data/lib/beskar/services/location_assessment.rb +109 -0
  76. data/lib/beskar/services/native_account_lock.rb +82 -0
  77. data/lib/beskar/services/notifications.rb +46 -0
  78. data/lib/beskar/services/rate_limiter.rb +103 -125
  79. data/lib/beskar/services/request_context.rb +69 -0
  80. data/lib/beskar/services/risk_assessment.rb +58 -0
  81. data/lib/beskar/services/session_revocation.rb +62 -0
  82. data/lib/beskar/services/waf.rb +166 -280
  83. data/lib/beskar/services/waf_request.rb +70 -0
  84. data/lib/beskar/version.rb +1 -1
  85. data/lib/beskar/warden_authentication.rb +53 -0
  86. data/lib/beskar.rb +53 -4
  87. data/lib/generators/beskar/install/install_generator.rb +36 -36
  88. data/lib/generators/beskar/install/templates/initializer.rb.tt +105 -20
  89. data/lib/tasks/beskar_tasks.rake +15 -19
  90. metadata +61 -8
@@ -0,0 +1,109 @@
1
+ require "time"
2
+
3
+ module Beskar
4
+ module Services
5
+ # Observations pair a location with its own occurred_at and event_id. Never
6
+ # apply one timestamp to a collection of unrelated historical locations.
7
+ class LocationAssessment
8
+ MAX_SPEED_KMH = 1000
9
+
10
+ def self.normalize(location)
11
+ location = location.is_a?(Hash) ? location.deep_symbolize_keys : {}
12
+ lat, lon = number(location[:latitude]), number(location[:longitude])
13
+ if lat && lon && lat.between?(-90, 90) && lon.between?(-180, 180)
14
+ location.merge(latitude: lat, longitude: lon)
15
+ else
16
+ location.merge(latitude: nil, longitude: nil)
17
+ end
18
+ end
19
+
20
+ def self.number(value)
21
+ number = Float(value, exception: false)
22
+ number if number&.finite?
23
+ end
24
+
25
+ def self.timestamp(value)
26
+ value = Time.iso8601(value) if value.is_a?(String)
27
+ value = value.to_time.to_f if value.respond_to?(:to_time)
28
+ number(value)
29
+ rescue ArgumentError
30
+ nil
31
+ end
32
+
33
+ def self.coordinates(location)
34
+ location = normalize(location)
35
+ return if location[:private_ip] == true || %w[Private Unknown].include?(location[:country])
36
+ return if location[:provider].to_s == "mock"
37
+ lat, lon = number(location[:latitude]), number(location[:longitude])
38
+ [lat, lon] if lat && lon && lat.between?(-90, 90) && lon.between?(-180, 180)
39
+ end
40
+
41
+ def initialize(location, observations: [], at: Time.current)
42
+ @location = self.class.normalize(location)
43
+ @at = self.class.timestamp(at)
44
+ @observations = observations.filter_map do |observation|
45
+ next unless observation.is_a?(Hash)
46
+ observation = observation.symbolize_keys
47
+ time = self.class.timestamp(observation[:occurred_at])
48
+ next unless @at && time && time < @at
49
+ {location: self.class.normalize(observation[:location]), occurred_at: time, event_id: observation[:event_id]}
50
+ end.sort_by { |observation| [observation[:occurred_at], observation[:event_id].to_i] }.reverse
51
+ end
52
+
53
+ def call
54
+ factors = []
55
+ location = @location.merge(impossible_travel: false, country_change: false)
56
+ status = if @location[:private_ip] == true || @location[:country] == "Private"
57
+ "private"
58
+ elsif @location[:provider].to_s == "mock"
59
+ "synthetic"
60
+ elsif self.class.coordinates(@location) || known_country?(@location)
61
+ "available"
62
+ else
63
+ "unknown"
64
+ end
65
+ if %w[private unknown].include?(status)
66
+ factors << {name: "location_unavailable", points: 10, evidence: {status: status}}
67
+ elsif status == "available"
68
+ previous = @observations.find { |entry| self.class.coordinates(entry[:location]) }
69
+ if previous && (current_coordinates = self.class.coordinates(@location))
70
+ elapsed = @at - previous[:occurred_at]
71
+ distance = GeolocationService.calculate_distance(*current_coordinates, *self.class.coordinates(previous[:location]))
72
+ evidence = {previous_event_id: previous[:event_id], previous_at: Time.at(previous[:occurred_at]).utc.iso8601(6),
73
+ elapsed_seconds: elapsed, distance_km: distance.round(3), max_speed_kmh: MAX_SPEED_KMH}
74
+ location[:travel] = evidence
75
+ if distance > MAX_SPEED_KMH * elapsed / 3600.0
76
+ location[:impossible_travel] = true
77
+ factors << {name: "impossible_travel", points: 25, evidence: evidence}
78
+ end
79
+ end
80
+ previous_country = @observations.find { |entry| known_country?(entry[:location]) }
81
+ if known_country?(@location) && previous_country && country_changed?(previous_country[:location])
82
+ location[:country_change] = true
83
+ factors << {name: "country_change", points: 10,
84
+ evidence: {previous_event_id: previous_country[:event_id], previous_country: previous_country[:location][:country],
85
+ current_country: @location[:country]}}
86
+ end
87
+ end
88
+ total = factors.sum { |factor| factor[:points] }
89
+ factors << {name: "geolocation_cap", points: 30 - total, evidence: {cap: 30}} if total > 30
90
+ {location: location.merge(assessment_status: status), score: [total, 30].min, factors: factors}
91
+ end
92
+
93
+ private
94
+
95
+ def known_country?(location)
96
+ location[:provider].to_s != "mock" && location[:private_ip] != true &&
97
+ location[:country].present? && !%w[Unknown Private].include?(location[:country])
98
+ end
99
+
100
+ def country_changed?(previous)
101
+ if previous[:country_code].present? && @location[:country_code].present?
102
+ previous[:country_code].to_s.upcase != @location[:country_code].to_s.upcase
103
+ else
104
+ previous[:country].to_s.downcase != @location[:country].to_s.downcase
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,82 @@
1
+ module Beskar
2
+ module Services
3
+ # Persistent Rails-native locks and session creation serialize on one row.
4
+ # unlock_at lives in data: merely checking/creating a session must never
5
+ # prolong a finite lock, and manual locks must not expire through cleanup.
6
+ class NativeAccountLock
7
+ def self.key(user)
8
+ raise ArgumentError, "Account must be persisted" unless user.persisted?
9
+ "account_lock:#{user.class.base_class.name}:#{user.id}"
10
+ end
11
+
12
+ def self.locked_data?(data)
13
+ data["locked"] && (!data["unlock_at"] || data["unlock_at"] > Time.current.to_f)
14
+ end
15
+
16
+ def self.locked?(user)
17
+ SecurityState.uncached { !!locked_data?(SecurityState.read(key(user))) }
18
+ end
19
+
20
+ def self.lock!(user, duration:, metadata: {})
21
+ return false unless RequestContext.enforce?(metadata[:ip_address] || metadata["ip_address"])
22
+ raise ArgumentError, "Unlock duration must be positive or nil" if duration && !duration.to_f.positive?
23
+ cleanup_error = nil
24
+ result = SecurityState.mutate(key(user), ttl: nil) do |state|
25
+ cleanup_error = nil
26
+ data = state.fetch(key(user))
27
+ next false if locked_data?(data)
28
+ data.replace("locked" => true, "locked_at" => Time.current.iso8601,
29
+ "unlock_at" => duration && Time.current.to_f + duration.to_f, "context" => AuditData.metadata(metadata))
30
+ SessionRevocation.rotate!(data)
31
+ # Every database session, including the current one, is revoked.
32
+ begin
33
+ user.class.transaction(requires_new: true) { revoke_sessions!(user) }
34
+ rescue => error
35
+ # A host callback must not prevent the authoritative lock. Session
36
+ # readers deny access until cleanup succeeds during explicit unlock;
37
+ # automatic expiry must never reactivate unrevoked old sessions.
38
+ cleanup_error = error
39
+ data.merge!("session_cleanup_pending" => true, "unlock_at" => nil)
40
+ end
41
+ true
42
+ end
43
+ Beskar::Logger.error("Locked account; session cleanup failed (#{cleanup_error.class})") if cleanup_error
44
+ result
45
+ end
46
+
47
+ def self.require_manual_unlock!(user)
48
+ SecurityState.mutate(key(user), ttl: nil) do |state|
49
+ state.fetch(key(user)).merge!("locked" => true, "unlock_at" => nil, "locked_at" => Time.current.iso8601)
50
+ end
51
+ end
52
+
53
+ def self.unlock!(user)
54
+ SecurityState.mutate(key(user), ttl: nil) do |state|
55
+ data = state.fetch(key(user))
56
+ revoke_sessions!(user) if data["session_cleanup_pending"]
57
+ data.replace(data.slice("generation"))
58
+ end
59
+ true
60
+ end
61
+
62
+ def self.revoke_sessions!(user)
63
+ sessions = user.sessions
64
+ # Bypass both a loaded association and MySQL's earlier transaction
65
+ # snapshot. Keep association removal and model destruction callbacks.
66
+ sessions.destroy(sessions.lock.to_a)
67
+ sessions.reset
68
+ # A host callback can abort removal. Verify against a current read too.
69
+ raise ActiveRecord::RecordNotDestroyed, "Account sessions could not be revoked" if sessions.lock.exists?
70
+ end
71
+
72
+ def self.with_session(user, request, generation: nil)
73
+ SecurityState.mutate(key(user), ttl: nil) do |state|
74
+ next false if generation && generation != state.fetch(key(user)).fetch("generation", "0")
75
+ next false if RequestContext.enforce?(RequestContext.ip(request)) && locked_data?(state.fetch(key(user)))
76
+ yield
77
+ true
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,46 @@
1
+ module Beskar
2
+ module Services
3
+ # Optional delivery, separate from the synchronous security decision. No
4
+ # password, token, email address, request metadata, or model is serialized.
5
+ class Notifications
6
+ class DeliveryError < StandardError; end
7
+
8
+ def self.enabled?(kind)
9
+ case kind
10
+ when "account_locked" then Beskar.configuration.notify_user_on_lock?
11
+ when "emergency_password_reset" then Beskar.configuration.emergency_password_reset[:send_notification] == true
12
+ when "security_team_reset" then Beskar.configuration.emergency_password_reset[:notify_security_team] == true
13
+ else raise Configuration::Error, "Unsupported notification kind"
14
+ end
15
+ end
16
+
17
+ def self.enqueue(user, kind)
18
+ return unless enabled?(kind)
19
+ raise ArgumentError, "Notification account must be persisted" unless user.persisted?
20
+ arguments = {user_type: user.class.base_class.name, user_id: user.id, kind: kind}
21
+ after_commit do
22
+ ConfigurationValidator.new(Beskar.configuration).validate_notifications!
23
+ # Indices keep addresses out of queue arguments. A separate job per
24
+ # recipient prevents one failed address from retrying all recipients.
25
+ indices = (kind == "security_team_reset") ? Beskar.configuration.notifications[:security_team_recipients].each_index.to_a : [nil]
26
+ indices.each do |index|
27
+ job = Beskar::NotificationJob.perform_later(**arguments, recipient_index: index)
28
+ Beskar::Logger.warn("Security notification was not enqueued") unless job
29
+ rescue => error
30
+ Beskar::Logger.warn("Security notification enqueue failed (#{error.class})")
31
+ end
32
+ end
33
+ rescue => error
34
+ Beskar::Logger.warn("Security notification preparation failed (#{error.class})")
35
+ end
36
+
37
+ def self.after_commit(&block)
38
+ ActiveRecord.after_all_transactions_commit do
39
+ block.call
40
+ rescue => error
41
+ Beskar::Logger.warn("Security notification dispatch failed (#{error.class})")
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -13,175 +13,150 @@ module Beskar
13
13
  exponential_backoff: true
14
14
  },
15
15
  global_attempts: {
16
+ enabled: false,
16
17
  limit: 100,
17
18
  period: 1.minute
18
19
  }
19
20
  }.freeze
20
21
 
21
- class << self
22
- def check_authentication_attempt(request, result, user = nil)
23
- ip_address = request.ip
24
-
25
- # Check IP-based rate limiting
26
- ip_result = check_ip_rate_limit(ip_address)
27
-
28
- # Check account-based rate limiting if we have a user
29
- account_result = user ? check_account_rate_limit(user) : {allowed: true}
30
-
31
- # Check global rate limiting to prevent system overload
32
- global_result = check_global_rate_limit
22
+ BACKOFF_DELAYS = [60, 300, 900, 3600, 14400, 86400].freeze
33
23
 
34
- # Record the attempt
35
- record_attempt(ip_address, result, user)
24
+ class << self
25
+ # Atomically reserve capacity across every applicable tier. :check is a
26
+ # read-only preview: it never counts an attempt or escalates backoff.
27
+ def check_authentication_attempt(request, result, user = nil, account_key: nil)
28
+ keys = keys_for(RequestContext.ip(request), user, account_key: account_key)
29
+ return preview(keys) if result == :check
36
30
 
37
- # Return most restrictive result
38
- most_restrictive_result([ip_result, account_result, global_result])
31
+ reserve(keys)
39
32
  end
40
33
 
41
34
  def check_ip_rate_limit(ip_address)
42
- config = Beskar.configuration.rate_limiting&.dig(:ip_attempts) || DEFAULT_CONFIG[:ip_attempts]
43
- cache_key = "beskar:ip_attempts:#{ip_address}"
44
- check_rate_limit(cache_key, config)
35
+ preview(keys_for(ip_address).slice(:ip_attempts))
45
36
  end
46
37
 
47
38
  def check_account_rate_limit(user)
48
- config = Beskar.configuration.rate_limiting&.dig(:account_attempts) || DEFAULT_CONFIG[:account_attempts]
49
- cache_key = "beskar:account_attempts:#{user.class.name}:#{user.id}"
50
- check_rate_limit(cache_key, config)
39
+ preview(keys_for(nil, user).slice(:account_attempts))
40
+ end
41
+
42
+ def reserve_account(user, ip_address: nil, account_key: nil)
43
+ reserve(keys_for(ip_address, user, account_key: account_key).slice(:account_attempts))
51
44
  end
52
45
 
53
46
  def check_global_rate_limit
54
- config = Beskar.configuration.rate_limiting&.dig(:global_attempts) || DEFAULT_CONFIG[:global_attempts]
55
- cache_key = "beskar:global_attempts"
56
- check_rate_limit(cache_key, config)
47
+ preview(keys_for(nil).slice(:global_attempts))
57
48
  end
58
49
 
59
50
  def is_rate_limited?(request, user = nil)
60
- result = check_authentication_attempt(request, :check, user)
61
- !result[:allowed]
51
+ !check_authentication_attempt(request, :check, user)[:allowed]
62
52
  end
63
53
 
64
54
  def time_until_allowed(request, user = nil)
65
- result = check_authentication_attempt(request, :check, user)
66
- result[:retry_after] || 0
55
+ check_authentication_attempt(request, :check, user)[:retry_after] || 0
67
56
  end
68
57
 
69
- def reset_rate_limit(ip_address: nil, user: nil)
58
+ def reset_rate_limit(ip_address: nil, user: nil, global: false)
59
+ keys = keys_for(ip_address, user)
60
+ keys.delete(:global_attempts) unless global
70
61
  if ip_address
71
- Rails.cache.delete("beskar:ip_attempts:#{ip_address}")
72
- Rails.cache.delete("beskar:ip_backoff:#{ip_address}")
62
+ mode = Beskar.configuration.monitor_only? ? "observe" : "enforce"
63
+ keys[:denials] = "rate_denials:#{mode}:#{IPAddr.new(ip_address.to_s)}"
73
64
  end
65
+ return if keys.empty?
74
66
 
75
- if user
76
- cache_key = "beskar:account_attempts:#{user.class.name}:#{user.id}"
77
- Rails.cache.delete(cache_key)
78
- Rails.cache.delete("beskar:account_backoff:#{user.class.name}:#{user.id}")
79
- end
67
+ SecurityState.mutate(keys.values, ttl: 1.second) { |state| state.each_value(&:clear) }
80
68
  end
81
69
 
82
70
  private
83
71
 
84
- def check_rate_limit(cache_key, config)
85
- now = Time.current.to_i
86
- period = config[:period].to_i
87
- limit = config[:limit]
88
- window_start = now - period
89
-
90
- # Get current window data
91
- window_data = Rails.cache.read(cache_key) || {}
92
-
93
- # Clean old entries
94
- cleaned_data = window_data.select { |timestamp, _| timestamp.to_i > window_start }
72
+ def keys_for(ip_address, user = nil, account_key: nil)
73
+ keys = {}
74
+ mode = (Beskar.configuration.monitor_only? || IpWhitelist.whitelisted?(ip_address)) ? "observe" : "enforce"
75
+ keys[:ip_attempts] = "rate:#{mode}:ip:#{IPAddr.new(ip_address.to_s)}" if ip_address
76
+ keys[:account_attempts] = "rate:#{mode}:account:#{user.class.name}:#{user.id}" if user
77
+ keys[:account_attempts] ||= "rate:#{mode}:account:#{account_key}" if account_key
78
+ keys[:global_attempts] = "rate:#{mode}:global" if config_for(:global_attempts)[:enabled]
79
+ keys
80
+ end
95
81
 
96
- # Update cache with cleaned data if it changed
97
- if cleaned_data != window_data
98
- if cleaned_data.empty?
99
- Rails.cache.delete(cache_key)
100
- else
101
- Rails.cache.write(cache_key, cleaned_data, expires_in: period + 60)
102
- end
82
+ def config_for(tier)
83
+ config = DEFAULT_CONFIG.fetch(tier).merge(Beskar.configuration.rate_limiting&.dig(tier) || {})
84
+ unless config[:limit].is_a?(Integer) && config[:limit].positive? && config[:period].to_f.positive?
85
+ raise ArgumentError, "Rate limits require a positive integer limit and positive period (#{tier})"
103
86
  end
87
+ config
88
+ end
104
89
 
105
- current_count = cleaned_data.values.sum
106
-
107
- # Check if we're over the limit
108
- if current_count >= limit
109
- # Calculate retry after time with exponential backoff if configured
110
- retry_after = calculate_retry_after(cache_key, config, current_count, limit)
90
+ def preview(keys)
91
+ now = Time.current.to_f
92
+ most_restrictive_result(keys.map do |tier, key|
93
+ evaluate(SecurityState.read(key), config_for(tier), now).merge(tier: tier)
94
+ end)
95
+ end
111
96
 
112
- reset_time = if cleaned_data.empty?
113
- Time.at(now + period)
114
- else
115
- Time.at(cleaned_data.keys.map(&:to_i).min + period)
97
+ def reserve(keys)
98
+ # No applicable tier (for example an addressless request with global
99
+ # limiting disabled): nothing to count, so admission is a no-op.
100
+ return most_restrictive_result([]) if keys.empty?
101
+
102
+ configs = keys.to_h { |tier, _| [tier, config_for(tier)] }
103
+ ttl = [configs.values.map { |config| config[:period].to_f }.max, BACKOFF_DELAYS.last].max + 60
104
+ SecurityState.mutate(keys.values, ttl: ttl) do |state|
105
+ now = Time.current.to_f
106
+ results = keys.map do |tier, key|
107
+ config = configs.fetch(tier)
108
+ data = state.fetch(key)
109
+ data["attempts"] = recent_attempts(data, config, now)
110
+ result = evaluate(data, config, now)
111
+ if result[:allowed]
112
+ data.delete("denials")
113
+ data.delete("blocked_until")
114
+ # Bounded to the configured limit: denied traffic never grows the
115
+ # sliding window indefinitely or resets its expiration.
116
+ data["attempts"] << now
117
+ elsif config[:exponential_backoff]
118
+ index = [data.fetch("denials", 0), BACKOFF_DELAYS.length - 1].min
119
+ data["denials"] = [index + 1, BACKOFF_DELAYS.length].min
120
+ data["blocked_until"] = [data.fetch("blocked_until", 0), now + BACKOFF_DELAYS[index]].max
121
+ result = evaluate(data, config, now)
122
+ end
123
+ result.merge(tier: tier)
116
124
  end
117
-
118
- {
119
- allowed: false,
120
- count: current_count,
121
- limit: limit,
122
- reset_time: reset_time,
123
- retry_after: retry_after,
124
- reason: "rate_limit_exceeded"
125
- }
126
- else
127
- {
128
- allowed: true,
129
- count: current_count,
130
- limit: limit,
131
- remaining: limit - current_count
132
- }
125
+ most_restrictive_result(results)
133
126
  end
134
127
  end
135
128
 
136
- def record_attempt(ip_address, result, user)
137
- return if result == :check # Don't record check operations
138
-
139
- now = Time.current.to_i
140
-
141
- # Record IP attempt
142
- ip_cache_key = "beskar:ip_attempts:#{ip_address}"
143
- record_attempt_in_cache(ip_cache_key, now)
144
-
145
- # Record account attempt if user exists
146
- if user
147
- account_cache_key = "beskar:account_attempts:#{user.class.name}:#{user.id}"
148
- record_attempt_in_cache(account_cache_key, now)
149
- end
150
-
151
- # Record global attempt
152
- global_cache_key = "beskar:global_attempts"
153
- record_attempt_in_cache(global_cache_key, now, 1.minute)
129
+ def recent_attempts(data, config, now)
130
+ Array(data["attempts"]).select { |timestamp| timestamp > now - config[:period].to_f }
154
131
  end
155
132
 
156
- def record_attempt_in_cache(cache_key, timestamp, expiry = 1.hour)
157
- window_data = Rails.cache.read(cache_key) || {}
158
- window_data[timestamp] = (window_data[timestamp] || 0) + 1
159
- Rails.cache.write(cache_key, window_data, expires_in: expiry + 60)
133
+ def evaluate(data, config, now)
134
+ attempts = recent_attempts(data, config, now)
135
+ count = attempts.size
136
+ deadline = data.fetch("blocked_until", 0)
137
+ if count >= config[:limit]
138
+ # Enough entries must expire to bring the count below the limit.
139
+ deadline = [deadline, attempts.sort[count - config[:limit]] + config[:period].to_f].max
140
+ end
141
+ if deadline > now
142
+ {allowed: false, count: count, limit: config[:limit], remaining: 0,
143
+ reset_time: Time.at(deadline), retry_after: (deadline - now).ceil, reason: "rate_limit_exceeded"}
144
+ else
145
+ {allowed: true, count: count, limit: config[:limit], remaining: config[:limit] - count}
146
+ end
160
147
  end
161
148
 
162
- def calculate_retry_after(cache_key, config, current_count, limit)
163
- return 0 unless config[:exponential_backoff]
164
-
165
- backoff_key = cache_key.gsub("_attempts:", "_backoff:")
166
- failure_count = Rails.cache.read(backoff_key) || 0
167
-
168
- # Increment failure count
169
- Rails.cache.write(backoff_key, failure_count + 1, expires_in: 1.hour)
170
-
171
- # Exponential backoff: 1min, 5min, 15min, 1hour, 4hours, 24hours
172
- base_delays = [60, 300, 900, 3600, 14400, 86400] # in seconds
173
- delay_index = [failure_count, base_delays.length - 1].min
174
-
175
- base_delays[delay_index]
149
+ # Compatibility for callers that record an outcome directly.
150
+ def record_attempt(ip_address, result, user)
151
+ reserve(keys_for(ip_address, user)) unless result == :check
176
152
  end
177
153
 
178
154
  def most_restrictive_result(results)
179
- # Find the most restrictive (not allowed) result
180
- restricted = results.find { |r| !r[:allowed] }
181
- return restricted if restricted
155
+ return {allowed: true, count: 0, remaining: Float::INFINITY, disabled: true} if results.empty?
156
+ denied = results.reject { |result| result[:allowed] }
157
+ return denied.max_by { |result| result[:retry_after] } if denied.any?
182
158
 
183
- # If all are allowed, return the one with the lowest remaining count
184
- results.min_by { |r| r[:remaining] || Float::INFINITY }
159
+ results.min_by { |result| result[:remaining] }
185
160
  end
186
161
  end
187
162
 
@@ -193,21 +168,24 @@ module Beskar
193
168
 
194
169
  def allowed?
195
170
  self.class.check_ip_rate_limit(@ip_address)[:allowed] &&
196
- (@user.nil? || self.class.check_account_rate_limit(@user)[:allowed])
171
+ (@user.nil? || self.class.check_account_rate_limit(@user)[:allowed]) &&
172
+ self.class.check_global_rate_limit[:allowed]
197
173
  end
198
174
 
199
175
  def attempts_remaining
200
176
  ip_result = self.class.check_ip_rate_limit(@ip_address)
201
177
  account_result = @user ? self.class.check_account_rate_limit(@user) : {remaining: Float::INFINITY}
202
178
 
203
- [ip_result[:remaining] || 0, account_result[:remaining] || 0].min
179
+ global_result = self.class.check_global_rate_limit
180
+ [ip_result[:remaining] || 0, account_result[:remaining] || 0, global_result[:remaining] || 0].min
204
181
  end
205
182
 
206
183
  def time_until_reset
207
184
  ip_result = self.class.check_ip_rate_limit(@ip_address)
208
185
  account_result = @user ? self.class.check_account_rate_limit(@user) : {retry_after: 0}
209
186
 
210
- [ip_result[:retry_after] || 0, account_result[:retry_after] || 0].max
187
+ global_result = self.class.check_global_rate_limit
188
+ [ip_result[:retry_after] || 0, account_result[:retry_after] || 0, global_result[:retry_after] || 0].max
211
189
  end
212
190
 
213
191
  def reset!
@@ -0,0 +1,69 @@
1
+ require "ipaddr"
2
+ require "uri"
3
+
4
+ module Beskar
5
+ module Services
6
+ module RequestContext
7
+ module_function
8
+
9
+ # Nil when the request has no usable address, for example an in-process Rack
10
+ # client with a blank REMOTE_ADDR. Rails falls back to REMOTE_ADDR when
11
+ # forwarded headers are invalid, so a remote client cannot cause this.
12
+ def ip(request)
13
+ value = if request.respond_to?(:remote_ip)
14
+ request.remote_ip
15
+ elsif request.respond_to?(:env) && request.env && request.env["action_dispatch.remote_ip"]
16
+ request.env["action_dispatch.remote_ip"]
17
+ else
18
+ request.ip
19
+ end
20
+ IPAddr.new(value.to_s).to_s
21
+ rescue IPAddr::Error
22
+ nil
23
+ end
24
+
25
+ def text(value, limit: 500)
26
+ value&.to_s&.encode("UTF-8", invalid: :replace, undef: :replace)&.gsub(/[[:cntrl:]]/, " ")&.truncate(limit)
27
+ end
28
+
29
+ # Query strings and referrer queries may carry secrets even when their keys
30
+ # are unknown to the host application's parameter filter.
31
+ def path(request)
32
+ text(request.path, limit: 2048)
33
+ end
34
+
35
+ def security_metadata(request, enrich: true)
36
+ metadata = {
37
+ timestamp: Time.current.iso8601,
38
+ request_id: request.respond_to?(:request_id) ? request.request_id : nil,
39
+ request_path: path(request),
40
+ referer: safe_referer(request.referer)
41
+ }
42
+ if enrich
43
+ metadata[:device_info] = DeviceDetector.detect(text(request.user_agent))
44
+ metadata[:geolocation] = GeolocationService.locate(ip(request))
45
+ end
46
+ metadata
47
+ end
48
+
49
+ def safe_referer(value)
50
+ return if value.blank?
51
+ uri = URI.parse(text(value, limit: 2048))
52
+ return unless uri.is_a?(URI::HTTP)
53
+ uri.query = uri.fragment = nil
54
+ uri.user = uri.password = nil
55
+ uri.to_s
56
+ rescue URI::Error
57
+ nil
58
+ end
59
+
60
+ def secure_match?(supplied, expected)
61
+ supplied.present? && expected.present? && ActiveSupport::SecurityUtils.secure_compare(supplied.to_s, expected.to_s)
62
+ end
63
+
64
+ def enforce?(ip_address = nil)
65
+ !Beskar.configuration.monitor_only? && !IpWhitelist.whitelisted?(ip_address)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,58 @@
1
+ module Beskar
2
+ module Services
3
+ # A single snapshot supplies both the numeric decision and its audit evidence.
4
+ # IP repetition and lock/unlock records are not verified device/recovery trust.
5
+ class RiskAssessment
6
+ HISTORY_LIMIT = 20
7
+ attr_reader :score, :metadata
8
+
9
+ def self.observations(user, at: Time.current, mode: "enforce")
10
+ return [] unless user
11
+ user.security_events.where(event_type: "login_success", created_at: (at - 4.hours)...at)
12
+ .order(created_at: :desc, id: :desc).limit(HISTORY_LIMIT).pluck(:id, :created_at, :metadata)
13
+ .filter_map do |id, created_at, metadata|
14
+ next unless metadata.is_a?(Hash)
15
+ admission = metadata["authentication"]
16
+ next unless admission.is_a?(Hash) && admission["allowed"] == true && admission["locked_now"] != true
17
+ next unless usable_history?(metadata, mode)
18
+ {event_id: id, occurred_at: created_at, location: metadata["geolocation"]}
19
+ end
20
+ end
21
+
22
+ def self.usable_history?(metadata, mode)
23
+ return false unless metadata.is_a?(Hash)
24
+ assessment = metadata["risk_assessment"]
25
+ return false if assessment && !assessment.is_a?(Hash)
26
+ mode != "enforce" || assessment&.fetch("mode", nil) != "observe"
27
+ end
28
+
29
+ def initialize(request, user: nil, result: :success, at: Time.current)
30
+ ip = RequestContext.ip(request)
31
+ mode = RequestContext.enforce?(ip) ? "enforce" : "observe"
32
+ device = DeviceDetector.new.assess(request.user_agent)
33
+ geography = GeolocationService.new.assess_location(ip, observations: self.class.observations(user, at: at, mode: mode), at: at)
34
+ factors = [{name: (result == :success) ? "credential_success" : "credential_failure", points: (result == :success) ? 1 : 10, evidence: {}}]
35
+ factors.concat(device[:factors]).concat(geography[:factors])
36
+ if device[:device_info][:mobile] && (at.hour >= 22 || at.hour < 6)
37
+ factors << {name: "mobile_late_hours", points: 5, evidence: {hour: at.hour, timezone: at.zone}}
38
+ end
39
+ failures = if user
40
+ user.security_events.where(event_type: "login_failure", created_at: (at - 10.minutes)..at)
41
+ .order(created_at: :desc, id: :desc).limit(HISTORY_LIMIT).pluck(:metadata)
42
+ .count { |metadata| self.class.usable_history?(metadata, mode) }
43
+ else
44
+ 0
45
+ end
46
+ factors << {name: "recent_failures", points: 20, evidence: {at_least: 2, window_seconds: 600}} if failures >= 2
47
+ total = factors.sum { |factor| factor[:points] }
48
+ factors << {name: "total_cap", points: 100 - total, evidence: {cap: 100}} if total > 100
49
+ @score = [total, 100].min
50
+ @metadata = {
51
+ device_info: device[:device_info], geolocation: geography[:location],
52
+ risk_assessment: {version: 1, assessed_at: at.iso8601(6), mode: mode, score: score,
53
+ factors: factors, trust_discount: 0, history_limit: HISTORY_LIMIT}
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end