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,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beskar
|
|
4
|
+
module Services
|
|
5
|
+
# Service for detecting device information from User-Agent strings
|
|
6
|
+
#
|
|
7
|
+
# This service provides comprehensive device, browser, and platform detection
|
|
8
|
+
# capabilities for security analysis and fingerprinting purposes.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# detector = Beskar::Services::DeviceDetector.new
|
|
12
|
+
# info = detector.detect("Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)")
|
|
13
|
+
# # => {
|
|
14
|
+
# # browser: "Safari 15",
|
|
15
|
+
# # platform: "iOS 15.0",
|
|
16
|
+
# # mobile: true,
|
|
17
|
+
# # bot: false
|
|
18
|
+
# # }
|
|
19
|
+
#
|
|
20
|
+
class DeviceDetector
|
|
21
|
+
# Known bot patterns for security analysis
|
|
22
|
+
BOT_PATTERNS = %r{
|
|
23
|
+
bot|crawler|spider|scraper|fetcher|
|
|
24
|
+
googlebot|bingbot|slurp|duckduckgo|
|
|
25
|
+
facebookexternalhit|twitterbot|linkedinbot|
|
|
26
|
+
whatsapp|telegram|discord|
|
|
27
|
+
curl|wget|postman|httpie|
|
|
28
|
+
python-requests|ruby|php|java|
|
|
29
|
+
headless|phantom|selenium|playwright
|
|
30
|
+
}ix.freeze
|
|
31
|
+
|
|
32
|
+
# Mobile device patterns
|
|
33
|
+
MOBILE_PATTERNS = %r{
|
|
34
|
+
Mobile|Android|iPhone|iPad|iPod|
|
|
35
|
+
BlackBerry|IEMobile|Opera\s*Mini|
|
|
36
|
+
Windows\s*Phone|webOS|Kindle
|
|
37
|
+
}ix.freeze
|
|
38
|
+
|
|
39
|
+
# Browser patterns with version extraction
|
|
40
|
+
BROWSER_PATTERNS = {
|
|
41
|
+
chrome: /Chrome\/(\d+(?:\.\d+)*)/i,
|
|
42
|
+
firefox: /Firefox\/(\d+(?:\.\d+)*)/i,
|
|
43
|
+
safari: /Version\/(\d+(?:\.\d+)*).+Safari/i,
|
|
44
|
+
edge: /Edg(?:e|A|iOS)?\/(\d+(?:\.\d+)*)/i,
|
|
45
|
+
opera: /(?:Opera|OPR)\/(\d+(?:\.\d+)*)/i,
|
|
46
|
+
internet_explorer: /(?:MSIE\s+|Trident.*rv:)(\d+(?:\.\d+)*)/i
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# Platform patterns with version extraction
|
|
50
|
+
PLATFORM_PATTERNS = {
|
|
51
|
+
windows: /Windows NT (\d+\.\d+)/i,
|
|
52
|
+
macos: /Mac OS X (\d+[._]\d+(?:[._]\d+)?)/i,
|
|
53
|
+
ios: /(?:iPhone|iPad).*OS (\d+[._]\d+(?:[._]\d+)?)/i,
|
|
54
|
+
android: /Android (\d+(?:\.\d+)*)/i,
|
|
55
|
+
linux: /Linux/i,
|
|
56
|
+
chromeos: /CrOS/i
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# Convenience method for one-off detection
|
|
61
|
+
#
|
|
62
|
+
# @param user_agent [String] The User-Agent string to analyze
|
|
63
|
+
# @return [Hash] Device information
|
|
64
|
+
def detect(user_agent)
|
|
65
|
+
new.detect(user_agent)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if a User-Agent represents a mobile device
|
|
69
|
+
#
|
|
70
|
+
# @param user_agent [String] The User-Agent string to check
|
|
71
|
+
# @return [Boolean] true if mobile device
|
|
72
|
+
def mobile?(user_agent)
|
|
73
|
+
return false if user_agent.blank?
|
|
74
|
+
user_agent.match?(MOBILE_PATTERNS)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Check if a User-Agent represents a bot/crawler
|
|
78
|
+
#
|
|
79
|
+
# @param user_agent [String] The User-Agent string to check
|
|
80
|
+
# @return [Boolean] true if bot/crawler
|
|
81
|
+
def bot?(user_agent)
|
|
82
|
+
return false if user_agent.blank?
|
|
83
|
+
user_agent.match?(BOT_PATTERNS)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Detect comprehensive device information from User-Agent
|
|
88
|
+
#
|
|
89
|
+
# @param user_agent [String] The User-Agent string to analyze
|
|
90
|
+
# @return [Hash] Hash containing browser, platform, mobile, and bot information
|
|
91
|
+
def detect(user_agent)
|
|
92
|
+
return empty_result if user_agent.blank?
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
browser: detect_browser(user_agent),
|
|
96
|
+
platform: detect_platform(user_agent),
|
|
97
|
+
mobile: mobile?(user_agent),
|
|
98
|
+
bot: bot?(user_agent),
|
|
99
|
+
raw_user_agent: truncate_user_agent(user_agent)
|
|
100
|
+
}.compact
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Extract browser information with version
|
|
104
|
+
#
|
|
105
|
+
# @param user_agent [String] The User-Agent string
|
|
106
|
+
# @return [String] Browser name and version, or "Unknown"
|
|
107
|
+
def detect_browser(user_agent)
|
|
108
|
+
return "Unknown" if user_agent.blank?
|
|
109
|
+
|
|
110
|
+
# Special case: Safari must be detected after Chrome check
|
|
111
|
+
# because Chrome user agents contain "Safari"
|
|
112
|
+
# Also check for Opera (OPR) before Chrome since it also contains Chrome
|
|
113
|
+
if user_agent.match?(/OPR|Opera/i)
|
|
114
|
+
if match = user_agent.match(BROWSER_PATTERNS[:opera])
|
|
115
|
+
return "Opera #{match[1]}"
|
|
116
|
+
end
|
|
117
|
+
elsif user_agent.match?(/Chrome/i) && !user_agent.match?(/Edg/i)
|
|
118
|
+
if match = user_agent.match(BROWSER_PATTERNS[:chrome])
|
|
119
|
+
return "Chrome #{match[1]}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
BROWSER_PATTERNS.each do |browser, pattern|
|
|
124
|
+
next if browser == :chrome # Already handled above
|
|
125
|
+
|
|
126
|
+
if match = user_agent.match(pattern)
|
|
127
|
+
return "#{browser.to_s.titleize} #{match[1]}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
"Unknown"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Extract platform/operating system information with version
|
|
135
|
+
#
|
|
136
|
+
# @param user_agent [String] The User-Agent string
|
|
137
|
+
# @return [String] Platform name and version, or "Unknown"
|
|
138
|
+
def detect_platform(user_agent)
|
|
139
|
+
return "Unknown" if user_agent.blank?
|
|
140
|
+
|
|
141
|
+
PLATFORM_PATTERNS.each do |platform, pattern|
|
|
142
|
+
if match = user_agent.match(pattern)
|
|
143
|
+
version = match[1]&.tr("_", ".")
|
|
144
|
+
|
|
145
|
+
case platform
|
|
146
|
+
when :windows
|
|
147
|
+
return "Windows #{version}"
|
|
148
|
+
when :macos
|
|
149
|
+
return "macOS #{version}"
|
|
150
|
+
when :ios
|
|
151
|
+
return "iOS #{version}"
|
|
152
|
+
when :android
|
|
153
|
+
return "Android #{version}"
|
|
154
|
+
when :linux
|
|
155
|
+
return "Linux"
|
|
156
|
+
when :chromeos
|
|
157
|
+
return "Chrome OS"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
"Unknown"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Check if User-Agent represents a mobile device
|
|
166
|
+
#
|
|
167
|
+
# @param user_agent [String] The User-Agent string
|
|
168
|
+
# @return [Boolean] true if mobile device detected
|
|
169
|
+
def mobile?(user_agent)
|
|
170
|
+
self.class.mobile?(user_agent)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Check if User-Agent represents a bot or crawler
|
|
174
|
+
#
|
|
175
|
+
# @param user_agent [String] The User-Agent string
|
|
176
|
+
# @return [Boolean] true if bot/crawler detected
|
|
177
|
+
def bot?(user_agent)
|
|
178
|
+
self.class.bot?(user_agent)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Calculate a risk score based on User-Agent characteristics
|
|
182
|
+
#
|
|
183
|
+
# @param user_agent [String] The User-Agent string
|
|
184
|
+
# @return [Integer] Risk score from 0 to 50
|
|
185
|
+
def calculate_user_agent_risk(user_agent)
|
|
186
|
+
return 20 if user_agent.blank?
|
|
187
|
+
|
|
188
|
+
risk = 0
|
|
189
|
+
|
|
190
|
+
# Bot detection adds significant risk
|
|
191
|
+
if bot?(user_agent)
|
|
192
|
+
risk += 30
|
|
193
|
+
Rails.logger.info "Bot detected: #{user_agent}, adding 30 risk"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Suspicious patterns
|
|
197
|
+
if user_agent.length < 20 || user_agent.length > 500
|
|
198
|
+
risk += 15
|
|
199
|
+
Rails.logger.info "Suspicious length: #{user_agent.length}, adding 15 risk"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
if user_agent.match?(/test|debug|script/i)
|
|
203
|
+
risk += 10
|
|
204
|
+
Rails.logger.info "Suspicious pattern: #{user_agent}, adding 10 risk"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
if user_agent.count("()") > 3
|
|
208
|
+
risk += 5
|
|
209
|
+
Rails.logger.info "Suspicious pattern: #{user_agent}, adding 5 risk"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Very old browsers might be suspicious
|
|
213
|
+
if browser_info = detect_browser(user_agent)
|
|
214
|
+
if browser_info.match?(/Chrome (\d+)/) && $1.to_i < 90
|
|
215
|
+
risk += 5
|
|
216
|
+
Rails.logger.info "Suspicious browser: #{browser_info}, adding 5 risk"
|
|
217
|
+
elsif browser_info.match?(/Firefox (\d+)/) && $1.to_i < 90
|
|
218
|
+
risk += 5
|
|
219
|
+
Rails.logger.info "Suspicious browser: #{browser_info}, adding 5 risk"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
[ risk, 50 ].min # Cap at 50 to leave room for other risk factors
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# Return empty result structure
|
|
229
|
+
#
|
|
230
|
+
# @return [Hash] Empty device information
|
|
231
|
+
def empty_result
|
|
232
|
+
{
|
|
233
|
+
browser: "Unknown",
|
|
234
|
+
platform: "Unknown",
|
|
235
|
+
mobile: false,
|
|
236
|
+
bot: false
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Truncate user agent for storage (prevent DoS via long strings)
|
|
241
|
+
#
|
|
242
|
+
# @param user_agent [String] The User-Agent string
|
|
243
|
+
# @return [String] Truncated user agent (max 500 chars)
|
|
244
|
+
def truncate_user_agent(user_agent)
|
|
245
|
+
return user_agent if user_agent.length <= 500
|
|
246
|
+
"#{user_agent[0..496]}..."
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require 'maxminddb'
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# MaxMindDB gem not available
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module Beskar
|
|
10
|
+
module Services
|
|
11
|
+
# Service for detecting geographic location from IP addresses
|
|
12
|
+
#
|
|
13
|
+
# This service provides IP-based geolocation capabilities for security analysis,
|
|
14
|
+
# impossible travel detection, and geographic anomaly detection.
|
|
15
|
+
#
|
|
16
|
+
# Features:
|
|
17
|
+
# - Efficient MaxMind database reading with singleton pattern
|
|
18
|
+
# - Automatic caching with configurable TTL
|
|
19
|
+
# - Private IP detection
|
|
20
|
+
# - Impossible travel detection using Haversine formula
|
|
21
|
+
#
|
|
22
|
+
# @example Basic usage
|
|
23
|
+
# service = Beskar::Services::GeolocationService.new
|
|
24
|
+
# location = service.locate("203.0.113.1")
|
|
25
|
+
# # => {
|
|
26
|
+
# # ip: "203.0.113.1",
|
|
27
|
+
# # country: "United States",
|
|
28
|
+
# # country_code: "US",
|
|
29
|
+
# # city: "New York",
|
|
30
|
+
# # latitude: 40.7128,
|
|
31
|
+
# # longitude: -74.0060,
|
|
32
|
+
# # timezone: "America/New_York"
|
|
33
|
+
# # }
|
|
34
|
+
#
|
|
35
|
+
class GeolocationService
|
|
36
|
+
# Private/internal IP ranges that should not be geolocated
|
|
37
|
+
PRIVATE_IP_RANGES = [
|
|
38
|
+
IPAddr.new('10.0.0.0/8'), # RFC 1918 - Private networks
|
|
39
|
+
IPAddr.new('172.16.0.0/12'), # RFC 1918 - Private networks
|
|
40
|
+
IPAddr.new('192.168.0.0/16'), # RFC 1918 - Private networks
|
|
41
|
+
IPAddr.new('127.0.0.0/8'), # Loopback
|
|
42
|
+
IPAddr.new('169.254.0.0/16'), # Link-local
|
|
43
|
+
IPAddr.new('224.0.0.0/4'), # Multicast
|
|
44
|
+
IPAddr.new('::1/128'), # IPv6 loopback
|
|
45
|
+
IPAddr.new('fe80::/10'), # IPv6 link-local
|
|
46
|
+
IPAddr.new('fc00::/7') # IPv6 unique local
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# Cache TTL for geolocation results (4 hours)
|
|
50
|
+
CACHE_TTL = 4.hours
|
|
51
|
+
|
|
52
|
+
# Thread-safe reader for MaxMind City database
|
|
53
|
+
@city_reader_mutex = Mutex.new
|
|
54
|
+
@city_reader = nil
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
attr_reader :city_reader_mutex
|
|
58
|
+
# Convenience method for one-off location lookup
|
|
59
|
+
#
|
|
60
|
+
# @param ip_address [String] The IP address to locate
|
|
61
|
+
# @return [Hash] Location information
|
|
62
|
+
def locate(ip_address)
|
|
63
|
+
new.locate(ip_address)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if an IP address is private/internal
|
|
67
|
+
#
|
|
68
|
+
# @param ip_address [String] The IP address to check
|
|
69
|
+
# @return [Boolean] true if private/internal IP
|
|
70
|
+
def private_ip?(ip_address)
|
|
71
|
+
return true if ip_address.blank?
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
ip = IPAddr.new(ip_address)
|
|
75
|
+
PRIVATE_IP_RANGES.any? { |range| range.include?(ip) }
|
|
76
|
+
rescue IPAddr::InvalidAddressError
|
|
77
|
+
true # Treat invalid IPs as private
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Calculate distance between two geographic points using Haversine formula
|
|
82
|
+
#
|
|
83
|
+
# @param lat1 [Float] Latitude of first point
|
|
84
|
+
# @param lon1 [Float] Longitude of first point
|
|
85
|
+
# @param lat2 [Float] Latitude of second point
|
|
86
|
+
# @param lon2 [Float] Longitude of second point
|
|
87
|
+
# @return [Float] Distance in kilometers
|
|
88
|
+
def calculate_distance(lat1, lon1, lat2, lon2)
|
|
89
|
+
return 0.0 if lat1.nil? || lon1.nil? || lat2.nil? || lon2.nil?
|
|
90
|
+
|
|
91
|
+
# Convert degrees to radians
|
|
92
|
+
lat1_rad = lat1 * Math::PI / 180
|
|
93
|
+
lon1_rad = lon1 * Math::PI / 180
|
|
94
|
+
lat2_rad = lat2 * Math::PI / 180
|
|
95
|
+
lon2_rad = lon2 * Math::PI / 180
|
|
96
|
+
|
|
97
|
+
# Haversine formula
|
|
98
|
+
dlat = lat2_rad - lat1_rad
|
|
99
|
+
dlon = lon2_rad - lon1_rad
|
|
100
|
+
|
|
101
|
+
a = Math.sin(dlat/2)**2 + Math.cos(lat1_rad) * Math.cos(lat2_rad) * Math.sin(dlon/2)**2
|
|
102
|
+
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
|
|
103
|
+
|
|
104
|
+
# Earth's radius in kilometers
|
|
105
|
+
earth_radius = 6371.0
|
|
106
|
+
earth_radius * c
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get or initialize the MaxMind City database reader
|
|
111
|
+
# Uses thread-safe singleton pattern for efficient database access
|
|
112
|
+
#
|
|
113
|
+
# @return [MaxMindDB::Reader, nil] The reader instance or nil if not configured
|
|
114
|
+
def self.city_reader
|
|
115
|
+
return @city_reader if @city_reader
|
|
116
|
+
return nil unless Beskar.configuration.maxmind_city_db_path
|
|
117
|
+
return nil unless defined?(MaxMindDB)
|
|
118
|
+
|
|
119
|
+
@city_reader_mutex.synchronize do
|
|
120
|
+
return @city_reader if @city_reader
|
|
121
|
+
|
|
122
|
+
db_path = Beskar.configuration.maxmind_city_db_path
|
|
123
|
+
if File.exist?(db_path)
|
|
124
|
+
@city_reader = MaxMindDB.new(db_path)
|
|
125
|
+
Beskar::Logger.info("MaxMind City database loaded from #{db_path}", component: :GeolocationService)
|
|
126
|
+
else
|
|
127
|
+
Beskar::Logger.warn("MaxMind City database not found at #{db_path}", component: :GeolocationService)
|
|
128
|
+
end
|
|
129
|
+
@city_reader
|
|
130
|
+
end
|
|
131
|
+
rescue => e
|
|
132
|
+
Beskar::Logger.error("Failed to load MaxMind City database: #{e.message}", component: :GeolocationService)
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Reset the database reader (useful for testing or reloading configuration)
|
|
137
|
+
def self.reset_readers!
|
|
138
|
+
@city_reader_mutex.synchronize { @city_reader = nil }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Initialize the geolocation service
|
|
142
|
+
#
|
|
143
|
+
# @param provider [Symbol] The geolocation provider to use (:maxmind, :mock)
|
|
144
|
+
def initialize(provider: nil)
|
|
145
|
+
@provider = provider || Beskar.configuration.geolocation_provider
|
|
146
|
+
@cache_key_prefix = "beskar:geolocation"
|
|
147
|
+
@cache_ttl = Beskar.configuration.geolocation_cache_ttl
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Locate an IP address and return geographic information
|
|
151
|
+
#
|
|
152
|
+
# @param ip_address [String] The IP address to locate
|
|
153
|
+
# @return [Hash] Location information with country, city, coordinates, etc.
|
|
154
|
+
def locate(ip_address)
|
|
155
|
+
if self.class.private_ip?(ip_address)
|
|
156
|
+
result = private_ip_result(ip_address)
|
|
157
|
+
result[:provider] = @provider
|
|
158
|
+
return result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Check cache first
|
|
162
|
+
if cached_result = get_cached_location(ip_address)
|
|
163
|
+
return cached_result
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Perform lookup based on provider
|
|
167
|
+
case @provider
|
|
168
|
+
when :maxmind
|
|
169
|
+
result = lookup_maxmind(ip_address)
|
|
170
|
+
when :ip2location
|
|
171
|
+
result = lookup_ip2location(ip_address)
|
|
172
|
+
else
|
|
173
|
+
result = lookup_mock(ip_address)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Cache the result
|
|
177
|
+
cache_location(ip_address, result)
|
|
178
|
+
|
|
179
|
+
result
|
|
180
|
+
rescue => e
|
|
181
|
+
Beskar::Logger.warn("Failed to locate IP #{ip_address}: #{e.message}", component: :GeolocationService)
|
|
182
|
+
unknown_location(ip_address)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Check if travel between two locations is impossible given the time difference
|
|
186
|
+
#
|
|
187
|
+
# @param location1 [Hash] First location with latitude/longitude
|
|
188
|
+
# @param location2 [Hash] Second location with latitude/longitude
|
|
189
|
+
# @param time_diff_seconds [Integer] Time difference in seconds
|
|
190
|
+
# @param max_speed_kmh [Integer] Maximum realistic travel speed in km/h (default: 1000 for commercial flights)
|
|
191
|
+
# @return [Boolean] true if travel is impossible
|
|
192
|
+
def impossible_travel?(location1, location2, time_diff_seconds, max_speed_kmh: 1000)
|
|
193
|
+
return false if location1.nil? || location2.nil?
|
|
194
|
+
return false unless location1[:latitude] && location2[:latitude]
|
|
195
|
+
|
|
196
|
+
distance_km = self.class.calculate_distance(
|
|
197
|
+
location1[:latitude], location1[:longitude],
|
|
198
|
+
location2[:latitude], location2[:longitude]
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Calculate maximum possible distance at given speed
|
|
202
|
+
time_hours = time_diff_seconds / 3600.0
|
|
203
|
+
max_distance_km = max_speed_kmh * time_hours
|
|
204
|
+
|
|
205
|
+
distance_km > max_distance_km
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Calculate risk score based on geolocation factors
|
|
209
|
+
#
|
|
210
|
+
# @param ip_address [String] The IP address
|
|
211
|
+
# @param previous_locations [Array<Hash>] Array of previous location hashes
|
|
212
|
+
# @param time_since_last [Integer] Seconds since last login
|
|
213
|
+
# @return [Integer] Risk score from 0 to 30
|
|
214
|
+
def calculate_location_risk(ip_address, previous_locations = [], time_since_last = nil)
|
|
215
|
+
current_location = locate(ip_address)
|
|
216
|
+
risk = 0
|
|
217
|
+
|
|
218
|
+
# Private/unknown IPs have moderate risk
|
|
219
|
+
return 10 if current_location[:country] == "Unknown" || current_location[:country] == "Private"
|
|
220
|
+
|
|
221
|
+
# Check for impossible travel if we have previous locations
|
|
222
|
+
if previous_locations.any? && time_since_last
|
|
223
|
+
previous_locations.each do |prev_location|
|
|
224
|
+
if impossible_travel?(current_location, prev_location, time_since_last)
|
|
225
|
+
risk += 25
|
|
226
|
+
break
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Country change adds some risk
|
|
232
|
+
if previous_locations.any?
|
|
233
|
+
recent_countries = previous_locations.map { |loc| loc[:country] }.uniq
|
|
234
|
+
risk += 10 unless recent_countries.include?(current_location[:country])
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Known high-risk countries (this would be configurable in production)
|
|
238
|
+
high_risk_countries = ['Unknown']
|
|
239
|
+
risk += 15 if high_risk_countries.include?(current_location[:country])
|
|
240
|
+
|
|
241
|
+
[risk, 30].min # Cap at 30 to leave room for other risk factors
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
# Return result for private/internal IP addresses
|
|
247
|
+
#
|
|
248
|
+
# @param ip_address [String] The private IP address
|
|
249
|
+
# @return [Hash] Location information for private IP
|
|
250
|
+
def private_ip_result(ip_address)
|
|
251
|
+
{
|
|
252
|
+
ip: ip_address,
|
|
253
|
+
country: "Private",
|
|
254
|
+
country_code: nil,
|
|
255
|
+
city: "Local Network",
|
|
256
|
+
latitude: nil,
|
|
257
|
+
longitude: nil,
|
|
258
|
+
timezone: nil,
|
|
259
|
+
provider: @provider,
|
|
260
|
+
private_ip: true
|
|
261
|
+
}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Return result for unknown/unlocatable IP addresses
|
|
265
|
+
#
|
|
266
|
+
# @param ip_address [String] The IP address
|
|
267
|
+
# @return [Hash] Unknown location information
|
|
268
|
+
def unknown_location(ip_address)
|
|
269
|
+
{
|
|
270
|
+
ip: ip_address,
|
|
271
|
+
country: "Unknown",
|
|
272
|
+
country_code: nil,
|
|
273
|
+
city: "Unknown",
|
|
274
|
+
latitude: nil,
|
|
275
|
+
longitude: nil,
|
|
276
|
+
timezone: nil,
|
|
277
|
+
provider: @provider,
|
|
278
|
+
private_ip: false
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Mock geolocation lookup for testing/development
|
|
283
|
+
#
|
|
284
|
+
# @param ip_address [String] The IP address
|
|
285
|
+
# @return [Hash] Mock location information
|
|
286
|
+
def lookup_mock(ip_address)
|
|
287
|
+
# Generate consistent mock data based on IP
|
|
288
|
+
country_codes = ['US', 'CA', 'GB', 'DE', 'FR', 'JP', 'AU']
|
|
289
|
+
cities = ['New York', 'Toronto', 'London', 'Berlin', 'Paris', 'Tokyo', 'Sydney']
|
|
290
|
+
|
|
291
|
+
index = ip_address.bytes.sum % country_codes.length
|
|
292
|
+
|
|
293
|
+
{
|
|
294
|
+
ip: ip_address,
|
|
295
|
+
country: case country_codes[index]
|
|
296
|
+
when 'US' then 'United States'
|
|
297
|
+
when 'CA' then 'Canada'
|
|
298
|
+
when 'GB' then 'United Kingdom'
|
|
299
|
+
when 'DE' then 'Germany'
|
|
300
|
+
when 'FR' then 'France'
|
|
301
|
+
when 'JP' then 'Japan'
|
|
302
|
+
when 'AU' then 'Australia'
|
|
303
|
+
end,
|
|
304
|
+
country_code: country_codes[index],
|
|
305
|
+
city: cities[index],
|
|
306
|
+
latitude: (40.0 + (index * 10)) % 90,
|
|
307
|
+
longitude: (-74.0 + (index * 15)) % 180,
|
|
308
|
+
timezone: "UTC#{index > 3 ? '+' : '-'}#{index + 1}",
|
|
309
|
+
provider: @provider,
|
|
310
|
+
private_ip: false
|
|
311
|
+
}
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Lookup using MaxMind GeoIP2 database
|
|
315
|
+
#
|
|
316
|
+
# @param ip_address [String] The IP address
|
|
317
|
+
# @return [Hash] Location information from MaxMind
|
|
318
|
+
def lookup_maxmind(ip_address)
|
|
319
|
+
result = { ip: ip_address, provider: @provider, private_ip: false }
|
|
320
|
+
|
|
321
|
+
# Lookup city/location data
|
|
322
|
+
if city_reader = self.class.city_reader
|
|
323
|
+
begin
|
|
324
|
+
city_data = city_reader.lookup(ip_address)
|
|
325
|
+
if city_data&.found?
|
|
326
|
+
city_hash = city_data.to_hash
|
|
327
|
+
result.merge!(
|
|
328
|
+
country: city_hash.dig("country", "names", "en") || "Unknown",
|
|
329
|
+
country_code: city_hash.dig("country", "iso_code"),
|
|
330
|
+
city: city_hash.dig("city", "names", "en") || "Unknown",
|
|
331
|
+
latitude: city_hash.dig("location", "latitude"),
|
|
332
|
+
longitude: city_hash.dig("location", "longitude"),
|
|
333
|
+
timezone: city_hash.dig("location", "time_zone"),
|
|
334
|
+
postal_code: city_hash.dig("postal", "code"),
|
|
335
|
+
subdivision: city_hash.dig("subdivisions", 0, "names", "en"),
|
|
336
|
+
subdivision_code: city_hash.dig("subdivisions", 0, "iso_code")
|
|
337
|
+
)
|
|
338
|
+
else
|
|
339
|
+
result.merge!(unknown_location(ip_address).except(:ip, :provider, :private_ip))
|
|
340
|
+
end
|
|
341
|
+
rescue => e
|
|
342
|
+
Beskar::Logger.warn("MaxMind City lookup failed for #{ip_address}: #{e.message}", component: :GeolocationService)
|
|
343
|
+
result.merge!(unknown_location(ip_address).except(:ip, :provider, :private_ip))
|
|
344
|
+
end
|
|
345
|
+
else
|
|
346
|
+
# No city database configured, return basic unknown location
|
|
347
|
+
result.merge!(unknown_location(ip_address).except(:ip, :provider, :private_ip))
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
result
|
|
351
|
+
rescue => e
|
|
352
|
+
Beskar::Logger.error("MaxMind lookup failed for #{ip_address}: #{e.message}", component: :GeolocationService)
|
|
353
|
+
unknown_location(ip_address)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Lookup using IP2Location database
|
|
357
|
+
#
|
|
358
|
+
# @param ip_address [String] The IP address
|
|
359
|
+
# @return [Hash] Location information from IP2Location
|
|
360
|
+
def lookup_ip2location(ip_address)
|
|
361
|
+
# This would integrate with IP2Location in production
|
|
362
|
+
# For now, return unknown result
|
|
363
|
+
result = unknown_location(ip_address)
|
|
364
|
+
result[:provider] = @provider
|
|
365
|
+
result
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Get cached location result
|
|
369
|
+
#
|
|
370
|
+
# @param ip_address [String] The IP address
|
|
371
|
+
# @return [Hash, nil] Cached location or nil
|
|
372
|
+
def get_cached_location(ip_address)
|
|
373
|
+
cache_key = "#{@cache_key_prefix}:#{ip_address}"
|
|
374
|
+
Rails.cache.read(cache_key)
|
|
375
|
+
rescue => e
|
|
376
|
+
Beskar::Logger.debug("Cache read failed: #{e.message}", component: :GeolocationService)
|
|
377
|
+
nil
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Cache location result
|
|
381
|
+
#
|
|
382
|
+
# @param ip_address [String] The IP address
|
|
383
|
+
# @param result [Hash] The location result to cache
|
|
384
|
+
def cache_location(ip_address, result)
|
|
385
|
+
cache_key = "#{@cache_key_prefix}:#{ip_address}"
|
|
386
|
+
Rails.cache.write(cache_key, result, expires_in: @cache_ttl)
|
|
387
|
+
rescue => e
|
|
388
|
+
Beskar::Logger.debug("Cache write failed: #{e.message}", component: :GeolocationService)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|