beskar 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +274 -0
  3. data/README.md +412 -204
  4. data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
  5. data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
  6. data/app/controllers/beskar/application_controller.rb +214 -0
  7. data/app/controllers/beskar/banned_ips_controller.rb +255 -0
  8. data/app/controllers/beskar/dashboard_controller.rb +62 -0
  9. data/app/controllers/beskar/security_events_controller.rb +164 -0
  10. data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
  11. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
  12. data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
  13. data/app/jobs/beskar/notification_job.rb +33 -0
  14. data/app/mailers/beskar/security_mailer.rb +59 -0
  15. data/app/models/beskar/administrative_action.rb +41 -0
  16. data/app/models/beskar/banned_ip.rb +105 -105
  17. data/app/models/beskar/security_event.rb +51 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +88 -0
  20. data/app/views/beskar/administrative_actions/index.html.erb +33 -0
  21. data/app/views/beskar/administrative_actions/show.html.erb +21 -0
  22. data/app/views/beskar/banned_ips/edit.html.erb +195 -0
  23. data/app/views/beskar/banned_ips/index.html.erb +319 -0
  24. data/app/views/beskar/banned_ips/new.html.erb +190 -0
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +304 -0
  27. data/app/views/beskar/dashboard/index.html.erb +280 -0
  28. data/app/views/beskar/security_events/index.html.erb +302 -0
  29. data/app/views/beskar/security_events/show.html.erb +293 -0
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  32. data/app/views/layouts/beskar/application.html.erb +581 -6
  33. data/config/routes.rb +30 -0
  34. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  35. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  36. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  37. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  38. data/docs/README.md +73 -0
  39. data/docs/archive/project-documentation.md +659 -0
  40. data/docs/audits/project-review.md +437 -0
  41. data/docs/audits/repair-status.md +216 -0
  42. data/docs/guides/audit-and-waf.md +175 -0
  43. data/docs/guides/audit-lifecycle.md +172 -0
  44. data/docs/guides/authentication.md +213 -0
  45. data/docs/guides/configuration.md +182 -0
  46. data/docs/guides/dashboard-and-search.md +251 -0
  47. data/docs/guides/notifications-and-recovery.md +157 -0
  48. data/docs/guides/risk-scoring.md +116 -0
  49. data/docs/operations/monitor-only-mode.md +85 -0
  50. data/docs/operations/security-hardening.md +167 -0
  51. data/docs/operations/state-storage.md +144 -0
  52. data/docs/research/rust-performance-assessment.md +69 -0
  53. data/lib/beskar/configuration.rb +105 -20
  54. data/lib/beskar/configuration_validator.rb +188 -0
  55. data/lib/beskar/devise_authentication.rb +24 -0
  56. data/lib/beskar/engine.rb +21 -88
  57. data/lib/beskar/logger.rb +288 -0
  58. data/lib/beskar/middleware/request_analyzer.rb +133 -99
  59. data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
  60. data/lib/beskar/models/security_trackable_devise.rb +34 -25
  61. data/lib/beskar/models/security_trackable_generic.rb +171 -214
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +90 -81
  64. data/lib/beskar/services/administrative_audit.rb +36 -0
  65. data/lib/beskar/services/administrative_bans.rb +104 -0
  66. data/lib/beskar/services/audit_data.rb +72 -0
  67. data/lib/beskar/services/authentication.rb +31 -0
  68. data/lib/beskar/services/authentication_attempt.rb +141 -0
  69. data/lib/beskar/services/ban_expiry.rb +28 -0
  70. data/lib/beskar/services/device_detector.rb +32 -41
  71. data/lib/beskar/services/event_search.rb +58 -0
  72. data/lib/beskar/services/geolocation_service.rb +83 -114
  73. data/lib/beskar/services/ip_whitelist.rb +31 -40
  74. data/lib/beskar/services/location_assessment.rb +109 -0
  75. data/lib/beskar/services/native_account_lock.rb +82 -0
  76. data/lib/beskar/services/notifications.rb +46 -0
  77. data/lib/beskar/services/rate_limiter.rb +99 -125
  78. data/lib/beskar/services/request_context.rb +64 -0
  79. data/lib/beskar/services/risk_assessment.rb +58 -0
  80. data/lib/beskar/services/session_revocation.rb +62 -0
  81. data/lib/beskar/services/waf.rb +311 -198
  82. data/lib/beskar/services/waf_request.rb +60 -0
  83. data/lib/beskar/version.rb +1 -1
  84. data/lib/beskar/warden_authentication.rb +53 -0
  85. data/lib/beskar.rb +54 -4
  86. data/lib/generators/beskar/install/install_generator.rb +158 -0
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
  88. data/lib/tasks/beskar_tasks.rake +25 -20
  89. metadata +93 -12
  90. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -7,78 +7,122 @@ module Beskar
7
7
 
8
8
  def call(env)
9
9
  request = ActionDispatch::Request.new(env)
