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
|
@@ -7,78 +7,122 @@ module Beskar
|
|
|
7
7
|
|
|
8
8
|
def call(env)
|
|
9
9
|
request = ActionDispatch::Request.new(env)
|
|
10
|
-
ip_address =
|
|
10
|
+
ip_address = Beskar::Services::RequestContext.ip(request)
|
|
11
|
+
|
|
12
|
+
Beskar::Logger.debug("[RequestAnalyzer] Processing request from IP: #{ip_address}", component: :Middleware)
|
|
11
13
|
|
|
12
14
|
# 1. Check if IP is whitelisted (whitelisted IPs skip blocking but still get logged)
|
|
13
15
|
is_whitelisted = Beskar::Services::IpWhitelist.whitelisted?(ip_address)
|
|
14
16
|
|
|
15
|
-
# 2. Check if IP is banned (early exit for blocked IPs, unless whitelisted)
|
|
17
|
+
# 2. Check if IP is banned (early exit for blocked IPs, unless whitelisted or in monitor-only mode)
|
|
16
18
|
if !is_whitelisted && Beskar::BannedIp.banned?(ip_address)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
if Beskar.configuration.monitor_only?
|
|
20
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block request from banned IP: #{ip_address}, but monitor_only=true. Request proceeding normally.", component: :Middleware)
|
|
21
|
+
else
|
|
22
|
+
Beskar::Logger.warn("Blocked request from banned IP: #{ip_address}", component: :Middleware)
|
|
23
|
+
return blocked_response("Your IP address has been blocked due to suspicious activity.")
|
|
24
|
+
end
|
|
19
25
|
end
|
|
20
26
|
|
|
21
27
|
# 3. Check rate limiting (unless whitelisted)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
#
|
|
26
|
-
if should_auto_block_rate_limit?(ip_address)
|
|
28
|
+
rate_limit = Beskar.configuration.rate_limiting[:ip_attempts][:block_requests] ?
|
|
29
|
+
Beskar::Services::RateLimiter.check_ip_rate_limit(ip_address) : {allowed: true}
|
|
30
|
+
if !is_whitelisted && !rate_limit[:allowed]
|
|
31
|
+
# Observe denials separately in monitor mode; never create active bans.
|
|
32
|
+
if should_auto_block_rate_limit?(ip_address) && !Beskar.configuration.monitor_only?
|
|
27
33
|
Beskar::BannedIp.ban!(
|
|
28
34
|
ip_address,
|
|
29
|
-
reason:
|
|
35
|
+
reason: "rate_limit_abuse",
|
|
30
36
|
duration: 1.hour,
|
|
31
|
-
details:
|
|
37
|
+
details: "Excessive rate limit violations"
|
|
32
38
|
)
|
|
33
39
|
end
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
|
|
41
|
+
if Beskar.configuration.monitor_only?
|
|
42
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block rate limit exceeded for IP: #{ip_address}, but monitor_only=true. Request proceeding normally.", component: :Middleware)
|
|
43
|
+
else
|
|
44
|
+
Beskar::Logger.warn("Rate limit exceeded for IP: #{ip_address}", component: :Middleware)
|
|
45
|
+
return rate_limit_response(rate_limit[:retry_after])
|
|
46
|
+
end
|
|
36
47
|
end
|
|
37
48
|
|
|
38
49
|
# 4. Check WAF patterns (vulnerability scans)
|
|
39
50
|
if Beskar.configuration.waf_enabled?
|
|
51
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF enabled, analyzing request", component: :Middleware)
|
|
40
52
|
waf_analysis = Beskar::Services::Waf.analyze_request(request)
|
|
41
|
-
|
|
53
|
+
|
|
42
54
|
if waf_analysis
|
|
55
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF detected threat: #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")}", component: :Middleware)
|
|
43
56
|
# Log the violation (and create security event if configured)
|
|
44
57
|
# Pass whitelist status to prevent auto-blocking whitelisted IPs
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
current_score = Beskar::Services::Waf.record_violation(ip_address, waf_analysis, whitelisted: is_whitelisted)
|
|
59
|
+
waf_recorded = true
|
|
60
|
+
Beskar::Logger.debug("[RequestAnalyzer] Current score after recording: #{current_score.round(2)}", component: :Middleware)
|
|
61
|
+
|
|
47
62
|
# Log even for whitelisted IPs (but don't block)
|
|
48
63
|
if is_whitelisted
|
|
49
|
-
|
|
50
|
-
"[
|
|
51
|
-
"(not blocking): #{waf_analysis[:patterns].map { |p| p[:description] }.join(', ')}"
|
|
52
|
-
)
|
|
64
|
+
Beskar::Logger.info("WAF violation from whitelisted IP #{ip_address} " \
|
|
65
|
+
"(not blocking): #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")}", component: :Middleware)
|
|
53
66
|
else
|
|
54
67
|
# Check if we should block
|
|
55
68
|
should_block = Beskar::Services::Waf.should_block?(ip_address)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
Beskar::Logger.debug("[RequestAnalyzer] Should block IP #{ip_address}?: #{should_block}", component: :Middleware)
|
|
70
|
+
|
|
71
|
+
if Beskar.configuration.monitor_only?
|
|
72
|
+
# Monitor-only mode records observations without creating active bans.
|
|
59
73
|
if should_block
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"Request proceeding normally."
|
|
64
|
-
)
|
|
74
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block IP #{ip_address} " \
|
|
75
|
+
"with score #{current_score.round(2)}, but monitor_only=true. " \
|
|
76
|
+
"Request proceeding normally.", component: :Middleware)
|
|
65
77
|
end
|
|
66
|
-
elsif should_block
|
|
67
|
-
# Actually block the request
|
|
68
|
-
|
|
69
|
-
"
|
|
70
|
-
"after #{violation_count} WAF violations"
|
|
71
|
-
)
|
|
78
|
+
elsif should_block && !Beskar.configuration.monitor_only?
|
|
79
|
+
# Actually block the request (not in monitor-only mode)
|
|
80
|
+
Beskar::Logger.warn("🔒 Blocking IP #{ip_address} " \
|
|
81
|
+
"with WAF score #{current_score.round(2)}", component: :Middleware)
|
|
72
82
|
# Block already handled by WAF.record_violation auto-block logic
|
|
73
83
|
# But we return 403 immediately
|
|
74
84
|
return blocked_response("Access denied due to suspicious activity.")
|
|
75
85
|
end
|
|
76
86
|
end
|
|
87
|
+
else
|
|
88
|
+
Beskar::Logger.debug("[RequestAnalyzer] No WAF threat detected", component: :Middleware)
|
|
77
89
|
end
|
|
90
|
+
else
|
|
91
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF is disabled", component: :Middleware)
|
|
78
92
|
end
|
|
79
93
|
|
|
80
94
|
# 5. Process the request normally (will raise 404 if route not found)
|
|
95
|
+
Beskar::Logger.debug("[RequestAnalyzer] Passing request to application", component: :Middleware)
|
|
96
|
+
processing_host = true
|
|
81
97
|
@app.call(env)
|
|
98
|
+
rescue Beskar::Services::AuthenticationAttempt::Unavailable
|
|
99
|
+
Beskar::Services::AuthenticationAttempt.unavailable_response
|
|
100
|
+
rescue ActionController::UnknownFormat => e
|
|
101
|
+
# Analyze unknown format as potential scanner
|
|
102
|
+
if Beskar.configuration.waf_enabled?
|
|
103
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) unless waf_recorded
|
|
104
|
+
end
|
|
105
|
+
# Re-raise to allow normal error handling
|
|
106
|
+
raise
|
|
107
|
+
rescue ActionDispatch::RemoteIp::IpSpoofAttackError => e
|
|
108
|
+
# Attribute downstream errors only when Rails already resolved a trusted
|
|
109
|
+
# client IP. Never ban an address from a rejected proxy chain.
|
|
110
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) if !waf_recorded && ip_address && Beskar.configuration.waf_enabled?
|
|
111
|
+
raise
|
|
112
|
+
rescue ActiveRecord::RecordNotFound => e
|
|
113
|
+
# Analyze record not found as potential enumeration scan
|
|
114
|
+
if Beskar.configuration.waf_enabled?
|
|
115
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) unless waf_recorded
|
|
116
|
+
end
|
|
117
|
+
# Re-raise to allow normal error handling
|
|
118
|
+
raise
|
|
119
|
+
rescue ActionDispatch::Http::MimeNegotiation::InvalidType => e
|
|
120
|
+
# Analyze invalid MIME type as potential scanner
|
|
121
|
+
if Beskar.configuration.waf_enabled?
|
|
122
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) unless waf_recorded
|
|
123
|
+
end
|
|
124
|
+
# Re-raise to allow normal error handling
|
|
125
|
+
raise
|
|
82
126
|
rescue ActionController::RoutingError => e
|
|
83
127
|
# If WAF is enabled, log 404s as potential scanning attempts
|
|
84
128
|
if Beskar.configuration.waf_enabled?
|
|
@@ -86,79 +130,69 @@ module Beskar
|
|
|
86
130
|
end
|
|
87
131
|
# Re-raise to allow normal 404 handling
|
|
88
132
|
raise
|
|
133
|
+
rescue ActiveRecord::ActiveRecordError => error
|
|
134
|
+
raise if processing_host
|
|
135
|
+
Beskar::Logger.warn("Request security state unavailable (#{error.class})")
|
|
136
|
+
Beskar::Services::AuthenticationAttempt.unavailable_response
|
|
89
137
|
end
|
|
90
138
|
|
|
91
139
|
private
|
|
92
140
|
|
|
93
|
-
def rate_limited?(request)
|
|
94
|
-
# Check both IP rate limit and authentication abuse
|
|
95
|
-
ip_check = Beskar::Services::RateLimiter.check_ip_rate_limit(request.ip)
|
|
96
|
-
auth_abused = authentication_brute_force?(request.ip)
|
|
97
|
-
|
|
98
|
-
!ip_check[:allowed] || auth_abused
|
|
99
|
-
end
|
|
100
|
-
|
|
101
141
|
def should_auto_block_rate_limit?(ip_address)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
142
|
+
mode = Beskar.configuration.monitor_only? ? "observe" : "enforce"
|
|
143
|
+
key = "rate_denials:#{mode}:#{ip_address}"
|
|
144
|
+
Beskar::SecurityState.mutate(key, ttl: 1.hour) do |state|
|
|
145
|
+
data = state.fetch(key)
|
|
146
|
+
now = Time.current.to_f
|
|
147
|
+
# A fixed window: repeated requests cannot prolong old violations.
|
|
148
|
+
data.clear if data["window_end"] && data["window_end"] <= now
|
|
149
|
+
data["window_end"] ||= now + 1.hour
|
|
150
|
+
data["count"] = [data.fetch("count", 0) + 1, 5].min
|
|
151
|
+
data["count"] >= 5
|
|
152
|
+
end
|
|
110
153
|
end
|
|
111
154
|
|
|
112
|
-
def
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return true
|
|
155
|
+
def handle_rails_exception(request, exception, ip_address, is_whitelisted)
|
|
156
|
+
# Analyze the exception using WAF
|
|
157
|
+
waf_analysis = Beskar::Services::Waf.analyze_exception(exception, request)
|
|
158
|
+
|
|
159
|
+
if waf_analysis
|
|
160
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF detected threat from exception: #{exception.class.name}", component: :Middleware)
|
|
161
|
+
|
|
162
|
+
# Record the violation (similar to regular WAF violations)
|
|
163
|
+
current_score = Beskar::Services::Waf.record_violation(ip_address, waf_analysis, whitelisted: is_whitelisted)
|
|
164
|
+
|
|
165
|
+
# Log for whitelisted IPs
|
|
166
|
+
if is_whitelisted
|
|
167
|
+
Beskar::Logger.info("Exception-based WAF violation from whitelisted IP #{ip_address} " \
|
|
168
|
+
"(not blocking): #{exception.class.name} - #{waf_analysis[:patterns].first[:description]}", component: :Middleware)
|
|
169
|
+
else
|
|
170
|
+
# Check if we should block
|
|
171
|
+
should_block = Beskar::Services::Waf.should_block?(ip_address)
|
|
172
|
+
|
|
173
|
+
if Beskar.configuration.monitor_only?
|
|
174
|
+
if should_block
|
|
175
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block IP #{ip_address} " \
|
|
176
|
+
"with score #{current_score.round(2)} (exception: #{exception.class.name}), " \
|
|
177
|
+
"but monitor_only=true. Request proceeding normally.", component: :Middleware)
|
|
178
|
+
end
|
|
179
|
+
elsif should_block && !Beskar.configuration.monitor_only?
|
|
180
|
+
Beskar::Logger.warn("🔒 Blocking IP #{ip_address} " \
|
|
181
|
+
"with score #{current_score.round(2)} (exception: #{exception.class.name})", component: :Middleware)
|
|
182
|
+
# Note: We don't return blocked response here as exception is already raised
|
|
183
|
+
# The ban record is created by WAF.record_violation
|
|
184
|
+
end
|
|
185
|
+
end
|
|
145
186
|
end
|
|
146
|
-
|
|
147
|
-
false
|
|
148
187
|
end
|
|
149
188
|
|
|
150
189
|
def log_404_for_waf(request, error)
|
|
151
|
-
# 404s on suspicious paths might indicate scanning
|
|
152
|
-
path = request.fullpath || request.path
|
|
153
|
-
|
|
154
190
|
# Only log if it matches WAF patterns (already analyzed in analyze_request)
|
|
155
191
|
waf_analysis = Beskar::Services::Waf.analyze_request(request)
|
|
156
|
-
|
|
192
|
+
|
|
157
193
|
if waf_analysis
|
|
158
|
-
|
|
159
|
-
"[
|
|
160
|
-
"(WAF patterns: #{waf_analysis[:patterns].map { |p| p[:description] }.join(', ')})"
|
|
161
|
-
)
|
|
194
|
+
Beskar::Logger.info("404 matching WAF rules from #{Beskar::Services::RequestContext.ip(request)} " \
|
|
195
|
+
"(WAF patterns: #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")})", component: :Middleware)
|
|
162
196
|
end
|
|
163
197
|
end
|
|
164
198
|
|
|
@@ -166,20 +200,20 @@ module Beskar
|
|
|
166
200
|
[
|
|
167
201
|
403,
|
|
168
202
|
{
|
|
169
|
-
"
|
|
170
|
-
"
|
|
203
|
+
"content-type" => "text/html; charset=utf-8",
|
|
204
|
+
"x-beskar-blocked" => "true"
|
|
171
205
|
},
|
|
172
206
|
[render_blocked_page(message)]
|
|
173
207
|
]
|
|
174
208
|
end
|
|
175
209
|
|
|
176
|
-
def rate_limit_response
|
|
210
|
+
def rate_limit_response(retry_after)
|
|
177
211
|
[
|
|
178
212
|
429,
|
|
179
213
|
{
|
|
180
|
-
"
|
|
181
|
-
"
|
|
182
|
-
"
|
|
214
|
+
"content-type" => "text/html; charset=utf-8",
|
|
215
|
+
"retry-after" => [retry_after.to_i, 1].max.to_s,
|
|
216
|
+
"x-beskar-rate-limited" => "true"
|
|
183
217
|
},
|
|
184
218
|
[render_rate_limit_page]
|
|
185
219
|
]
|
|
@@ -16,17 +16,31 @@ module Beskar
|
|
|
16
16
|
|
|
17
17
|
# Make handle_high_risk_lock public (it's private in Generic)
|
|
18
18
|
public
|
|
19
|
-
|
|
19
|
+
|
|
20
|
+
def beskar_access_locked?
|
|
21
|
+
Services::NativeAccountLock.locked?(self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def beskar_access_allowed?(request)
|
|
25
|
+
!Services::RequestContext.enforce?(Services::RequestContext.ip(request)) || !beskar_access_locked?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def with_beskar_session(request, generation: nil, &block)
|
|
29
|
+
Services::NativeAccountLock.with_session(self, request, generation: generation, &block)
|
|
30
|
+
end
|
|
31
|
+
|
|
20
32
|
# Rails 8 auth-specific: Handle high risk lock by destroying sessions
|
|
21
33
|
# Public method called when high-risk event is detected
|
|
22
34
|
def handle_high_risk_lock(security_event, request)
|
|
35
|
+
return unless Services::RequestContext.enforce?(Services::RequestContext.ip(request))
|
|
36
|
+
return unless beskar_access_locked?
|
|
23
37
|
reason = determine_lock_reason(security_event)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
|
|
39
|
+
Beskar::Logger.warn("Rails auth high-risk lock detected: #{reason}")
|
|
40
|
+
|
|
41
|
+
# NativeAccountLock already revoked every database session atomically
|
|
42
|
+
# with the lock. A Rack session ID is not a sessions-table primary key.
|
|
43
|
+
|
|
30
44
|
# Check if this warrants emergency password reset
|
|
31
45
|
if should_reset_password?(security_event, reason)
|
|
32
46
|
perform_emergency_password_reset(security_event, reason)
|
|
@@ -39,128 +53,93 @@ module Beskar
|
|
|
39
53
|
if except
|
|
40
54
|
# Keep current session but destroy all others
|
|
41
55
|
sessions.where.not(id: except).destroy_all
|
|
42
|
-
|
|
56
|
+
Beskar::Logger.info("Destroyed #{sessions.count} sessions except current")
|
|
43
57
|
else
|
|
44
58
|
# Destroy ALL sessions including current
|
|
45
59
|
count = sessions.count
|
|
46
60
|
sessions.destroy_all
|
|
47
|
-
|
|
61
|
+
Beskar::Logger.info("Destroyed all #{count} sessions")
|
|
48
62
|
end
|
|
49
63
|
else
|
|
50
|
-
|
|
64
|
+
Beskar::Logger.warn("Model does not have sessions association, cannot destroy sessions")
|
|
51
65
|
end
|
|
52
66
|
rescue => e
|
|
53
|
-
|
|
67
|
+
Beskar::Logger.error("Failed to destroy sessions: #{e.class}")
|
|
54
68
|
end
|
|
55
69
|
|
|
56
70
|
# Determine if emergency password reset is warranted
|
|
57
71
|
def should_reset_password?(security_event, reason)
|
|
58
72
|
config = Beskar.configuration.emergency_password_reset
|
|
59
73
|
return false unless config[:enabled]
|
|
74
|
+
return false unless Services::RequestContext.enforce?(security_event.ip_address)
|
|
60
75
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
.where("metadata->>'geolocation' LIKE ?", '%impossible_travel%')
|
|
68
|
-
.count
|
|
69
|
-
|
|
70
|
-
recent_impossible_travel >= (config[:impossible_travel_threshold] || 3)
|
|
71
|
-
|
|
72
|
-
when :suspicious_device
|
|
73
|
-
# Multiple suspicious device logins
|
|
74
|
-
recent_suspicious = security_events
|
|
75
|
-
.where(event_type: "account_locked")
|
|
76
|
-
.where("created_at >= ?", 24.hours.ago)
|
|
77
|
-
.where("metadata->>'device_info' LIKE ?", '%suspicious%')
|
|
78
|
-
.count
|
|
79
|
-
|
|
80
|
-
recent_suspicious >= (config[:suspicious_device_threshold] || 5)
|
|
81
|
-
|
|
82
|
-
else
|
|
83
|
-
# For other reasons, check total lock count
|
|
84
|
-
recent_locks = security_events
|
|
85
|
-
.where(event_type: "account_locked")
|
|
86
|
-
.where("created_at >= ?", 24.hours.ago)
|
|
87
|
-
.count
|
|
88
|
-
|
|
89
|
-
recent_locks >= (config[:total_locks_threshold] || 5)
|
|
76
|
+
events = security_events.where(event_type: "account_locked")
|
|
77
|
+
.where("created_at >= ?", 24.hours.ago)
|
|
78
|
+
threshold = case reason
|
|
79
|
+
when :impossible_travel then config[:impossible_travel_threshold] || 3
|
|
80
|
+
when :suspicious_device then config[:suspicious_device_threshold] || 5
|
|
81
|
+
else config[:total_locks_threshold] || 5
|
|
90
82
|
end
|
|
83
|
+
|
|
84
|
+
count = 0
|
|
85
|
+
events.find_each do |event|
|
|
86
|
+
data = (event.metadata || {}).deep_stringify_keys
|
|
87
|
+
context = data["additional_context"] || {}
|
|
88
|
+
matches = case reason
|
|
89
|
+
when :impossible_travel
|
|
90
|
+
data["reason"] == "impossible_travel" ||
|
|
91
|
+
data.dig("geolocation", "impossible_travel") == true ||
|
|
92
|
+
context.dig("geolocation", "impossible_travel") == true
|
|
93
|
+
when :suspicious_device
|
|
94
|
+
data["reason"] == "suspicious_device" ||
|
|
95
|
+
data.dig("device_info", "suspicious") == true ||
|
|
96
|
+
context.dig("device_info", "suspicious") == true
|
|
97
|
+
else true
|
|
98
|
+
end
|
|
99
|
+
count += 1 if matches
|
|
100
|
+
return true if count >= threshold
|
|
101
|
+
end
|
|
102
|
+
false
|
|
91
103
|
end
|
|
92
104
|
|
|
93
|
-
#
|
|
105
|
+
# Password invalidation and its mandatory recovery audit are one transaction.
|
|
106
|
+
# Notification hooks run after commit, never inside a retryable state block.
|
|
94
107
|
def perform_emergency_password_reset(security_event, reason)
|
|
95
108
|
config = Beskar.configuration.emergency_password_reset
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# Update password
|
|
109
|
+
return false unless config[:enabled]
|
|
110
|
+
return false unless Services::RequestContext.enforce?(security_event.ip_address)
|
|
111
|
+
|
|
112
|
+
self.class.transaction(requires_new: true) do
|
|
113
|
+
new_password = SecureRandom.base58(32)
|
|
102
114
|
update!(password: new_password, password_confirmation: new_password)
|
|
103
|
-
|
|
104
|
-
|
|
115
|
+
Services::NativeAccountLock.require_manual_unlock!(self) if config[:require_manual_unlock]
|
|
116
|
+
revoke_beskar_sessions!
|
|
105
117
|
security_events.create!(
|
|
106
|
-
event_type: "emergency_password_reset",
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
triggering_event_id: security_event.id,
|
|
112
|
-
timestamp: Time.current.iso8601,
|
|
113
|
-
reset_method: "automatic"
|
|
114
|
-
},
|
|
115
|
-
risk_score: 100
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
# Send notification to user
|
|
119
|
-
if config[:send_notification]
|
|
120
|
-
send_emergency_reset_notification(reason)
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# Notify security team
|
|
124
|
-
if config[:notify_security_team]
|
|
125
|
-
notify_security_team_of_reset(reason, security_event)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
Rails.logger.warn "[Beskar] Emergency password reset performed for user #{id}, reason: #{reason}"
|
|
129
|
-
|
|
130
|
-
rescue => e
|
|
131
|
-
Rails.logger.error "[Beskar] Failed to perform emergency password reset: #{e.message}"
|
|
132
|
-
|
|
133
|
-
# Create failed reset event
|
|
134
|
-
security_events.create!(
|
|
135
|
-
event_type: "emergency_password_reset_failed",
|
|
136
|
-
ip_address: security_event.ip_address,
|
|
137
|
-
user_agent: security_event.user_agent,
|
|
138
|
-
metadata: {
|
|
139
|
-
reason: reason.to_s,
|
|
140
|
-
error: e.message,
|
|
141
|
-
timestamp: Time.current.iso8601
|
|
142
|
-
},
|
|
143
|
-
risk_score: 100
|
|
118
|
+
event_type: "emergency_password_reset", ip_address: security_event.ip_address,
|
|
119
|
+
user_agent: security_event.user_agent, risk_score: 100,
|
|
120
|
+
metadata: {reason: reason.to_s, triggering_event_id: security_event.id,
|
|
121
|
+
authentication_attempt_id: security_event.beskar_attempt&.id,
|
|
122
|
+
timestamp: Time.current.iso8601, reset_method: "automatic"}
|
|
144
123
|
)
|
|
145
124
|
end
|
|
125
|
+
# Keep the extension hooks independent: a failed user hook must not
|
|
126
|
+
# suppress the security-team notification or undo the committed reset.
|
|
127
|
+
Services::Notifications.after_commit { send_emergency_reset_notification(reason) } if config[:send_notification]
|
|
128
|
+
Services::Notifications.after_commit { notify_security_team_of_reset(reason, security_event) } if config[:notify_security_team]
|
|
129
|
+
true
|
|
130
|
+
rescue => error
|
|
131
|
+
Beskar::Logger.error("Emergency password reset failed (#{error.class})")
|
|
132
|
+
false
|
|
146
133
|
end
|
|
147
134
|
|
|
148
135
|
# Send notification to user about emergency password reset
|
|
149
136
|
def send_emergency_reset_notification(reason)
|
|
150
|
-
|
|
151
|
-
# Example: UserMailer.emergency_password_reset(self, reason).deliver_later
|
|
152
|
-
Rails.logger.info "[Beskar] Would send emergency reset notification to user #{id}"
|
|
153
|
-
rescue => e
|
|
154
|
-
Rails.logger.error "[Beskar] Failed to send emergency reset notification: #{e.message}"
|
|
137
|
+
Services::Notifications.enqueue(self, "emergency_password_reset")
|
|
155
138
|
end
|
|
156
139
|
|
|
157
140
|
# Notify security team about emergency password reset
|
|
158
141
|
def notify_security_team_of_reset(reason, security_event)
|
|
159
|
-
|
|
160
|
-
# Example: SecurityMailer.emergency_reset_alert(self, reason, security_event).deliver_later
|
|
161
|
-
Rails.logger.info "[Beskar] Would notify security team about reset for user #{id}"
|
|
162
|
-
rescue => e
|
|
163
|
-
Rails.logger.error "[Beskar] Failed to notify security team: #{e.message}"
|
|
142
|
+
Services::Notifications.enqueue(self, "security_team_reset")
|
|
164
143
|
end
|
|
165
144
|
end
|
|
166
145
|
end
|
|
@@ -9,11 +9,21 @@ module Beskar
|
|
|
9
9
|
included do
|
|
10
10
|
# Include the generic functionality first
|
|
11
11
|
include Beskar::Models::SecurityTrackableGeneric
|
|
12
|
+
prepend SessionCredentials
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
after_update :revoke_beskar_sessions_after_lock
|
|
15
|
+
|
|
16
|
+
# The engine registers the single Warden outcome callback. Devise's
|
|
17
|
+
# after_database_authentication is an instance hook, not a callback macro.
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module SessionCredentials
|
|
21
|
+
def authenticatable_salt
|
|
22
|
+
Digest::SHA256.hexdigest([super, beskar_session_token].to_json)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rememberable_value
|
|
26
|
+
Digest::SHA256.hexdigest([super, beskar_session_token].to_json)
|
|
17
27
|
end
|
|
18
28
|
end
|
|
19
29
|
|
|
@@ -21,38 +31,38 @@ module Beskar
|
|
|
21
31
|
def track_successful_login
|
|
22
32
|
# Skip tracking if disabled in configuration
|
|
23
33
|
unless Beskar.configuration.track_successful_logins?
|
|
24
|
-
|
|
34
|
+
Beskar::Logger.debug("Successful login tracking disabled in configuration")
|
|
25
35
|
return
|
|
26
36
|
end
|
|
27
37
|
|
|
28
|
-
if current_request = request_from_context
|
|
38
|
+
if (current_request = request_from_context)
|
|
29
39
|
track_authentication_event(current_request, :success)
|
|
30
40
|
end
|
|
31
41
|
rescue => e
|
|
32
|
-
|
|
42
|
+
Beskar::Logger.warn("Failed to track successful login: #{e.class}")
|
|
33
43
|
nil
|
|
34
44
|
end
|
|
35
45
|
|
|
36
46
|
# PUBLIC method called from Warden callback in engine.rb
|
|
37
47
|
# Checks if account was just locked due to high risk and signs out if needed
|
|
38
|
-
def check_high_risk_lock_and_signout(auth)
|
|
48
|
+
def check_high_risk_lock_and_signout(auth, scope: nil, attempt: nil)
|
|
39
49
|
return unless Beskar.configuration.risk_based_locking_enabled?
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if recent_lock
|
|
48
|
-
Rails.logger.warn "[Beskar] High-risk lock detected, signing out user #{id}"
|
|
49
|
-
auth.logout
|
|
50
|
-
throw :warden, message: :account_locked_due_to_high_risk
|
|
51
|
-
end
|
|
50
|
+
return unless scope
|
|
51
|
+
attempt ||= Services::AuthenticationAttempt.current(auth.request, scope) if auth.respond_to?(:request)
|
|
52
|
+
return unless attempt && attempt.user == self && attempt.scope == scope.to_s && attempt.locked_now
|
|
53
|
+
return unless Services::RequestContext.enforce?(attempt.ip_address)
|
|
54
|
+
auth.logout(scope)
|
|
55
|
+
throw :warden, scope: scope, message: :account_locked_due_to_high_risk
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
private
|
|
55
59
|
|
|
60
|
+
# Runs in the user save transaction, including Devise's own failed-password
|
|
61
|
+
# lock and ordinary updates of locked_at, not just Beskar risk-based locks.
|
|
62
|
+
def revoke_beskar_sessions_after_lock
|
|
63
|
+
revoke_beskar_sessions! if has_attribute?(:locked_at) && saved_change_to_locked_at? && locked_at.present?
|
|
64
|
+
end
|
|
65
|
+
|
|
56
66
|
# Devise-specific: Try to get request from various Warden/Devise contexts
|
|
57
67
|
def request_from_context
|
|
58
68
|
# Try to get request from various contexts
|
|
@@ -66,16 +76,15 @@ module Beskar
|
|
|
66
76
|
Warden::Manager.current_request
|
|
67
77
|
end
|
|
68
78
|
rescue => e
|
|
69
|
-
|
|
79
|
+
Beskar::Logger.debug("Could not get request from context: #{e.class}")
|
|
70
80
|
nil
|
|
71
81
|
end
|
|
72
82
|
|
|
73
|
-
# Devise-specific:
|
|
83
|
+
# Devise-specific: The current attempt carries the completed lock result.
|
|
74
84
|
# The actual sign-out is handled by Warden callback in engine.rb
|
|
75
85
|
def handle_high_risk_lock(security_event, request)
|
|
76
|
-
|
|
77
|
-
# The
|
|
78
|
-
# The Warden callback will detect it and perform the actual sign-out
|
|
86
|
+
Beskar::Logger.debug("Devise account locked - Warden callback will handle sign-out")
|
|
87
|
+
# The Warden callback uses the attempt, independently of audit writes.
|
|
79
88
|
end
|
|
80
89
|
end
|
|
81
90
|
end
|