beskar 0.0.2 → 0.2.0

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 +274 -0
  3. data/README.md +412 -204
  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 +214 -0
  7. data/app/controllers/beskar/banned_ips_controller.rb +255 -0
  8. data/app/controllers/beskar/dashboard_controller.rb +62 -0
  9. data/app/controllers/beskar/security_events_controller.rb +164 -0
  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 +105 -105
  17. data/app/models/beskar/security_event.rb +51 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +88 -0
  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 +195 -0
  23. data/app/views/beskar/banned_ips/index.html.erb +319 -0
  24. data/app/views/beskar/banned_ips/new.html.erb +190 -0
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +304 -0
  27. data/app/views/beskar/dashboard/index.html.erb +280 -0
  28. data/app/views/beskar/security_events/index.html.erb +302 -0
  29. data/app/views/beskar/security_events/show.html.erb +293 -0
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  32. data/app/views/layouts/beskar/application.html.erb +581 -6
  33. data/config/routes.rb +30 -0
  34. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  35. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  36. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  37. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  38. data/docs/README.md +73 -0
  39. data/docs/archive/project-documentation.md +659 -0
  40. data/docs/audits/project-review.md +437 -0
  41. data/docs/audits/repair-status.md +216 -0
  42. data/docs/guides/audit-and-waf.md +175 -0
  43. data/docs/guides/audit-lifecycle.md +172 -0
  44. data/docs/guides/authentication.md +213 -0
  45. data/docs/guides/configuration.md +182 -0
  46. data/docs/guides/dashboard-and-search.md +251 -0
  47. data/docs/guides/notifications-and-recovery.md +157 -0
  48. data/docs/guides/risk-scoring.md +116 -0
  49. data/docs/operations/monitor-only-mode.md +85 -0
  50. data/docs/operations/security-hardening.md +167 -0
  51. data/docs/operations/state-storage.md +144 -0
  52. data/docs/research/rust-performance-assessment.md +69 -0
  53. data/lib/beskar/configuration.rb +105 -20
  54. data/lib/beskar/configuration_validator.rb +188 -0
  55. data/lib/beskar/devise_authentication.rb +24 -0
  56. data/lib/beskar/engine.rb +21 -88
  57. data/lib/beskar/logger.rb +288 -0
  58. data/lib/beskar/middleware/request_analyzer.rb +133 -99
  59. data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
  60. data/lib/beskar/models/security_trackable_devise.rb +34 -25
  61. data/lib/beskar/models/security_trackable_generic.rb +171 -214
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +90 -81
  64. data/lib/beskar/services/administrative_audit.rb +36 -0
  65. data/lib/beskar/services/administrative_bans.rb +104 -0
  66. data/lib/beskar/services/audit_data.rb +72 -0
  67. data/lib/beskar/services/authentication.rb +31 -0
  68. data/lib/beskar/services/authentication_attempt.rb +141 -0
  69. data/lib/beskar/services/ban_expiry.rb +28 -0
  70. data/lib/beskar/services/device_detector.rb +32 -41
  71. data/lib/beskar/services/event_search.rb +58 -0
  72. data/lib/beskar/services/geolocation_service.rb +83 -114
  73. data/lib/beskar/services/ip_whitelist.rb +31 -40
  74. data/lib/beskar/services/location_assessment.rb +109 -0
  75. data/lib/beskar/services/native_account_lock.rb +82 -0
  76. data/lib/beskar/services/notifications.rb +46 -0
  77. data/lib/beskar/services/rate_limiter.rb +99 -125
  78. data/lib/beskar/services/request_context.rb +64 -0
  79. data/lib/beskar/services/risk_assessment.rb +58 -0
  80. data/lib/beskar/services/session_revocation.rb +62 -0
  81. data/lib/beskar/services/waf.rb +311 -198
  82. data/lib/beskar/services/waf_request.rb +60 -0
  83. data/lib/beskar/version.rb +1 -1
  84. data/lib/beskar/warden_authentication.rb +53 -0
  85. data/lib/beskar.rb +54 -4
  86. data/lib/generators/beskar/install/install_generator.rb +158 -0
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
  88. data/lib/tasks/beskar_tasks.rake +25 -20
  89. metadata +93 -12
  90. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -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,146 @@ 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
+ configs = keys.to_h { |tier, _| [tier, config_for(tier)] }
99
+ ttl = [configs.values.map { |config| config[:period].to_f }.max, BACKOFF_DELAYS.last].max + 60
100
+ SecurityState.mutate(keys.values, ttl: ttl) do |state|
101
+ now = Time.current.to_f
102
+ results = keys.map do |tier, key|
103
+ config = configs.fetch(tier)
104
+ data = state.fetch(key)
105
+ data["attempts"] = recent_attempts(data, config, now)
106
+ result = evaluate(data, config, now)
107
+ if result[:allowed]
108
+ data.delete("denials")
109
+ data.delete("blocked_until")
110
+ # Bounded to the configured limit: denied traffic never grows the
111
+ # sliding window indefinitely or resets its expiration.
112
+ data["attempts"] << now
113
+ elsif config[:exponential_backoff]
114
+ index = [data.fetch("denials", 0), BACKOFF_DELAYS.length - 1].min
115
+ data["denials"] = [index + 1, BACKOFF_DELAYS.length].min
116
+ data["blocked_until"] = [data.fetch("blocked_until", 0), now + BACKOFF_DELAYS[index]].max
117
+ result = evaluate(data, config, now)
118
+ end
119
+ result.merge(tier: tier)
116
120
  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
- }
121
+ most_restrictive_result(results)
133
122
  end
134
123
  end