10
- ip_address = request.ip
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
- Rails.logger.warn "[Beskar::Middleware] Blocked request from banned IP: #{ip_address}"
18
- return blocked_response("Your IP address has been blocked due to suspicious activity.")
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
- if !is_whitelisted && rate_limited?(request)
23
- Rails.logger.warn "[Beskar::Middleware] Rate limit exceeded for IP: #{ip_address}"
24
-
25
- # Auto-block after excessive rate limiting violations
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: 'rate_limit_abuse',
35
+ reason: "rate_limit_abuse",
30
36
  duration: 1.hour,
31
- details: 'Excessive rate limit violations'
37
+ details: "Excessive rate limit violations"
32
38
  )
33
39
  end
34
-
35
- return rate_limit_response
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
- violation_count = Beskar::Services::Waf.record_violation(ip_address, waf_analysis, whitelisted: is_whitelisted)
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
- Rails.logger.info(
50
- "[Beskar::Middleware] WAF violation from whitelisted IP #{ip_address} " \
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
- if Beskar.configuration.waf_monitor_only?
58
- # Monitor-only mode: Just log, don't block
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
- Rails.logger.warn(
61
- "[Beskar::Middleware] 🔍 MONITOR-ONLY: Would block IP #{ip_address} " \
62
- "after #{violation_count} WAF violations, but monitor_only=true. " \
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
- Rails.logger.warn(
69
- "[Beskar::Middleware] 🔒 Blocking IP #{ip_address} " \
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
- # Check how many times this IP has been rate limited in the past hour
103
- cache_key = "beskar:rate_limit_violations:#{ip_address}"
104
- violations = Rails.cache.read(cache_key) || 0
105
- violations += 1
106
- Rails.cache.write(cache_key, violations, expires_in: 1.hour)
107
-
108
- # Block after 5 rate limit violations in an hour
109
- violations >= 5
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 authentication_brute_force?(ip_address)
113
- # Check authentication failure count from RateLimiter
114
- cache_key = "beskar:ip_auth_failures:#{ip_address}"
115
-
116
- # Get current failure count and timestamp
117
- failure_data = Rails.cache.read(cache_key)
118
- return false unless failure_data.is_a?(Hash)
119
-
120
- # Count recent failures (within the configured period)
121
- config = Beskar.configuration.rate_limiting[:ip_attempts] || {}
122
- period = config[:period] || 1.hour
123
- limit = config[:limit] || 10
124
-
125
- now = Time.current.to_i
126
- recent_failures = failure_data.select { |timestamp, _| now - timestamp.to_i < period.to_i }
127
-
128
- # If too many auth failures, it's brute force
129
- if recent_failures.length >= limit
130
- # Auto-ban for authentication abuse
131
- Beskar::BannedIp.ban!(
132
- ip_address,
133
- reason: 'authentication_abuse',
134
- duration: 1.hour,
135
- details: "#{recent_failures.length} failed authentication attempts in #{period / 60} minutes",
136
- metadata: { failure_count: recent_failures.length, detection_time: Time.current }
137
- )
138
-
139
- Rails.logger.warn(
140
- "[Beskar::Middleware] 🔒 Auto-blocked IP #{ip_address} " \
141
- "for authentication brute force (#{recent_failures.length} failures)"
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
- Rails.logger.info(
159
- "[Beskar::Middleware] 404 on suspicious path from #{request.ip}: #{path} " \
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
- "Content-Type" => "text/html",
170
- "X-Beskar-Blocked" => "true"
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
- "Content-Type" => "text/html",
181
- "Retry-After" => "3600",
182
- "X-Beskar-Rate-Limited" => "true"
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
- Rails.logger.warn "[Beskar] 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
-
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
- Rails.logger.info "[Beskar] Destroyed #{sessions.count} sessions except current"
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
- Rails.logger.info "[Beskar] Destroyed all #{count} sessions"
61
+ Beskar::Logger.info("Destroyed all #{count} sessions")
48
62
  end
49
63
  else
50
- Rails.logger.warn "[Beskar] Model does not have sessions association, cannot destroy sessions"
64
+ Beskar::Logger.warn("Model does not have sessions association, cannot destroy sessions")
51
65
  end
52
66
  rescue => e
53
- Rails.logger.error "[Beskar] Failed to destroy sessions: #{e.message}"
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
- 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)
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
- # Perform emergency password reset
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
- # Generate a cryptographically secure random password
98
- new_password = SecureRandom.base58(32)
99
-
100
- begin
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
- # Log the reset event
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
- 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
- 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
- # This should be implemented by the application
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
- # This should be implemented by the application
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
- # 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
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
- Rails.logger.debug "[Beskar] Successful login tracking disabled in configuration"
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
- Rails.logger.warn "[Beskar] Failed to track successful login: #{e.message}"
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
- # 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
- 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
- Rails.logger.debug "[Beskar] Could not get request from context: #{e.message}"
79
+ Beskar::Logger.debug("Could not get request from context: #{e.class}")
70
80
  nil
71
81
  end
72
82
 
73
- # Devise-specific: Handle high risk lock by creating lock event
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
- Rails.logger.debug "[Beskar] 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
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