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,355 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Models
|
|
3
|
+
# Generic security tracking functionality shared by all authentication systems
|
|
4
|
+
# This module provides the core security event tracking, risk scoring,
|
|
5
|
+
# and adaptive learning features that work with any authentication framework
|
|
6
|
+
module SecurityTrackableGeneric
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
has_many :security_events, class_name: "Beskar::SecurityEvent", as: :user, dependent: :destroy
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module ClassMethods
|
|
14
|
+
# Track failed authentication attempt without a user context
|
|
15
|
+
# Used when authentication fails and we don't have a user object
|
|
16
|
+
def track_failed_authentication(request, scope)
|
|
17
|
+
# Skip tracking if disabled in configuration
|
|
18
|
+
unless Beskar.configuration.track_failed_logins?
|
|
19
|
+
Beskar::Logger.debug("Failed login tracking disabled in configuration")
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Extract attempted email from params based on scope
|
|
24
|
+
attempted_email = extract_attempted_email(request, scope)
|
|
25
|
+
|
|
26
|
+
# Create a security event for failed authentication
|
|
27
|
+
metadata = {
|
|
28
|
+
scope: scope.to_s,
|
|
29
|
+
attempted_email: attempted_email,
|
|
30
|
+
timestamp: Time.current.iso8601,
|
|
31
|
+
session_id: request.session.id,
|
|
32
|
+
request_path: request.path,
|
|
33
|
+
referer: request.referer,
|
|
34
|
+
accept_language: request.headers["Accept-Language"],
|
|
35
|
+
x_forwarded_for: request.headers["X-Forwarded-For"],
|
|
36
|
+
x_real_ip: request.headers["X-Real-IP"],
|
|
37
|
+
device_info: Beskar::Services::DeviceDetector.detect(request.user_agent),
|
|
38
|
+
geolocation: Beskar::Services::GeolocationService.locate(request.ip)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Beskar::SecurityEvent.create!(
|
|
42
|
+
user: nil,
|
|
43
|
+
event_type: "login_failure",
|
|
44
|
+
ip_address: request.ip,
|
|
45
|
+
user_agent: request.user_agent,
|
|
46
|
+
attempted_email: attempted_email,
|
|
47
|
+
metadata: metadata,
|
|
48
|
+
risk_score: calculate_failure_risk_score(request)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Trigger rate limiting check
|
|
52
|
+
Beskar::Services::RateLimiter.check_authentication_attempt(request, :failure)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def extract_attempted_email(request, scope)
|
|
58
|
+
# Try different param patterns based on scope
|
|
59
|
+
request.params.dig("devise_user", "email") ||
|
|
60
|
+
request.params.dig(scope.to_s, "email") ||
|
|
61
|
+
request.params.dig("user", "email") ||
|
|
62
|
+
request.params.dig("email_address") ||
|
|
63
|
+
request.params["email"]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def calculate_failure_risk_score(request)
|
|
67
|
+
score = 10 # Base score for failed login
|
|
68
|
+
|
|
69
|
+
# Use device detector for comprehensive risk assessment
|
|
70
|
+
device_detector = Beskar::Services::DeviceDetector.new
|
|
71
|
+
score += device_detector.calculate_user_agent_risk(request.user_agent)
|
|
72
|
+
|
|
73
|
+
# Additional failure-specific risk factors
|
|
74
|
+
password = request.params.dig("user", "password") ||
|
|
75
|
+
request.params.dig("devise_user", "password") ||
|
|
76
|
+
request.params["password"]
|
|
77
|
+
|
|
78
|
+
if password&.length.to_i > 50
|
|
79
|
+
score += 10
|
|
80
|
+
Beskar::Logger.info("Suspicious password length: #{password.length}, adding 10 risk")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Use geolocation service for location-based risk
|
|
84
|
+
geolocation_service = Beskar::Services::GeolocationService.new
|
|
85
|
+
geo_score = geolocation_service.calculate_location_risk(request.ip)
|
|
86
|
+
score += geo_score
|
|
87
|
+
Beskar::Logger.info("Geolocation risk: #{geo_score}")
|
|
88
|
+
|
|
89
|
+
[score, 100].min # Cap at 100
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Track authentication event (success or failure) for a specific user
|
|
94
|
+
def track_authentication_event(request, result)
|
|
95
|
+
return unless request
|
|
96
|
+
|
|
97
|
+
# Check if tracking is enabled for this event type
|
|
98
|
+
if result == :success && !Beskar.configuration.track_successful_logins?
|
|
99
|
+
Beskar::Logger.debug("Successful login tracking disabled in configuration")
|
|
100
|
+
return
|
|
101
|
+
elsif result == :failure && !Beskar.configuration.track_failed_logins?
|
|
102
|
+
Beskar::Logger.debug("Failed login tracking disabled in configuration")
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
event_type = result == :success ? "login_success" : "login_failure"
|
|
107
|
+
|
|
108
|
+
security_event = security_events.build(
|
|
109
|
+
event_type: event_type,
|
|
110
|
+
ip_address: request.ip,
|
|
111
|
+
user_agent: request.user_agent,
|
|
112
|
+
attempted_email: extract_user_email,
|
|
113
|
+
metadata: extract_security_context(request),
|
|
114
|
+
risk_score: calculate_risk_score(request, result)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if security_event.save
|
|
118
|
+
# Perform background security analysis
|
|
119
|
+
analyze_suspicious_patterns_async if result == :success && Beskar.configuration.auto_analyze_patterns?
|
|
120
|
+
|
|
121
|
+
# Update rate limiting
|
|
122
|
+
Beskar::Services::RateLimiter.check_authentication_attempt(request, result, self)
|
|
123
|
+
|
|
124
|
+
# Check risk-based locking after successful authentication
|
|
125
|
+
# This prevents compromised accounts from being used even after successful login
|
|
126
|
+
if result == :success
|
|
127
|
+
check_and_lock_if_high_risk(security_event, request)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
security_event
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def analyze_suspicious_patterns_async
|
|
135
|
+
# Skip analysis if disabled in configuration
|
|
136
|
+
unless Beskar.configuration.auto_analyze_patterns?
|
|
137
|
+
Beskar::Logger.debug("Auto pattern analysis disabled in configuration")
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Queue background job for detailed analysis
|
|
142
|
+
Beskar::SecurityAnalysisJob.perform_later(self.id, "login_success") if defined?(Beskar::SecurityAnalysisJob)
|
|
143
|
+
rescue => e
|
|
144
|
+
Beskar::Logger.warn("Failed to queue security analysis: #{e.message}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def recent_failed_attempts(within: 1.hour)
|
|
148
|
+
security_events.where(
|
|
149
|
+
event_type: "login_failure",
|
|
150
|
+
created_at: within.ago..Time.current
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def recent_successful_logins(within: 24.hours)
|
|
155
|
+
security_events.where(
|
|
156
|
+
event_type: "login_success",
|
|
157
|
+
created_at: within.ago..Time.current
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def suspicious_login_pattern?
|
|
162
|
+
# Check for rapid successive attempts
|
|
163
|
+
recent_attempts = recent_failed_attempts(within: 5.minutes)
|
|
164
|
+
return true if recent_attempts.count >= 3
|
|
165
|
+
|
|
166
|
+
# Check for geographic anomalies
|
|
167
|
+
recent_logins = recent_successful_logins(within: 4.hours).includes(:security_events)
|
|
168
|
+
return true if geographic_anomaly_detected?(recent_logins)
|
|
169
|
+
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def extract_user_email
|
|
176
|
+
# Try different email attribute names
|
|
177
|
+
respond_to?(:email) ? email : (respond_to?(:email_address) ? email_address : nil)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def extract_security_context(request)
|
|
181
|
+
{
|
|
182
|
+
timestamp: Time.current.iso8601,
|
|
183
|
+
session_id: request.session.id,
|
|
184
|
+
request_path: request.path,
|
|
185
|
+
referer: request.referer,
|
|
186
|
+
accept_language: request.headers["Accept-Language"],
|
|
187
|
+
x_forwarded_for: request.headers["X-Forwarded-For"],
|
|
188
|
+
x_real_ip: request.headers["X-Real-IP"],
|
|
189
|
+
device_info: Beskar::Services::DeviceDetector.detect(request.user_agent),
|
|
190
|
+
geolocation: Beskar::Services::GeolocationService.locate(request.ip)
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def calculate_risk_score(request, result)
|
|
195
|
+
base_score = result == :success ? 1 : 25
|
|
196
|
+
score = base_score
|
|
197
|
+
|
|
198
|
+
# Use dedicated services for risk assessment
|
|
199
|
+
device_detector = Beskar::Services::DeviceDetector.new
|
|
200
|
+
score += device_detector.calculate_user_agent_risk(request.user_agent)
|
|
201
|
+
|
|
202
|
+
# Mobile device login during late hours
|
|
203
|
+
if device_detector.mobile?(request.user_agent) && Time.current.hour.between?(22, 6)
|
|
204
|
+
score += 5
|
|
205
|
+
Beskar::Logger.info("Mobile device login during late hours, adding 5 risk")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Account-specific risk factors
|
|
209
|
+
if recent_failed_attempts(within: 10.minutes).count >= 2
|
|
210
|
+
score += 20
|
|
211
|
+
Beskar::Logger.info("Recent failed attempts: #{recent_failed_attempts(within: 10.minutes).count}, adding 20 risk")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# ADAPTIVE LEARNING: Check if this is an established pattern
|
|
215
|
+
if result == :success && established_pattern?(request)
|
|
216
|
+
Beskar::Logger.info("Established pattern detected, reducing risk score")
|
|
217
|
+
score = [score * 0.3, 25].min.to_i # Reduce to 30% of original, cap at 25
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Geographic risk assessment
|
|
221
|
+
geolocation_service = Beskar::Services::GeolocationService.new
|
|
222
|
+
recent_locations = recent_successful_logins(within: 4.hours).map do |event|
|
|
223
|
+
event.metadata&.dig("geolocation")
|
|
224
|
+
end.compact
|
|
225
|
+
|
|
226
|
+
# Don't apply geographic risk if this location is established
|
|
227
|
+
unless location_established?(request.ip)
|
|
228
|
+
score += geolocation_service.calculate_location_risk(
|
|
229
|
+
request.ip,
|
|
230
|
+
recent_locations,
|
|
231
|
+
recent_successful_logins(within: 4.hours).last&.created_at&.to_i
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
[score, 100].min # Cap at 100
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def geographic_anomaly_detected?(recent_logins)
|
|
239
|
+
# Placeholder for geographic anomaly detection
|
|
240
|
+
# Would implement haversine formula and impossible travel detection
|
|
241
|
+
false
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Check if the account should be locked based on risk score
|
|
245
|
+
def check_and_lock_if_high_risk(security_event, request)
|
|
246
|
+
return unless Beskar.configuration.risk_based_locking_enabled?
|
|
247
|
+
return unless security_event.risk_score
|
|
248
|
+
|
|
249
|
+
locker = Beskar::Services::AccountLocker.new(
|
|
250
|
+
self,
|
|
251
|
+
risk_score: security_event.risk_score,
|
|
252
|
+
reason: determine_lock_reason(security_event),
|
|
253
|
+
metadata: {
|
|
254
|
+
ip_address: request.ip,
|
|
255
|
+
user_agent: request.user_agent,
|
|
256
|
+
security_event_id: security_event.id,
|
|
257
|
+
geolocation: security_event.geolocation,
|
|
258
|
+
device_info: security_event.device_info
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if locker.lock_if_necessary!
|
|
263
|
+
Beskar::Logger.warn("Account locked due to high risk score: #{security_event.risk_score}")
|
|
264
|
+
|
|
265
|
+
# Trigger auth-system-specific lock handling
|
|
266
|
+
handle_high_risk_lock(security_event, request)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Determine the specific reason for locking
|
|
271
|
+
def determine_lock_reason(security_event)
|
|
272
|
+
metadata = security_event.metadata || {}
|
|
273
|
+
|
|
274
|
+
# Check for impossible travel
|
|
275
|
+
if metadata.dig("geolocation", "impossible_travel")
|
|
276
|
+
return :impossible_travel
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Check for suspicious device
|
|
280
|
+
device_info = metadata["device_info"] || {}
|
|
281
|
+
if device_info["bot_signature"] || device_info["suspicious"]
|
|
282
|
+
return :suspicious_device
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Check for geographic anomaly
|
|
286
|
+
geolocation = metadata["geolocation"] || {}
|
|
287
|
+
if geolocation["country_change"] || geolocation["high_risk_country"]
|
|
288
|
+
return :geographic_anomaly
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Default to high risk authentication
|
|
292
|
+
:high_risk_authentication
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Override this in auth-system-specific modules
|
|
296
|
+
def handle_high_risk_lock(security_event, request)
|
|
297
|
+
Beskar::Logger.debug("High risk lock handled - override in specific module")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# ADAPTIVE LEARNING: Check if this login pattern is established
|
|
301
|
+
def established_pattern?(request)
|
|
302
|
+
return false unless security_events.any?
|
|
303
|
+
|
|
304
|
+
current_ip = request.ip
|
|
305
|
+
|
|
306
|
+
# Look for successful logins from this IP in the past 30 days
|
|
307
|
+
historical_logins = security_events
|
|
308
|
+
.where(event_type: "login_success")
|
|
309
|
+
.where(ip_address: current_ip)
|
|
310
|
+
.where("created_at >= ?", 30.days.ago)
|
|
311
|
+
.where("created_at < ?", 5.minutes.ago)
|
|
312
|
+
|
|
313
|
+
# Need at least 2 successful logins from this context
|
|
314
|
+
return false if historical_logins.count < 2
|
|
315
|
+
|
|
316
|
+
# Check if there was an unlock event followed by successful logins
|
|
317
|
+
recent_unlock_or_lock = security_events
|
|
318
|
+
.where(event_type: ["account_locked", "account_unlocked", "lock_attempted"])
|
|
319
|
+
.where("created_at >= ?", 7.days.ago)
|
|
320
|
+
.order(created_at: :desc)
|
|
321
|
+
.first
|
|
322
|
+
|
|
323
|
+
if recent_unlock_or_lock
|
|
324
|
+
# Check for successful logins after unlock/lock from same IP
|
|
325
|
+
logins_after_unlock = security_events
|
|
326
|
+
.where(event_type: "login_success")
|
|
327
|
+
.where(ip_address: current_ip)
|
|
328
|
+
.where("created_at > ?", recent_unlock_or_lock.created_at)
|
|
329
|
+
.count
|
|
330
|
+
|
|
331
|
+
# If user unlocked and successfully logged in, that's legitimate
|
|
332
|
+
return true if logins_after_unlock >= 1
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Pattern is established if there are 3+ successful logins
|
|
336
|
+
historical_logins.count >= 3
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Check if a location (IP) is established/trusted
|
|
340
|
+
def location_established?(ip_address)
|
|
341
|
+
return false unless security_events.any?
|
|
342
|
+
|
|
343
|
+
successful_logins_from_ip = security_events
|
|
344
|
+
.where(event_type: "login_success")
|
|
345
|
+
.where(ip_address: ip_address)
|
|
346
|
+
.where("created_at >= ?", 30.days.ago)
|
|
347
|
+
.where("created_at < ?", 5.minutes.ago)
|
|
348
|
+
.count
|
|
349
|
+
|
|
350
|
+
# Location is established if there are 2+ successful logins
|
|
351
|
+
successful_logins_from_ip >= 2
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beskar
|
|
4
|
+
module Services
|
|
5
|
+
# Service for locking user accounts based on risk scores
|
|
6
|
+
#
|
|
7
|
+
# This service provides a modular approach to account locking that can work
|
|
8
|
+
# with Devise's lockable module or custom locking implementations. It keeps
|
|
9
|
+
# Devise-specific code isolated for maintainability.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage with Devise lockable
|
|
12
|
+
# locker = Beskar::Services::AccountLocker.new(user, risk_score: 85, reason: :high_risk_login)
|
|
13
|
+
# locker.lock_if_necessary!
|
|
14
|
+
#
|
|
15
|
+
# @example Check if account should be locked
|
|
16
|
+
# if locker.should_lock?
|
|
17
|
+
# locker.lock!
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
class AccountLocker
|
|
21
|
+
attr_reader :user, :risk_score, :reason, :metadata
|
|
22
|
+
|
|
23
|
+
# Initialize the account locker
|
|
24
|
+
#
|
|
25
|
+
# @param user [ActiveRecord::Base] The user to potentially lock
|
|
26
|
+
# @param risk_score [Integer] The calculated risk score (0-100)
|
|
27
|
+
# @param reason [Symbol] The reason for potential lock (:high_risk_login, :impossible_travel, etc.)
|
|
28
|
+
# @param metadata [Hash] Additional context for the lock decision
|
|
29
|
+
def initialize(user, risk_score:, reason: :high_risk_authentication, metadata: {})
|
|
30
|
+
@user = user
|
|
31
|
+
@risk_score = risk_score
|
|
32
|
+
@reason = reason
|
|
33
|
+
@metadata = metadata
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check if account should be locked based on configuration
|
|
37
|
+
#
|
|
38
|
+
# @return [Boolean] true if account should be locked
|
|
39
|
+
def should_lock?
|
|
40
|
+
return false unless Beskar.configuration.risk_based_locking_enabled?
|
|
41
|
+
return false unless user
|
|
42
|
+
return false if user_already_locked?
|
|
43
|
+
|
|
44
|
+
risk_score >= Beskar.configuration.risk_threshold
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Lock the account if necessary (based on should_lock? check)
|
|
48
|
+
#
|
|
49
|
+
# @return [Boolean] true if account was locked, false otherwise
|
|
50
|
+
def lock_if_necessary!
|
|
51
|
+
return false unless should_lock?
|
|
52
|
+
lock!
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Lock the account using the configured strategy
|
|
56
|
+
#
|
|
57
|
+
# @return [Boolean] true if lock was successful, false otherwise
|
|
58
|
+
def lock!
|
|
59
|
+
return false unless user
|
|
60
|
+
|
|
61
|
+
strategy = Beskar.configuration.lock_strategy
|
|
62
|
+
|
|
63
|
+
result = case strategy
|
|
64
|
+
when :devise_lockable
|
|
65
|
+
lock_with_devise_lockable
|
|
66
|
+
when :custom
|
|
67
|
+
lock_with_custom_strategy
|
|
68
|
+
else
|
|
69
|
+
Beskar::Logger.warn("Unknown lock strategy: #{strategy}", component: :AccountLocker)
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Always log lock events when risk-based locking is enabled
|
|
74
|
+
# This creates an audit trail even if actual locking fails
|
|
75
|
+
if Beskar.configuration.log_lock_events?
|
|
76
|
+
log_lock_event(result)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if result
|
|
80
|
+
notify_user if Beskar.configuration.notify_user_on_lock?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Unlock the account using the configured strategy
|
|
87
|
+
#
|
|
88
|
+
# @return [Boolean] true if unlock was successful
|
|
89
|
+
def unlock!
|
|
90
|
+
return false unless user
|
|
91
|
+
|
|
92
|
+
strategy = Beskar.configuration.lock_strategy
|
|
93
|
+
|
|
94
|
+
result = case strategy
|
|
95
|
+
when :devise_lockable
|
|
96
|
+
unlock_with_devise_lockable
|
|
97
|
+
when :custom
|
|
98
|
+
unlock_with_custom_strategy
|
|
99
|
+
else
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Log unlock event for adaptive learning
|
|
104
|
+
if result && Beskar.configuration.log_lock_events?
|
|
105
|
+
log_unlock_event
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Check if user is currently locked
|
|
112
|
+
#
|
|
113
|
+
# @return [Boolean] true if user is locked
|
|
114
|
+
def locked?
|
|
115
|
+
user_already_locked?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Check if user is already locked
|
|
121
|
+
def user_already_locked?
|
|
122
|
+
return false unless user
|
|
123
|
+
|
|
124
|
+
if user.respond_to?(:access_locked?)
|
|
125
|
+
user.access_locked?
|
|
126
|
+
elsif user.respond_to?(:locked_at)
|
|
127
|
+
user.locked_at.present?
|
|
128
|
+
else
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Lock account using Devise's lockable module
|
|
134
|
+
def lock_with_devise_lockable
|
|
135
|
+
unless devise_lockable_available?
|
|
136
|
+
Beskar::Logger.warn("Devise lockable not available for #{user.class.name}", component: :AccountLocker)
|
|
137
|
+
return false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
begin
|
|
141
|
+
# Use Devise's lock_access! method
|
|
142
|
+
user.lock_access!(send_instructions: false)
|
|
143
|
+
|
|
144
|
+
# Set automatic unlock time if configured and supported
|
|
145
|
+
if Beskar.configuration.auto_unlock_time && user.respond_to?(:locked_at=)
|
|
146
|
+
user.update_column(:locked_at, Time.current)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
Beskar::Logger.info("Locked account #{user.id} (#{user.class.name}) - Risk: #{risk_score}, Reason: #{reason}", component: :AccountLocker)
|
|
150
|
+
true
|
|
151
|
+
rescue => e
|
|
152
|
+
Beskar::Logger.error("Failed to lock account: #{e.message}", component: :AccountLocker)
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Unlock account using Devise's lockable module
|
|
158
|
+
def unlock_with_devise_lockable
|
|
159
|
+
unless devise_lockable_available?
|
|
160
|
+
Beskar::Logger.warn("Devise lockable not available for #{user.class.name}", component: :AccountLocker)
|
|
161
|
+
return false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
begin
|
|
165
|
+
user.unlock_access!
|
|
166
|
+
Beskar::Logger.info("Unlocked account #{user.id} (#{user.class.name})", component: :AccountLocker)
|
|
167
|
+
true
|
|
168
|
+
rescue => e
|
|
169
|
+
Beskar::Logger.error("Failed to unlock account: #{e.message}", component: :AccountLocker)
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Lock account using custom strategy (to be implemented by application)
|
|
175
|
+
def lock_with_custom_strategy
|
|
176
|
+
# Applications can implement this by:
|
|
177
|
+
# 1. Adding a locked_by_beskar column to users table
|
|
178
|
+
# 2. Checking this in authentication callbacks
|
|
179
|
+
# 3. Implementing unlock logic
|
|
180
|
+
|
|
181
|
+
Beskar::Logger.warn("Custom lock strategy not implemented", component: :AccountLocker)
|
|
182
|
+
false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Unlock using custom strategy
|
|
186
|
+
def unlock_with_custom_strategy
|
|
187
|
+
Beskar::Logger.warn("Custom unlock strategy not implemented", component: :AccountLocker)
|
|
188
|
+
false
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Check if Devise lockable is available for this user
|
|
192
|
+
def devise_lockable_available?
|
|
193
|
+
defined?(Devise) &&
|
|
194
|
+
user.class.respond_to?(:devise_modules) &&
|
|
195
|
+
user.class.devise_modules.include?(:lockable) &&
|
|
196
|
+
user.respond_to?(:lock_access!)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Log the lock event to security events
|
|
200
|
+
# Always logs, even if actual lock fails, to maintain audit trail
|
|
201
|
+
def log_lock_event(lock_succeeded = true)
|
|
202
|
+
return unless user.respond_to?(:security_events)
|
|
203
|
+
|
|
204
|
+
begin
|
|
205
|
+
event_type = lock_succeeded ? 'account_locked' : 'lock_attempted'
|
|
206
|
+
|
|
207
|
+
user.security_events.create!(
|
|
208
|
+
event_type: event_type,
|
|
209
|
+
ip_address: metadata[:ip_address] || 'system',
|
|
210
|
+
user_agent: metadata[:user_agent] || 'beskar_system',
|
|
211
|
+
risk_score: risk_score,
|
|
212
|
+
metadata: {
|
|
213
|
+
reason: reason,
|
|
214
|
+
risk_threshold: Beskar.configuration.risk_threshold,
|
|
215
|
+
lock_strategy: Beskar.configuration.lock_strategy,
|
|
216
|
+
auto_unlock_time: Beskar.configuration.auto_unlock_time,
|
|
217
|
+
locked_at: Time.current.iso8601,
|
|
218
|
+
lock_succeeded: lock_succeeded,
|
|
219
|
+
additional_context: metadata
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
rescue => e
|
|
223
|
+
Beskar::Logger.warn("Failed to log lock event: #{e.message}", component: :AccountLocker)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Log unlock event for adaptive learning
|
|
228
|
+
# This helps establish patterns - if user unlocks and logs in successfully,
|
|
229
|
+
# that context becomes "established" and trusted
|
|
230
|
+
def log_unlock_event
|
|
231
|
+
return unless user.respond_to?(:security_events)
|
|
232
|
+
|
|
233
|
+
begin
|
|
234
|
+
user.security_events.create!(
|
|
235
|
+
event_type: 'account_unlocked',
|
|
236
|
+
ip_address: metadata[:ip_address] || 'system',
|
|
237
|
+
user_agent: metadata[:user_agent] || 'beskar_system',
|
|
238
|
+
risk_score: 0, # Unlock has no risk
|
|
239
|
+
metadata: {
|
|
240
|
+
unlocked_at: Time.current.iso8601,
|
|
241
|
+
unlock_method: 'manual',
|
|
242
|
+
additional_context: metadata
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
rescue => e
|
|
246
|
+
Beskar::Logger.warn("Failed to log unlock event: #{e.message}", component: :AccountLocker)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Notify user about account lock
|
|
251
|
+
def notify_user
|
|
252
|
+
# This would integrate with ActionMailer or notification system
|
|
253
|
+
# For now, just log it
|
|
254
|
+
Beskar::Logger.info("User #{user.id} should be notified of account lock", component: :AccountLocker)
|
|
255
|
+
|
|
256
|
+
# Future implementation:
|
|
257
|
+
# if defined?(Beskar::AccountLockMailer)
|
|
258
|
+
# Beskar::AccountLockMailer.account_locked(user, risk_score, reason).deliver_later
|
|
259
|
+
# end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|