135
124
 
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)
125
+ def recent_attempts(data, config, now)
126
+ Array(data["attempts"]).select { |timestamp| timestamp > now - config[:period].to_f }
154
127
  end
155
128
 
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)
129
+ def evaluate(data, config, now)
130
+ attempts = recent_attempts(data, config, now)
131
+ count = attempts.size
132
+ deadline = data.fetch("blocked_until", 0)
133
+ if count >= config[:limit]
134
+ # Enough entries must expire to bring the count below the limit.
135
+ deadline = [deadline, attempts.sort[count - config[:limit]] + config[:period].to_f].max
136
+ end
137
+ if deadline > now
138
+ {allowed: false, count: count, limit: config[:limit], remaining: 0,
139
+ reset_time: Time.at(deadline), retry_after: (deadline - now).ceil, reason: "rate_limit_exceeded"}
140
+ else
141
+ {allowed: true, count: count, limit: config[:limit], remaining: config[:limit] - count}
142
+ end
160
143
  end
161
144
 
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]
145
+ # Compatibility for callers that record an outcome directly.
146
+ def record_attempt(ip_address, result, user)
147
+ reserve(keys_for(ip_address, user)) unless result == :check
176
148
  end
177
149
 
178
150
  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
151
+ return {allowed: true, count: 0, remaining: Float::INFINITY, disabled: true} if results.empty?
152
+ denied = results.reject { |result| result[:allowed] }
153
+ return denied.max_by { |result| result[:retry_after] } if denied.any?
182
154
 
183
- # If all are allowed, return the one with the lowest remaining count
184
- results.min_by { |r| r[:remaining] || Float::INFINITY }
155
+ results.min_by { |result| result[:remaining] }
185
156
  end
186
157
  end
187
158
 
@@ -193,21 +164,24 @@ module Beskar
193
164
 
194
165
  def allowed?
195
166
  self.class.check_ip_rate_limit(@ip_address)[:allowed] &&
196
- (@user.nil? || self.class.check_account_rate_limit(@user)[:allowed])
167
+ (@user.nil? || self.class.check_account_rate_limit(@user)[:allowed]) &&
168
+ self.class.check_global_rate_limit[:allowed]
197
169
  end
198
170
 
199
171
  def attempts_remaining
200
172
  ip_result = self.class.check_ip_rate_limit(@ip_address)
201
173
  account_result = @user ? self.class.check_account_rate_limit(@user) : {remaining: Float::INFINITY}
202
174
 
203
- [ip_result[:remaining] || 0, account_result[:remaining] || 0].min
175
+ global_result = self.class.check_global_rate_limit
176
+ [ip_result[:remaining] || 0, account_result[:remaining] || 0, global_result[:remaining] || 0].min
204
177
  end
205
178
 
206
179
  def time_until_reset
207
180
  ip_result = self.class.check_ip_rate_limit(@ip_address)
208
181
  account_result = @user ? self.class.check_account_rate_limit(@user) : {retry_after: 0}
209
182
 
210
- [ip_result[:retry_after] || 0, account_result[:retry_after] || 0].max
183
+ global_result = self.class.check_global_rate_limit
184
+ [ip_result[:retry_after] || 0, account_result[:retry_after] || 0, global_result[:retry_after] || 0].max
211
185
  end
212
186
 
213
187
  def reset!
@@ -0,0 +1,64 @@
1
+ require "ipaddr"
2
+ require "uri"
3
+
4
+ module Beskar
5
+ module Services
6
+ module RequestContext
7
+ module_function
8
+
9
+ def ip(request)
10
+ value = if request.respond_to?(:remote_ip)
11
+ request.remote_ip
12
+ elsif request.respond_to?(:env) && request.env && request.env["action_dispatch.remote_ip"]
13
+ request.env["action_dispatch.remote_ip"]
14
+ else
15
+ request.ip
16
+ end
17
+ IPAddr.new(value.to_s).to_s
18
+ end
19
+
20
+ def text(value, limit: 500)
21
+ value&.to_s&.encode("UTF-8", invalid: :replace, undef: :replace)&.gsub(/[[:cntrl:]]/, " ")&.truncate(limit)
22
+ end
23
+
24
+ # Query strings and referrer queries may carry secrets even when their keys
25
+ # are unknown to the host application's parameter filter.
26
+ def path(request)
27
+ text(request.path, limit: 2048)
28
+ end
29
+
30
+ def security_metadata(request, enrich: true)
31
+ metadata = {
32
+ timestamp: Time.current.iso8601,
33
+ request_id: request.respond_to?(:request_id) ? request.request_id : nil,
34
+ request_path: path(request),
35
+ referer: safe_referer(request.referer)
36
+ }
37
+ if enrich
38
+ metadata[:device_info] = DeviceDetector.detect(text(request.user_agent))
39
+ metadata[:geolocation] = GeolocationService.locate(ip(request))
40
+ end
41
+ metadata
42
+ end
43
+
44
+ def safe_referer(value)
45
+ return if value.blank?
46
+ uri = URI.parse(text(value, limit: 2048))
47
+ return unless uri.is_a?(URI::HTTP)
48
+ uri.query = uri.fragment = nil
49
+ uri.user = uri.password = nil
50
+ uri.to_s
51
+ rescue URI::Error
52
+ nil
53
+ end
54
+
55
+ def secure_match?(supplied, expected)
56
+ supplied.present? && expected.present? && ActiveSupport::SecurityUtils.secure_compare(supplied.to_s, expected.to_s)
57
+ end
58
+
59
+ def enforce?(ip_address = nil)
60
+ !Beskar.configuration.monitor_only? && !IpWhitelist.whitelisted?(ip_address)
61
+ end
62
+ end
63
+ end
64
+ 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