beskar 0.0.1 → 0.1.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 +143 -0
- data/README.md +987 -21
- data/app/controllers/beskar/application_controller.rb +170 -0
- data/app/controllers/beskar/banned_ips_controller.rb +280 -0
- data/app/controllers/beskar/dashboard_controller.rb +70 -0
- data/app/controllers/beskar/security_events_controller.rb +182 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +70 -0
- data/app/models/beskar/banned_ip.rb +193 -0
- data/app/models/beskar/security_event.rb +64 -0
- data/app/services/beskar/banned_ip_manager.rb +78 -0
- data/app/views/beskar/banned_ips/edit.html.erb +259 -0
- data/app/views/beskar/banned_ips/index.html.erb +361 -0
- data/app/views/beskar/banned_ips/new.html.erb +310 -0
- data/app/views/beskar/banned_ips/show.html.erb +310 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +309 -0
- data/app/views/beskar/security_events/show.html.erb +307 -0
- data/app/views/layouts/beskar/application.html.erb +647 -5
- data/config/locales/en.yml +10 -0
- data/config/routes.rb +41 -0
- data/db/migrate/20251016000001_create_beskar_security_events.rb +25 -0
- data/db/migrate/20251016000002_create_beskar_banned_ips.rb +23 -0
- data/lib/beskar/configuration.rb +214 -0
- data/lib/beskar/engine.rb +105 -0
- data/lib/beskar/logger.rb +293 -0
- data/lib/beskar/middleware/request_analyzer.rb +305 -0
- data/lib/beskar/middleware.rb +4 -0
- data/lib/beskar/models/security_trackable.rb +25 -0
- data/lib/beskar/models/security_trackable_authenticable.rb +167 -0
- data/lib/beskar/models/security_trackable_devise.rb +82 -0
- data/lib/beskar/models/security_trackable_generic.rb +355 -0
- data/lib/beskar/services/account_locker.rb +263 -0
- data/lib/beskar/services/device_detector.rb +250 -0
- data/lib/beskar/services/geolocation_service.rb +392 -0
- data/lib/beskar/services/ip_whitelist.rb +113 -0
- data/lib/beskar/services/rate_limiter.rb +257 -0
- data/lib/beskar/services/waf.rb +551 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar.rb +32 -1
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
- data/lib/tasks/beskar_tasks.rake +121 -4
- metadata +138 -5
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Middleware
|
|
3
|
+
class RequestAnalyzer
|
|
4
|
+
def initialize(app)
|
|
5
|
+
@app = app
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(env)
|
|
9
|
+
request = ActionDispatch::Request.new(env)
|
|
10
|
+
ip_address = request.ip
|
|
11
|
+
|
|
12
|
+
Beskar::Logger.debug("[RequestAnalyzer] Processing request from IP: #{ip_address}, Path: #{request.path}", component: :Middleware)
|
|
13
|
+
|
|
14
|
+
# 1. Check if IP is whitelisted (whitelisted IPs skip blocking but still get logged)
|
|
15
|
+
is_whitelisted = Beskar::Services::IpWhitelist.whitelisted?(ip_address)
|
|
16
|
+
|
|
17
|
+
# 2. Check if IP is banned (early exit for blocked IPs, unless whitelisted or in monitor-only mode)
|
|
18
|
+
if !is_whitelisted && Beskar::BannedIp.banned?(ip_address)
|
|
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
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 3. Check rate limiting (unless whitelisted)
|
|
28
|
+
if !is_whitelisted && rate_limited?(request)
|
|
29
|
+
# Auto-block after excessive rate limiting violations (even in monitor-only mode - we create the ban record)
|
|
30
|
+
if should_auto_block_rate_limit?(ip_address)
|
|
31
|
+
Beskar::BannedIp.ban!(
|
|
32
|
+
ip_address,
|
|
33
|
+
reason: "rate_limit_abuse",
|
|
34
|
+
duration: 1.hour,
|
|
35
|
+
details: "Excessive rate limit violations"
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if Beskar.configuration.monitor_only?
|
|
40
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block rate limit exceeded for IP: #{ip_address}, but monitor_only=true. Request proceeding normally.", component: :Middleware)
|
|
41
|
+
else
|
|
42
|
+
Beskar::Logger.warn("Rate limit exceeded for IP: #{ip_address}", component: :Middleware)
|
|
43
|
+
return rate_limit_response
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 4. Check WAF patterns (vulnerability scans)
|
|
48
|
+
if Beskar.configuration.waf_enabled?
|
|
49
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF enabled, analyzing request", component: :Middleware)
|
|
50
|
+
waf_analysis = Beskar::Services::Waf.analyze_request(request)
|
|
51
|
+
|
|
52
|
+
if waf_analysis
|
|
53
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF detected threat: #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")}", component: :Middleware)
|
|
54
|
+
# Log the violation (and create security event if configured)
|
|
55
|
+
# Pass whitelist status to prevent auto-blocking whitelisted IPs
|
|
56
|
+
current_score = Beskar::Services::Waf.record_violation(ip_address, waf_analysis, whitelisted: is_whitelisted)
|
|
57
|
+
Beskar::Logger.debug("[RequestAnalyzer] Current score after recording: #{current_score.round(2)}", component: :Middleware)
|
|
58
|
+
|
|
59
|
+
# Log even for whitelisted IPs (but don't block)
|
|
60
|
+
if is_whitelisted
|
|
61
|
+
Beskar::Logger.info("WAF violation from whitelisted IP #{ip_address} " \
|
|
62
|
+
"(not blocking): #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")}", component: :Middleware)
|
|
63
|
+
else
|
|
64
|
+
# Check if we should block
|
|
65
|
+
should_block = Beskar::Services::Waf.should_block?(ip_address)
|
|
66
|
+
Beskar::Logger.debug("[RequestAnalyzer] Should block IP #{ip_address}?: #{should_block}", component: :Middleware)
|
|
67
|
+
|
|
68
|
+
if Beskar.configuration.monitor_only?
|
|
69
|
+
# Monitor-only mode: Just log, don't block (but ban record was created by WAF.record_violation)
|
|
70
|
+
if should_block
|
|
71
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block IP #{ip_address} " \
|
|
72
|
+
"with score #{current_score.round(2)}, but monitor_only=true. " \
|
|
73
|
+
"Request proceeding normally.", component: :Middleware)
|
|
74
|
+
end
|
|
75
|
+
elsif should_block && !Beskar.configuration.monitor_only?
|
|
76
|
+
# Actually block the request (not in monitor-only mode)
|
|
77
|
+
Beskar::Logger.warn("🔒 Blocking IP #{ip_address} " \
|
|
78
|
+
"with WAF score #{current_score.round(2)}", component: :Middleware)
|
|
79
|
+
# Block already handled by WAF.record_violation auto-block logic
|
|
80
|
+
# But we return 403 immediately
|
|
81
|
+
return blocked_response("Access denied due to suspicious activity.")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
Beskar::Logger.debug("[RequestAnalyzer] No WAF threat detected for path: #{request.path}", component: :Middleware)
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF is disabled", component: :Middleware)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 5. Process the request normally (will raise 404 if route not found)
|
|
92
|
+
Beskar::Logger.debug("[RequestAnalyzer] Passing request to application", component: :Middleware)
|
|
93
|
+
@app.call(env)
|
|
94
|
+
rescue ActionController::UnknownFormat => e
|
|
95
|
+
# Analyze unknown format as potential scanner
|
|
96
|
+
if Beskar.configuration.waf_enabled?
|
|
97
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
98
|
+
end
|
|
99
|
+
# Re-raise to allow normal error handling
|
|
100
|
+
raise
|
|
101
|
+
rescue ActionDispatch::RemoteIp::IpSpoofAttackError => e
|
|
102
|
+
# Handle IP spoofing attack
|
|
103
|
+
if Beskar.configuration.waf_enabled?
|
|
104
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
105
|
+
end
|
|
106
|
+
# Re-raise to allow normal error handling
|
|
107
|
+
raise
|
|
108
|
+
rescue ActiveRecord::RecordNotFound => e
|
|
109
|
+
# Analyze record not found as potential enumeration scan
|
|
110
|
+
if Beskar.configuration.waf_enabled?
|
|
111
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
112
|
+
end
|
|
113
|
+
# Re-raise to allow normal error handling
|
|
114
|
+
raise
|
|
115
|
+
rescue ActionDispatch::Http::MimeNegotiation::InvalidType => e
|
|
116
|
+
# Analyze invalid MIME type as potential scanner
|
|
117
|
+
if Beskar.configuration.waf_enabled?
|
|
118
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
119
|
+
end
|
|
120
|
+
# Re-raise to allow normal error handling
|
|
121
|
+
raise
|
|
122
|
+
rescue ActionController::RoutingError => e
|
|
123
|
+
# If WAF is enabled, log 404s as potential scanning attempts
|
|
124
|
+
if Beskar.configuration.waf_enabled?
|
|
125
|
+
log_404_for_waf(request, e)
|
|
126
|
+
end
|
|
127
|
+
# Re-raise to allow normal 404 handling
|
|
128
|
+
raise
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def rate_limited?(request)
|
|
134
|
+
# Check both IP rate limit and authentication abuse
|
|
135
|
+
ip_check = Beskar::Services::RateLimiter.check_ip_rate_limit(request.ip)
|
|
136
|
+
auth_abused = authentication_brute_force?(request.ip)
|
|
137
|
+
|
|
138
|
+
!ip_check[:allowed] || auth_abused
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def should_auto_block_rate_limit?(ip_address)
|
|
142
|
+
# Check how many times this IP has been rate limited in the past hour
|
|
143
|
+
cache_key = "beskar:rate_limit_violations:#{ip_address}"
|
|
144
|
+
violations = Rails.cache.read(cache_key) || 0
|
|
145
|
+
violations += 1
|
|
146
|
+
Rails.cache.write(cache_key, violations, expires_in: 1.hour)
|
|
147
|
+
|
|
148
|
+
# Block after 5 rate limit violations in an hour
|
|
149
|
+
violations >= 5
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def authentication_brute_force?(ip_address)
|
|
153
|
+
# Check authentication failure count from RateLimiter
|
|
154
|
+
cache_key = "beskar:ip_auth_failures:#{ip_address}"
|
|
155
|
+
|
|
156
|
+
# Get current failure count and timestamp
|
|
157
|
+
failure_data = Rails.cache.read(cache_key)
|
|
158
|
+
return false unless failure_data.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
# Count recent failures (within the configured period)
|
|
161
|
+
config = Beskar.configuration.rate_limiting[:ip_attempts] || {}
|
|
162
|
+
period = config[:period] || 1.hour
|
|
163
|
+
limit = config[:limit] || 10
|
|
164
|
+
|
|
165
|
+
now = Time.current.to_i
|
|
166
|
+
recent_failures = failure_data.select { |timestamp, _| now - timestamp.to_i < period.to_i }
|
|
167
|
+
|
|
168
|
+
# If too many auth failures, it's brute force
|
|
169
|
+
if recent_failures.length >= limit
|
|
170
|
+
# Auto-ban for authentication abuse (create ban record even in monitor-only mode)
|
|
171
|
+
Beskar::BannedIp.ban!(
|
|
172
|
+
ip_address,
|
|
173
|
+
reason: "authentication_abuse",
|
|
174
|
+
duration: 1.hour,
|
|
175
|
+
details: "#{recent_failures.length} failed authentication attempts in #{period / 60} minutes",
|
|
176
|
+
metadata: {failure_count: recent_failures.length, detection_time: Time.current}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if Beskar.configuration.monitor_only?
|
|
180
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would auto-block IP #{ip_address} " \
|
|
181
|
+
"for authentication brute force (#{recent_failures.length} failures), but monitor_only=true", component: :Middleware)
|
|
182
|
+
else
|
|
183
|
+
Beskar::Logger.warn("🔒 Auto-blocked IP #{ip_address} " \
|
|
184
|
+
"for authentication brute force (#{recent_failures.length} failures)", component: :Middleware)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
return true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
false
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def handle_rails_exception(request, exception, ip_address, is_whitelisted)
|
|
194
|
+
# Analyze the exception using WAF
|
|
195
|
+
waf_analysis = Beskar::Services::Waf.analyze_exception(exception, request)
|
|
196
|
+
|
|
197
|
+
if waf_analysis
|
|
198
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF detected threat from exception: #{exception.class.name}", component: :Middleware)
|
|
199
|
+
|
|
200
|
+
# Record the violation (similar to regular WAF violations)
|
|
201
|
+
current_score = Beskar::Services::Waf.record_violation(ip_address, waf_analysis, whitelisted: is_whitelisted)
|
|
202
|
+
|
|
203
|
+
# Log for whitelisted IPs
|
|
204
|
+
if is_whitelisted
|
|
205
|
+
Beskar::Logger.info("Exception-based WAF violation from whitelisted IP #{ip_address} " \
|
|
206
|
+
"(not blocking): #{exception.class.name} - #{waf_analysis[:patterns].first[:description]}", component: :Middleware)
|
|
207
|
+
else
|
|
208
|
+
# Check if we should block
|
|
209
|
+
should_block = Beskar::Services::Waf.should_block?(ip_address)
|
|
210
|
+
|
|
211
|
+
if Beskar.configuration.monitor_only?
|
|
212
|
+
if should_block
|
|
213
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block IP #{ip_address} " \
|
|
214
|
+
"with score #{current_score.round(2)} (exception: #{exception.class.name}), " \
|
|
215
|
+
"but monitor_only=true. Request proceeding normally.", component: :Middleware)
|
|
216
|
+
end
|
|
217
|
+
elsif should_block && !Beskar.configuration.monitor_only?
|
|
218
|
+
Beskar::Logger.warn("🔒 Blocking IP #{ip_address} " \
|
|
219
|
+
"with score #{current_score.round(2)} (exception: #{exception.class.name})", component: :Middleware)
|
|
220
|
+
# Note: We don't return blocked response here as exception is already raised
|
|
221
|
+
# The ban record is created by WAF.record_violation
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def log_404_for_waf(request, error)
|
|
228
|
+
# 404s on suspicious paths might indicate scanning
|
|
229
|
+
path = request.fullpath || request.path
|
|
230
|
+
|
|
231
|
+
# Only log if it matches WAF patterns (already analyzed in analyze_request)
|
|
232
|
+
waf_analysis = Beskar::Services::Waf.analyze_request(request)
|
|
233
|
+
|
|
234
|
+
if waf_analysis
|
|
235
|
+
Beskar::Logger.info("404 on suspicious path from #{request.ip}: #{path} " \
|
|
236
|
+
"(WAF patterns: #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")})", component: :Middleware)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def blocked_response(message = "Forbidden")
|
|
241
|
+
[
|
|
242
|
+
403,
|
|
243
|
+
{
|
|
244
|
+
"Content-Type" => "text/html",
|
|
245
|
+
"X-Beskar-Blocked" => "true"
|
|
246
|
+
},
|
|
247
|
+
[render_blocked_page(message)]
|
|
248
|
+
]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def rate_limit_response
|
|
252
|
+
[
|
|
253
|
+
429,
|
|
254
|
+
{
|
|
255
|
+
"Content-Type" => "text/html",
|
|
256
|
+
"Retry-After" => "3600",
|
|
257
|
+
"X-Beskar-Rate-Limited" => "true"
|
|
258
|
+
},
|
|
259
|
+
[render_rate_limit_page]
|
|
260
|
+
]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def render_blocked_page(message)
|
|
264
|
+
<<~HTML
|
|
265
|
+
<!DOCTYPE html>
|
|
266
|
+
<html>
|
|
267
|
+
<head>
|
|
268
|
+
<title>Access Denied</title>
|
|
269
|
+
<style>
|
|
270
|
+
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
|
271
|
+
h1 { color: #d32f2f; }
|
|
272
|
+
p { color: #666; }
|
|
273
|
+
</style>
|
|
274
|
+
</head>
|
|
275
|
+
<body>
|
|
276
|
+
<h1>Access Denied</h1>
|
|
277
|
+
<p>#{message}</p>
|
|
278
|
+
<p>If you believe this is an error, please contact the site administrator.</p>
|
|
279
|
+
</body>
|
|
280
|
+
</html>
|
|
281
|
+
HTML
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def render_rate_limit_page
|
|
285
|
+
<<~HTML
|
|
286
|
+
<!DOCTYPE html>
|
|
287
|
+
<html>
|
|
288
|
+
<head>
|
|
289
|
+
<title>Too Many Requests</title>
|
|
290
|
+
<style>
|
|
291
|
+
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
|
292
|
+
h1 { color: #ff9800; }
|
|
293
|
+
p { color: #666; }
|
|
294
|
+
</style>
|
|
295
|
+
</head>
|
|
296
|
+
<body>
|
|
297
|
+
<h1>Too Many Requests</h1>
|
|
298
|
+
<p>You have exceeded the rate limit. Please try again later.</p>
|
|
299
|
+
</body>
|
|
300
|
+
</html>
|
|
301
|
+
HTML
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Models
|
|
3
|
+
# Main SecurityTrackable module - delegates to Devise-specific implementation
|
|
4
|
+
# for backward compatibility with existing Devise integrations
|
|
5
|
+
#
|
|
6
|
+
# This is now a thin wrapper around the modular implementation:
|
|
7
|
+
# - SecurityTrackableGeneric: Shared functionality for all auth systems
|
|
8
|
+
# - SecurityTrackableDevise: Devise/Warden specific (included by default here)
|
|
9
|
+
# - SecurityTrackableAuthenticable: Rails 8 has_secure_password specific
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# For Devise models:
|
|
13
|
+
# include Beskar::Models::SecurityTrackable # (this module)
|
|
14
|
+
# For Rails 8 auth models:
|
|
15
|
+
# include Beskar::Models::SecurityTrackableAuthenticable
|
|
16
|
+
module SecurityTrackable
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
# Include Devise-specific tracking by default for backward compatibility
|
|
21
|
+
include Beskar::Models::SecurityTrackableDevise
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Models
|
|
3
|
+
# Rails 8 authentication-specific security tracking functionality
|
|
4
|
+
# This module provides security tracking for models using has_secure_password
|
|
5
|
+
# and session-based authentication (Rails 8 built-in authentication)
|
|
6
|
+
module SecurityTrackableAuthenticable
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# Include the generic functionality first
|
|
11
|
+
include Beskar::Models::SecurityTrackableGeneric
|
|
12
|
+
|
|
13
|
+
# No automatic callbacks like Devise - tracking is done explicitly
|
|
14
|
+
# in controllers via the Beskar::Controllers::SecurityTracking concern
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Make handle_high_risk_lock public (it's private in Generic)
|
|
18
|
+
public
|
|
19
|
+
|
|
20
|
+
# Rails 8 auth-specific: Handle high risk lock by destroying sessions
|
|
21
|
+
# Public method called when high-risk event is detected
|
|
22
|
+
def handle_high_risk_lock(security_event, request)
|
|
23
|
+
reason = determine_lock_reason(security_event)
|
|
24
|
+
|
|
25
|
+
Beskar::Logger.warn("Rails auth high-risk lock detected: #{reason}")
|
|
26
|
+
|
|
27
|
+
# Destroy all sessions to immediately lock out attacker
|
|
28
|
+
destroy_all_sessions(except: request.session.id)
|
|
29
|
+
|
|
30
|
+
# Check if this warrants emergency password reset
|
|
31
|
+
if should_reset_password?(security_event, reason)
|
|
32
|
+
perform_emergency_password_reset(security_event, reason)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Destroy all user sessions (for impossible travel / high-risk scenarios)
|
|
37
|
+
def destroy_all_sessions(except: nil)
|
|
38
|
+
if respond_to?(:sessions) && sessions.respond_to?(:destroy_all)
|
|
39
|
+
if except
|
|
40
|
+
# Keep current session but destroy all others
|
|
41
|
+
sessions.where.not(id: except).destroy_all
|
|
42
|
+
Beskar::Logger.info("Destroyed #{sessions.count} sessions except current")
|
|
43
|
+
else
|
|
44
|
+
# Destroy ALL sessions including current
|
|
45
|
+
count = sessions.count
|
|
46
|
+
sessions.destroy_all
|
|
47
|
+
Beskar::Logger.info("Destroyed all #{count} sessions")
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
Beskar::Logger.warn("Model does not have sessions association, cannot destroy sessions")
|
|
51
|
+
end
|
|
52
|
+
rescue => e
|
|
53
|
+
Beskar::Logger.error("Failed to destroy sessions: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Determine if emergency password reset is warranted
|
|
57
|
+
def should_reset_password?(security_event, reason)
|
|
58
|
+
config = Beskar.configuration.emergency_password_reset
|
|
59
|
+
return false unless config[:enabled]
|
|
60
|
+
|
|
61
|
+
case reason
|
|
62
|
+
when :impossible_travel
|
|
63
|
+
# Count impossible travel events in recent history
|
|
64
|
+
recent_impossible_travel = security_events
|
|
65
|
+
.where(event_type: ["account_locked", "login_success"])
|
|
66
|
+
.where("created_at >= ?", 24.hours.ago)
|
|
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)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Perform emergency password reset
|
|
94
|
+
def perform_emergency_password_reset(security_event, reason)
|
|
95
|
+
config = Beskar.configuration.emergency_password_reset
|
|
96
|
+
|
|
97
|
+
# Generate a cryptographically secure random password
|
|
98
|
+
new_password = SecureRandom.base58(32)
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
# Update password
|
|
102
|
+
update!(password: new_password, password_confirmation: new_password)
|
|
103
|
+
|
|
104
|
+
# Log the reset event
|
|
105
|
+
security_events.create!(
|
|
106
|
+
event_type: "emergency_password_reset",
|
|
107
|
+
ip_address: security_event.ip_address,
|
|
108
|
+
user_agent: security_event.user_agent,
|
|
109
|
+
metadata: {
|
|
110
|
+
reason: reason.to_s,
|
|
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
|
+
Beskar::Logger.warn("Emergency password reset performed for user #{id}, reason: #{reason}")
|
|
129
|
+
|
|
130
|
+
rescue => e
|
|
131
|
+
Beskar::Logger.error("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
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Send notification to user about emergency password reset
|
|
149
|
+
def send_emergency_reset_notification(reason)
|
|
150
|
+
# This should be implemented by the application
|
|
151
|
+
# Example: UserMailer.emergency_password_reset(self, reason).deliver_later
|
|
152
|
+
Beskar::Logger.info("Would send emergency reset notification to user #{id}")
|
|
153
|
+
rescue => e
|
|
154
|
+
Beskar::Logger.error("Failed to send emergency reset notification: #{e.message}")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Notify security team about emergency password reset
|
|
158
|
+
def notify_security_team_of_reset(reason, security_event)
|
|
159
|
+
# This should be implemented by the application
|
|
160
|
+
# Example: SecurityMailer.emergency_reset_alert(self, reason, security_event).deliver_later
|
|
161
|
+
Beskar::Logger.info("Would notify security team about reset for user #{id}")
|
|
162
|
+
rescue => e
|
|
163
|
+
Beskar::Logger.error("Failed to notify security team: #{e.message}")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Models
|
|
3
|
+
# Devise-specific security tracking functionality
|
|
4
|
+
# This module hooks into Devise/Warden callbacks and provides
|
|
5
|
+
# Devise-specific authentication tracking and account locking
|
|
6
|
+
module SecurityTrackableDevise
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# Include the generic functionality first
|
|
11
|
+
include Beskar::Models::SecurityTrackableGeneric
|
|
12
|
+
|
|
13
|
+
# Hook into Devise callbacks if Devise is present and available
|
|
14
|
+
if defined?(Devise) && respond_to?(:after_database_authentication)
|
|
15
|
+
# Track successful authentications
|
|
16
|
+
after_database_authentication :track_successful_login
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Track successful login via Devise callback
|
|
21
|
+
def track_successful_login
|
|
22
|
+
# Skip tracking if disabled in configuration
|
|
23
|
+
unless Beskar.configuration.track_successful_logins?
|
|
24
|
+
Beskar::Logger.debug("Successful login tracking disabled in configuration")
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if current_request = request_from_context
|
|
29
|
+
track_authentication_event(current_request, :success)
|
|
30
|
+
end
|
|
31
|
+
rescue => e
|
|
32
|
+
Beskar::Logger.warn("Failed to track successful login: #{e.message}")
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# PUBLIC method called from Warden callback in engine.rb
|
|
37
|
+
# Checks if account was just locked due to high risk and signs out if needed
|
|
38
|
+
def check_high_risk_lock_and_signout(auth)
|
|
39
|
+
return unless Beskar.configuration.risk_based_locking_enabled?
|
|
40
|
+
|
|
41
|
+
# Check if there's a very recent lock event (within last 5 seconds)
|
|
42
|
+
recent_lock = security_events
|
|
43
|
+
.where(event_type: ["account_locked", "lock_attempted"])
|
|
44
|
+
.where("created_at >= ?", 5.seconds.ago)
|
|
45
|
+
.exists?
|
|
46
|
+
|
|
47
|
+
if recent_lock
|
|
48
|
+
Beskar::Logger.warn("High-risk lock detected, signing out user #{id}")
|
|
49
|
+
auth.logout
|
|
50
|
+
throw :warden, message: :account_locked_due_to_high_risk
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Devise-specific: Try to get request from various Warden/Devise contexts
|
|
57
|
+
def request_from_context
|
|
58
|
+
# Try to get request from various contexts
|
|
59
|
+
if defined?(Current) && Current.respond_to?(:request)
|
|
60
|
+
Current.request
|
|
61
|
+
elsif Thread.current[:request]
|
|
62
|
+
Thread.current[:request]
|
|
63
|
+
elsif defined?(ActionController::Base) && ActionController::Base.respond_to?(:current_request)
|
|
64
|
+
ActionController::Base.current_request
|
|
65
|
+
elsif defined?(Warden) && Warden::Manager.respond_to?(:current_request)
|
|
66
|
+
Warden::Manager.current_request
|
|
67
|
+
end
|
|
68
|
+
rescue => e
|
|
69
|
+
Beskar::Logger.debug("Could not get request from context: #{e.message}")
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Devise-specific: Handle high risk lock by creating lock event
|
|
74
|
+
# The actual sign-out is handled by Warden callback in engine.rb
|
|
75
|
+
def handle_high_risk_lock(security_event, request)
|
|
76
|
+
Beskar::Logger.debug("Devise account locked - Warden callback will handle sign-out")
|
|
77
|
+
# The lock event is already created by AccountLocker service
|
|
78
|
+
# The Warden callback will detect it and perform the actual sign-out
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|