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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +274 -0
- data/README.md +412 -204
- data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
- data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
- data/app/controllers/beskar/application_controller.rb +214 -0
- data/app/controllers/beskar/banned_ips_controller.rb +255 -0
- data/app/controllers/beskar/dashboard_controller.rb +62 -0
- data/app/controllers/beskar/security_events_controller.rb +164 -0
- data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
- data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
- data/app/jobs/beskar/notification_job.rb +33 -0
- data/app/mailers/beskar/security_mailer.rb +59 -0
- data/app/models/beskar/administrative_action.rb +41 -0
- data/app/models/beskar/banned_ip.rb +105 -105
- data/app/models/beskar/security_event.rb +51 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +88 -0
- data/app/views/beskar/administrative_actions/index.html.erb +33 -0
- data/app/views/beskar/administrative_actions/show.html.erb +21 -0
- data/app/views/beskar/banned_ips/edit.html.erb +195 -0
- data/app/views/beskar/banned_ips/index.html.erb +319 -0
- data/app/views/beskar/banned_ips/new.html.erb +190 -0
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +304 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +302 -0
- data/app/views/beskar/security_events/show.html.erb +293 -0
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/layouts/beskar/_behavior.html.erb +121 -0
- data/app/views/layouts/beskar/application.html.erb +581 -6
- data/config/routes.rb +30 -0
- data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
- data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
- data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
- data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
- data/docs/README.md +73 -0
- data/docs/archive/project-documentation.md +659 -0
- data/docs/audits/project-review.md +437 -0
- data/docs/audits/repair-status.md +216 -0
- data/docs/guides/audit-and-waf.md +175 -0
- data/docs/guides/audit-lifecycle.md +172 -0
- data/docs/guides/authentication.md +213 -0
- data/docs/guides/configuration.md +182 -0
- data/docs/guides/dashboard-and-search.md +251 -0
- data/docs/guides/notifications-and-recovery.md +157 -0
- data/docs/guides/risk-scoring.md +116 -0
- data/docs/operations/monitor-only-mode.md +85 -0
- data/docs/operations/security-hardening.md +167 -0
- data/docs/operations/state-storage.md +144 -0
- data/docs/research/rust-performance-assessment.md +69 -0
- data/lib/beskar/configuration.rb +105 -20
- data/lib/beskar/configuration_validator.rb +188 -0
- data/lib/beskar/devise_authentication.rb +24 -0
- data/lib/beskar/engine.rb +21 -88
- data/lib/beskar/logger.rb +288 -0
- data/lib/beskar/middleware/request_analyzer.rb +133 -99
- data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
- data/lib/beskar/models/security_trackable_devise.rb +34 -25
- data/lib/beskar/models/security_trackable_generic.rb +171 -214
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +90 -81
- data/lib/beskar/services/administrative_audit.rb +36 -0
- data/lib/beskar/services/administrative_bans.rb +104 -0
- data/lib/beskar/services/audit_data.rb +72 -0
- data/lib/beskar/services/authentication.rb +31 -0
- data/lib/beskar/services/authentication_attempt.rb +141 -0
- data/lib/beskar/services/ban_expiry.rb +28 -0
- data/lib/beskar/services/device_detector.rb +32 -41
- data/lib/beskar/services/event_search.rb +58 -0
- data/lib/beskar/services/geolocation_service.rb +83 -114
- data/lib/beskar/services/ip_whitelist.rb +31 -40
- data/lib/beskar/services/location_assessment.rb +109 -0
- data/lib/beskar/services/native_account_lock.rb +82 -0
- data/lib/beskar/services/notifications.rb +46 -0
- data/lib/beskar/services/rate_limiter.rb +99 -125
- data/lib/beskar/services/request_context.rb +64 -0
- data/lib/beskar/services/risk_assessment.rb +58 -0
- data/lib/beskar/services/session_revocation.rb +62 -0
- data/lib/beskar/services/waf.rb +311 -198
- data/lib/beskar/services/waf_request.rb +60 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar/warden_authentication.rb +53 -0
- data/lib/beskar.rb +54 -4
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
- data/lib/tasks/beskar_tasks.rake +25 -20
- metadata +93 -12
- 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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
137
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|