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
|
@@ -2,146 +2,168 @@ module Beskar
|
|
|
2
2
|
module Models
|
|
3
3
|
# Generic security tracking functionality shared by all authentication systems
|
|
4
4
|
# This module provides the core security event tracking, risk scoring,
|
|
5
|
-
# and
|
|
5
|
+
# and risk evidence shared by the supported authentication adapters
|
|
6
6
|
module SecurityTrackableGeneric
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
9
|
included do
|
|
10
|
-
|
|
10
|
+
# Audit rows outlive the account, retaining the original polymorphic
|
|
11
|
+
# identity and stored evidence without deletion or nullification.
|
|
12
|
+
has_many :security_events, class_name: "Beskar::SecurityEvent", as: :user
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
module ClassMethods
|
|
14
16
|
# Track failed authentication attempt without a user context
|
|
15
17
|
# 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
|
-
Rails.logger.debug "[Beskar] Failed login tracking disabled in configuration"
|
|
20
|
-
return
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Extract attempted email from params based on scope
|
|
18
|
+
def track_failed_authentication(request, scope, attempt: nil)
|
|
24
19
|
attempted_email = extract_attempted_email(request, scope)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
20
|
+
attempt ||= Services::AuthenticationAttempt.current(request, scope)
|
|
21
|
+
unless attempt
|
|
22
|
+
key = attribute_names.include?("email_address") ? :email_address : :email
|
|
23
|
+
credentials = attempted_email ? {key => attempted_email} : {}
|
|
24
|
+
user = if credentials.empty?
|
|
25
|
+
nil
|
|
26
|
+
elsif respond_to?(:find_for_database_authentication)
|
|
27
|
+
find_for_database_authentication(credentials)
|
|
28
|
+
else
|
|
29
|
+
find_by(credentials)
|
|
30
|
+
end
|
|
31
|
+
attempt = Services::AuthenticationAttempt.reserve(request, model: self, scope: scope,
|
|
32
|
+
user: user, credentials: credentials)
|
|
33
|
+
end
|
|
34
|
+
attempted_email ||= attempt.attempted_email
|
|
35
|
+
return attempt.event if attempt.completed
|
|
36
|
+
|
|
37
|
+
attempt.completed = true
|
|
38
|
+
return unless Beskar.configuration.track_failed_logins?
|
|
39
|
+
|
|
40
|
+
assessment = Services::RiskAssessment.new(request, user: attempt.user, result: :failure) if attempt.allowed?
|
|
41
|
+
event = Beskar::SecurityEvent.new(
|
|
42
|
+
user_type: attempt.user&.class&.polymorphic_name, user_id: attempt.user&.id,
|
|
43
|
+
event_type: attempt.allowed? ? "login_failure" : "authentication_blocked",
|
|
44
|
+
ip_address: Services::RequestContext.ip(request),
|
|
45
|
+
user_agent: Services::RequestContext.text(request.user_agent),
|
|
46
46
|
attempted_email: attempted_email,
|
|
47
|
-
metadata: metadata,
|
|
48
|
-
|
|
47
|
+
metadata: Services::RequestContext.security_metadata(request, enrich: false).merge(assessment&.metadata || {}).merge(scope: scope.to_s,
|
|
48
|
+
request_path: attempt.request_path, authentication: attempt.metadata),
|
|
49
|
+
risk_score: assessment&.score || 0
|
|
49
50
|
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
Beskar::
|
|
51
|
+
event.beskar_attempt = attempt
|
|
52
|
+
attempt.event = event
|
|
53
|
+
Beskar::SecurityEvent.transaction(requires_new: true) { event.save! }
|
|
54
|
+
event
|
|
55
|
+
rescue Services::AuthenticationAttempt::Unavailable
|
|
56
|
+
raise
|
|
57
|
+
rescue => error
|
|
58
|
+
Beskar::Logger.warn("Failed authentication audit unavailable (#{error.class})")
|
|
59
|
+
attempt&.event
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
private
|
|
56
63
|
|
|
57
64
|
def extract_attempted_email(request, scope)
|
|
58
65
|
# Try different param patterns based on scope
|
|
59
|
-
request.params.dig(
|
|
60
|
-
request.params.dig(scope.to_s, "
|
|
66
|
+
request.params.dig(scope.to_s, "email") ||
|
|
67
|
+
request.params.dig(scope.to_s, "email_address") ||
|
|
68
|
+
request.params.dig("devise_user", "email") ||
|
|
61
69
|
request.params.dig("user", "email") ||
|
|
70
|
+
request.params.dig("user", "email_address") ||
|
|
62
71
|
request.params.dig("email_address") ||
|
|
63
72
|
request.params["email"]
|
|
64
73
|
end
|
|
65
74
|
|
|
66
75
|
def calculate_failure_risk_score(request)
|
|
67
|
-
|
|
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
|
-
Rails.logger.info "[Beskar] 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
|
-
Rails.logger.info "[Beskar] Geolocation risk: #{geo_score}"
|
|
88
|
-
|
|
89
|
-
[score, 100].min # Cap at 100
|
|
76
|
+
Services::RiskAssessment.new(request, result: :failure).score
|
|
90
77
|
end
|
|
91
78
|
end
|
|
92
79
|
|
|
93
80
|
# Track authentication event (success or failure) for a specific user
|
|
94
|
-
def track_authentication_event(request, result)
|
|
81
|
+
def track_authentication_event(request, result, attempt: nil, persist: true)
|
|
95
82
|
return unless request
|
|
96
83
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
84
|
+
attempt ||= Services::AuthenticationAttempt.reserve(request, model: self.class,
|
|
85
|
+
scope: self.class.name.underscore, user: self)
|
|
86
|
+
attempt.bind_user!(self)
|
|
87
|
+
return attempt.event if attempt.completed
|
|
88
|
+
tracking = (result == :success) ? Beskar.configuration.track_successful_logins? : Beskar.configuration.track_failed_logins?
|
|
89
|
+
assessment_required = tracking || (result == :success && Beskar.configuration.risk_based_locking_enabled?)
|
|
90
|
+
unless assessment_required
|
|
91
|
+
attempt.completed = true
|
|
103
92
|
return
|
|
104
93
|
end
|
|
105
94
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
95
|
+
# Build the decision even when audit persistence is disabled. A rejected
|
|
96
|
+
# credential attempt is never recorded as a trusted successful login.
|
|
97
|
+
# Do not build through has_many: saving/locking the user can autosave its
|
|
98
|
+
# unsaved children and accidentally make optional audit writes mandatory.
|
|
99
|
+
assessment = assess_authentication_risk(request, result)
|
|
100
|
+
security_event = Beskar::SecurityEvent.new(
|
|
101
|
+
user_type: self.class.polymorphic_name, user_id: id,
|
|
102
|
+
event_type: (result == :success) ? "login_success" : "login_failure",
|
|
103
|
+
ip_address: Services::RequestContext.ip(request),
|
|
104
|
+
user_agent: Services::RequestContext.text(request.user_agent),
|
|
112
105
|
attempted_email: extract_user_email,
|
|
113
|
-
metadata:
|
|
114
|
-
risk_score:
|
|
106
|
+
metadata: Services::RequestContext.security_metadata(request, enrich: false).merge(assessment.metadata),
|
|
107
|
+
risk_score: assessment.score
|
|
115
108
|
)
|
|
109
|
+
security_event.beskar_attempt = attempt
|
|
110
|
+
attempt.event = security_event
|
|
116
111
|
|
|
117
|
-
if
|
|
118
|
-
|
|
119
|
-
|
|
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)
|
|
112
|
+
if result == :success && attempt.allowed?
|
|
113
|
+
attempt.locked_now = !!check_and_lock_if_high_risk(security_event, request)
|
|
114
|
+
if attempt.locked_now
|
|
115
|
+
attempt.deny!(:account_locked)
|
|
128
116
|
end
|
|
129
117
|
end
|
|
130
118
|
|
|
119
|
+
attempt.verify_generation!
|
|
120
|
+
security_event.event_type = "authentication_blocked" unless attempt.allowed?
|
|
121
|
+
security_event.metadata = (security_event.metadata || {}).merge("authentication" => attempt.metadata)
|
|
122
|
+
attempt.completed = true
|
|
123
|
+
return unless tracking && persist
|
|
124
|
+
|
|
125
|
+
persist_authentication_audit(security_event)
|
|
126
|
+
analyze_suspicious_patterns_async if result == :success && attempt.allowed? && Beskar.configuration.auto_analyze_patterns?
|
|
131
127
|
security_event
|
|
128
|
+
rescue Services::AuthenticationAttempt::Unavailable
|
|
129
|
+
raise
|
|
130
|
+
rescue => error
|
|
131
|
+
Beskar::Logger.error("Authentication risk assessment unavailable (#{error.class})")
|
|
132
|
+
# A broken risk assessment must not silently admit a potentially locked
|
|
133
|
+
# account. Audit writes have their own non-fatal boundary below.
|
|
134
|
+
if Beskar.configuration.risk_based_locking_enabled? && Services::RequestContext.enforce?(attempt&.ip_address)
|
|
135
|
+
raise Services::AuthenticationAttempt::Unavailable, "Authentication temporarily unavailable"
|
|
136
|
+
end
|
|
137
|
+
attempt.completed = true if attempt
|
|
138
|
+
nil
|
|
132
139
|
end
|
|
133
140
|
|
|
134
141
|
def analyze_suspicious_patterns_async
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
return unless Beskar.configuration.auto_analyze_patterns?
|
|
143
|
+
job = Beskar.configuration.analysis_job_class
|
|
144
|
+
arguments = {user_type: self.class.base_class.name, user_id: id, event_type: "login_success"}
|
|
145
|
+
# Never enqueue for an outer transaction that later rolls back. The job
|
|
146
|
+
# is optional enrichment, not delayed authentication enforcement.
|
|
147
|
+
ActiveRecord.after_all_transactions_commit do
|
|
148
|
+
result = job.perform_later(**arguments)
|
|
149
|
+
Beskar::Logger.warn("Security analysis job was not enqueued") unless result
|
|
150
|
+
rescue => error
|
|
151
|
+
Beskar::Logger.warn("Failed to queue security analysis (#{error.class})")
|
|
139
152
|
end
|
|
140
|
-
|
|
141
|
-
# Queue background job for detailed analysis
|
|
142
|
-
Beskar::SecurityAnalysisJob.perform_later(self.id, "login_success") if defined?(Beskar::SecurityAnalysisJob)
|
|
143
153
|
rescue => e
|
|
144
|
-
|
|
154
|
+
Beskar::Logger.warn("Failed to prepare security analysis (#{e.class})")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def beskar_session_token
|
|
158
|
+
Services::SessionRevocation.token(self)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def revoke_beskar_sessions!
|
|
162
|
+
Services::SessionRevocation.revoke!(self)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def beskar_session_valid?(request, token:)
|
|
166
|
+
Services::SessionRevocation.allowed?(self, request: request, token: token)
|
|
145
167
|
end
|
|
146
168
|
|
|
147
169
|
def recent_failed_attempts(within: 1.hour)
|
|
@@ -164,81 +186,48 @@ module Beskar
|
|
|
164
186
|
return true if recent_attempts.count >= 3
|
|
165
187
|
|
|
166
188
|
# Check for geographic anomalies
|
|
167
|
-
|
|
168
|
-
return true if geographic_anomaly_detected?(recent_logins)
|
|
189
|
+
return true if geographic_anomaly_detected?
|
|
169
190
|
|
|
170
191
|
false
|
|
171
192
|
end
|
|
172
193
|
|
|
173
194
|
private
|
|
174
195
|
|
|
196
|
+
def persist_authentication_audit(event)
|
|
197
|
+
Beskar::SecurityEvent.transaction(requires_new: true) { event.save }
|
|
198
|
+
rescue => error
|
|
199
|
+
Beskar::Logger.warn("Authentication audit unavailable (#{error.class})")
|
|
200
|
+
false
|
|
201
|
+
end
|
|
202
|
+
|
|
175
203
|
def extract_user_email
|
|
176
204
|
# Try different email attribute names
|
|
177
|
-
respond_to?(:email)
|
|
205
|
+
if respond_to?(:email)
|
|
206
|
+
email
|
|
207
|
+
else
|
|
208
|
+
(respond_to?(:email_address) ? email_address : nil)
|
|
209
|
+
end
|
|
178
210
|
end
|
|
179
211
|
|
|
180
212
|
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
|
-
}
|
|
213
|
+
Services::RequestContext.security_metadata(request)
|
|
192
214
|
end
|
|
193
215
|
|
|
194
|
-
def
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
Rails.logger.info "[Beskar] 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
|
-
Rails.logger.info "[Beskar] 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
|
-
Rails.logger.info "[Beskar] 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
|
|
216
|
+
def assess_authentication_risk(request, result)
|
|
217
|
+
Services::RiskAssessment.new(request, user: self, result: result)
|
|
218
|
+
end
|
|
234
219
|
|
|
235
|
-
|
|
220
|
+
# Numeric compatibility helper; authentication uses the complete assessment.
|
|
221
|
+
def calculate_risk_score(request, result)
|
|
222
|
+
assess_authentication_risk(request, result).score
|
|
236
223
|
end
|
|
237
224
|
|
|
238
|
-
def geographic_anomaly_detected?
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
225
|
+
def geographic_anomaly_detected?
|
|
226
|
+
observations = Services::RiskAssessment.observations(self)
|
|
227
|
+
observations.sort_by { |entry| [entry[:occurred_at], entry[:event_id]] }.each_cons(2).any? do |previous, current|
|
|
228
|
+
assessment = Services::LocationAssessment.new(current[:location], observations: [previous], at: current[:occurred_at]).call
|
|
229
|
+
assessment[:location][:impossible_travel]
|
|
230
|
+
end
|
|
242
231
|
end
|
|
243
232
|
|
|
244
233
|
# Check if the account should be locked based on risk score
|
|
@@ -251,40 +240,52 @@ module Beskar
|
|
|
251
240
|
risk_score: security_event.risk_score,
|
|
252
241
|
reason: determine_lock_reason(security_event),
|
|
253
242
|
metadata: {
|
|
254
|
-
ip_address:
|
|
255
|
-
user_agent: request.user_agent,
|
|
243
|
+
ip_address: Beskar::Services::RequestContext.ip(request),
|
|
244
|
+
user_agent: Services::RequestContext.text(request.user_agent),
|
|
256
245
|
security_event_id: security_event.id,
|
|
246
|
+
authentication_attempt_id: security_event.beskar_attempt&.id,
|
|
257
247
|
geolocation: security_event.geolocation,
|
|
258
|
-
device_info: security_event.device_info
|
|
248
|
+
device_info: security_event.device_info,
|
|
249
|
+
risk_assessment: security_event.metadata["risk_assessment"]
|
|
259
250
|
}
|
|
260
251
|
)
|
|
261
252
|
|
|
253
|
+
security_event.metadata["lock_decision"] = {
|
|
254
|
+
"threshold" => Beskar.configuration.risk_threshold,
|
|
255
|
+
"strategy_available" => locker.supported?,
|
|
256
|
+
"would_lock" => locker.supported? && security_event.risk_score >= Beskar.configuration.risk_threshold && !locker.locked?,
|
|
257
|
+
"enforcement_enabled" => Services::RequestContext.enforce?(Services::RequestContext.ip(request))
|
|
258
|
+
}
|
|
259
|
+
|
|
262
260
|
if locker.lock_if_necessary!
|
|
263
|
-
|
|
264
|
-
|
|
261
|
+
Beskar::Logger.warn("Account locked due to high risk score: #{security_event.risk_score}")
|
|
262
|
+
|
|
265
263
|
# Trigger auth-system-specific lock handling
|
|
266
264
|
handle_high_risk_lock(security_event, request)
|
|
265
|
+
true
|
|
266
|
+
else
|
|
267
|
+
false
|
|
267
268
|
end
|
|
268
269
|
end
|
|
269
270
|
|
|
270
271
|
# Determine the specific reason for locking
|
|
271
272
|
def determine_lock_reason(security_event)
|
|
272
|
-
metadata = security_event.metadata || {}
|
|
273
|
+
metadata = (security_event.metadata || {}).deep_stringify_keys
|
|
273
274
|
|
|
274
275
|
# Check for impossible travel
|
|
275
|
-
if metadata.dig("geolocation", "impossible_travel")
|
|
276
|
+
if metadata.dig("geolocation", "impossible_travel") == true
|
|
276
277
|
return :impossible_travel
|
|
277
278
|
end
|
|
278
279
|
|
|
279
280
|
# Check for suspicious device
|
|
280
281
|
device_info = metadata["device_info"] || {}
|
|
281
|
-
if device_info["bot_signature"] || device_info["suspicious"]
|
|
282
|
+
if device_info["bot"] == true || device_info["bot_signature"] == true || device_info["suspicious"] == true
|
|
282
283
|
return :suspicious_device
|
|
283
284
|
end
|
|
284
285
|
|
|
285
286
|
# Check for geographic anomaly
|
|
286
287
|
geolocation = metadata["geolocation"] || {}
|
|
287
|
-
if geolocation["country_change"] || geolocation["high_risk_country"]
|
|
288
|
+
if geolocation["country_change"] == true || geolocation["high_risk_country"] == true
|
|
288
289
|
return :geographic_anomaly
|
|
289
290
|
end
|
|
290
291
|
|
|
@@ -294,61 +295,17 @@ module Beskar
|
|
|
294
295
|
|
|
295
296
|
# Override this in auth-system-specific modules
|
|
296
297
|
def handle_high_risk_lock(security_event, request)
|
|
297
|
-
|
|
298
|
+
Beskar::Logger.debug("High risk lock handled - override in specific module")
|
|
298
299
|
end
|
|
299
300
|
|
|
300
|
-
#
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
301
|
+
# Kept for integrations that called these former private helpers. Repeated
|
|
302
|
+
# IP use, automatic unlocks, and account-lock attempts do not establish trust.
|
|
303
|
+
def established_pattern?(_request)
|
|
304
|
+
false
|
|
337
305
|
end
|
|
338
306
|
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
307
|
+
def location_established?(_ip_address)
|
|
308
|
+
false
|
|
352
309
|
end
|
|
353
310
|
end
|
|
354
311
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
# Reporting bands, not configurable authentication-lock thresholds.
|
|
3
|
+
module RiskLevel
|
|
4
|
+
RANGES = {low: 0...30, medium: 30...70, high: 70...90, critical: 90..100}.freeze
|
|
5
|
+
BADGES = {low: "success", medium: "warning", high: "danger", critical: "critical"}.freeze
|
|
6
|
+
COLORS = {low: "#4CAF50", medium: "#FF9800", high: "#E91E63", critical: "#D32F2F"}.freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def for(score)
|
|
11
|
+
return unless score.is_a?(Numeric) && score.real? && score.finite?
|
|
12
|
+
RANGES.find { |_, range| range.cover?(score) }&.first
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def filter_options
|
|
16
|
+
RANGES.map do |level, range|
|
|
17
|
+
last = range.exclude_end? ? range.end - 1 : range.end
|
|
18
|
+
["#{level.to_s.capitalize} (#{range.begin}-#{last})", level.to_s]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|