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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +987 -21
  4. data/app/controllers/beskar/application_controller.rb +170 -0
  5. data/app/controllers/beskar/banned_ips_controller.rb +280 -0
  6. data/app/controllers/beskar/dashboard_controller.rb +70 -0
  7. data/app/controllers/beskar/security_events_controller.rb +182 -0
  8. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +70 -0
  9. data/app/models/beskar/banned_ip.rb +193 -0
  10. data/app/models/beskar/security_event.rb +64 -0
  11. data/app/services/beskar/banned_ip_manager.rb +78 -0
  12. data/app/views/beskar/banned_ips/edit.html.erb +259 -0
  13. data/app/views/beskar/banned_ips/index.html.erb +361 -0
  14. data/app/views/beskar/banned_ips/new.html.erb +310 -0
  15. data/app/views/beskar/banned_ips/show.html.erb +310 -0
  16. data/app/views/beskar/dashboard/index.html.erb +280 -0
  17. data/app/views/beskar/security_events/index.html.erb +309 -0
  18. data/app/views/beskar/security_events/show.html.erb +307 -0
  19. data/app/views/layouts/beskar/application.html.erb +647 -5
  20. data/config/locales/en.yml +10 -0
  21. data/config/routes.rb +41 -0
  22. data/db/migrate/20251016000001_create_beskar_security_events.rb +25 -0
  23. data/db/migrate/20251016000002_create_beskar_banned_ips.rb +23 -0
  24. data/lib/beskar/configuration.rb +214 -0
  25. data/lib/beskar/engine.rb +105 -0
  26. data/lib/beskar/logger.rb +293 -0
  27. data/lib/beskar/middleware/request_analyzer.rb +305 -0
  28. data/lib/beskar/middleware.rb +4 -0
  29. data/lib/beskar/models/security_trackable.rb +25 -0
  30. data/lib/beskar/models/security_trackable_authenticable.rb +167 -0
  31. data/lib/beskar/models/security_trackable_devise.rb +82 -0
  32. data/lib/beskar/models/security_trackable_generic.rb +355 -0
  33. data/lib/beskar/services/account_locker.rb +263 -0
  34. data/lib/beskar/services/device_detector.rb +250 -0
  35. data/lib/beskar/services/geolocation_service.rb +392 -0
  36. data/lib/beskar/services/ip_whitelist.rb +113 -0
  37. data/lib/beskar/services/rate_limiter.rb +257 -0
  38. data/lib/beskar/services/waf.rb +551 -0
  39. data/lib/beskar/version.rb +1 -1
  40. data/lib/beskar.rb +32 -1
  41. data/lib/generators/beskar/install/install_generator.rb +158 -0
  42. data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
  43. data/lib/tasks/beskar_tasks.rake +121 -4
  44. 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