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,70 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Controllers
|
|
3
|
+
# Controller concern for tracking Rails 8 authentication events
|
|
4
|
+
#
|
|
5
|
+
# Usage in SessionsController:
|
|
6
|
+
# class SessionsController < ApplicationController
|
|
7
|
+
# include Beskar::Controllers::SecurityTracking
|
|
8
|
+
#
|
|
9
|
+
# def create
|
|
10
|
+
# if user = User.authenticate_by(params.permit(:email_address, :password))
|
|
11
|
+
# track_authentication_success(user)
|
|
12
|
+
# start_new_session_for user
|
|
13
|
+
# redirect_to after_authentication_url
|
|
14
|
+
# else
|
|
15
|
+
# track_authentication_failure(User, :user)
|
|
16
|
+
# redirect_to new_session_path, alert: "Try another email address or password."
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
module SecurityTracking
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Track successful authentication for a user
|
|
26
|
+
# This should be called after verifying credentials but before creating session
|
|
27
|
+
def track_authentication_success(user)
|
|
28
|
+
return unless user
|
|
29
|
+
return unless Beskar.configuration.track_successful_logins?
|
|
30
|
+
|
|
31
|
+
user.track_authentication_event(request, :success)
|
|
32
|
+
Beskar::Logger.info("Tracked successful authentication for user #{user.id}")
|
|
33
|
+
rescue => e
|
|
34
|
+
Beskar::Logger.error("Failed to track authentication success: #{e.message}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Track failed authentication attempt
|
|
38
|
+
# This should be called when authentication fails
|
|
39
|
+
def track_authentication_failure(model_class, scope = :user)
|
|
40
|
+
return unless Beskar.configuration.track_failed_logins?
|
|
41
|
+
|
|
42
|
+
model_class.track_failed_authentication(request, scope)
|
|
43
|
+
Beskar::Logger.info("Tracked failed authentication for scope #{scope}")
|
|
44
|
+
rescue => e
|
|
45
|
+
Beskar::Logger.error("Failed to track authentication failure: #{e.message}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Track logout event
|
|
49
|
+
def track_logout(user)
|
|
50
|
+
return unless user
|
|
51
|
+
return unless Beskar.configuration.security_tracking_enabled?
|
|
52
|
+
|
|
53
|
+
user.security_events.create!(
|
|
54
|
+
event_type: "logout",
|
|
55
|
+
ip_address: request.ip,
|
|
56
|
+
user_agent: request.user_agent,
|
|
57
|
+
metadata: {
|
|
58
|
+
timestamp: Time.current.iso8601,
|
|
59
|
+
session_id: request.session.id,
|
|
60
|
+
request_path: request.path
|
|
61
|
+
},
|
|
62
|
+
risk_score: 0
|
|
63
|
+
)
|
|
64
|
+
Beskar::Logger.info("Tracked logout for user #{user.id}")
|
|
65
|
+
rescue => e
|
|
66
|
+
Beskar::Logger.error("Failed to track logout: #{e.message}")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
class BannedIp < ApplicationRecord
|
|
3
|
+
# Serialize metadata as JSON
|
|
4
|
+
serialize :metadata, coder: JSON
|
|
5
|
+
|
|
6
|
+
validates :ip_address, presence: true, uniqueness: true
|
|
7
|
+
validates :reason, presence: true
|
|
8
|
+
validates :banned_at, presence: true
|
|
9
|
+
|
|
10
|
+
# Ensure metadata is always a hash
|
|
11
|
+
after_initialize do
|
|
12
|
+
self.metadata ||= {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Cache management callbacks
|
|
16
|
+
after_save :update_cache
|
|
17
|
+
after_destroy :clear_cache
|
|
18
|
+
|
|
19
|
+
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
|
20
|
+
scope :permanent, -> { where(permanent: true) }
|
|
21
|
+
scope :temporary, -> { where(permanent: false) }
|
|
22
|
+
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
|
|
23
|
+
scope :by_reason, ->(reason) { where(reason: reason) }
|
|
24
|
+
|
|
25
|
+
# Check if a ban is currently active
|
|
26
|
+
def active?
|
|
27
|
+
permanent? || (expires_at.present? && expires_at > Time.current)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Check if ban has expired
|
|
31
|
+
def expired?
|
|
32
|
+
!permanent? && expires_at.present? && expires_at <= Time.current
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Extend ban duration (for repeat offenders)
|
|
36
|
+
def extend_ban!(additional_time = nil)
|
|
37
|
+
self.violation_count += 1
|
|
38
|
+
|
|
39
|
+
if permanent?
|
|
40
|
+
# Already permanent, just increment violation count
|
|
41
|
+
save!
|
|
42
|
+
elsif additional_time
|
|
43
|
+
self.expires_at = [expires_at || Time.current, Time.current].max + additional_time
|
|
44
|
+
save!
|
|
45
|
+
else
|
|
46
|
+
# Calculate exponential backoff based on violation count
|
|
47
|
+
# 1 hour, 6 hours, 24 hours, 7 days, permanent
|
|
48
|
+
duration = case violation_count
|
|
49
|
+
when 1 then 1.hour
|
|
50
|
+
when 2 then 6.hours
|
|
51
|
+
when 3 then 24.hours
|
|
52
|
+
when 4 then 7.days
|
|
53
|
+
else
|
|
54
|
+
self.permanent = true
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if duration
|
|
59
|
+
self.expires_at = Time.current + duration
|
|
60
|
+
end
|
|
61
|
+
save!
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Unban an IP address
|
|
66
|
+
def unban!
|
|
67
|
+
destroy
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Class methods for ban management
|
|
71
|
+
class << self
|
|
72
|
+
# Ban an IP address
|
|
73
|
+
def ban!(ip_address, reason:, duration: nil, permanent: false, details: nil, metadata: {})
|
|
74
|
+
# Retry logic to handle race conditions when multiple requests try to ban the same IP
|
|
75
|
+
retries = 0
|
|
76
|
+
max_retries = 2
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
banned_ip = find_or_initialize_by(ip_address: ip_address)
|
|
80
|
+
|
|
81
|
+
if banned_ip.persisted?
|
|
82
|
+
# Existing ban - extend it
|
|
83
|
+
banned_ip.extend_ban!(duration)
|
|
84
|
+
banned_ip.details = details if details
|
|
85
|
+
# Deep stringify keys to avoid duplicate key issues
|
|
86
|
+
if metadata.any?
|
|
87
|
+
banned_ip.metadata = banned_ip.metadata.deep_stringify_keys.merge(metadata.deep_stringify_keys)
|
|
88
|
+
end
|
|
89
|
+
banned_ip.save!
|
|
90
|
+
else
|
|
91
|
+
# New ban
|
|
92
|
+
banned_ip.assign_attributes(
|
|
93
|
+
reason: reason,
|
|
94
|
+
banned_at: Time.current,
|
|
95
|
+
expires_at: permanent ? nil : (Time.current + (duration || 1.hour)),
|
|
96
|
+
permanent: permanent,
|
|
97
|
+
details: details,
|
|
98
|
+
metadata: metadata
|
|
99
|
+
)
|
|
100
|
+
banned_ip.save!
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Update cache
|
|
104
|
+
cache_key = "beskar:banned_ip:#{ip_address}"
|
|
105
|
+
Rails.cache.write(cache_key, true, expires_in: permanent ? nil : (duration || 1.hour))
|
|
106
|
+
|
|
107
|
+
banned_ip
|
|
108
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
109
|
+
# Race condition: another request created the record between find and save
|
|
110
|
+
if e.message.include?("Ip address has already been taken") && retries < max_retries
|
|
111
|
+
retries += 1
|
|
112
|
+
# Small random delay to reduce contention (1-10ms)
|
|
113
|
+
sleep(rand(1..10) / 1000.0)
|
|
114
|
+
retry
|
|
115
|
+
else
|
|
116
|
+
# Re-raise if it's a different validation error or we've exceeded retries
|
|
117
|
+
raise
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Check if an IP is banned (cache-first approach)
|
|
123
|
+
def banned?(ip_address)
|
|
124
|
+
# Check cache first for performance
|
|
125
|
+
cache_key = "beskar:banned_ip:#{ip_address}"
|
|
126
|
+
cached_result = Rails.cache.read(cache_key)
|
|
127
|
+
return true if cached_result == true
|
|
128
|
+
return false if cached_result == false
|
|
129
|
+
|
|
130
|
+
# Check database
|
|
131
|
+
banned_record = active.find_by(ip_address: ip_address)
|
|
132
|
+
is_banned = banned_record&.active? || false
|
|
133
|
+
|
|
134
|
+
# Update cache
|
|
135
|
+
if is_banned && banned_record
|
|
136
|
+
ttl = banned_record.permanent? ? 30.days : (banned_record.expires_at - Time.current).to_i
|
|
137
|
+
Rails.cache.write(cache_key, true, expires_in: ttl)
|
|
138
|
+
else
|
|
139
|
+
Rails.cache.write(cache_key, false, expires_in: 5.minutes)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
is_banned
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Unban an IP address
|
|
146
|
+
def unban!(ip_address)
|
|
147
|
+
banned_ip = find_by(ip_address: ip_address)
|
|
148
|
+
if banned_ip
|
|
149
|
+
banned_ip.destroy
|
|
150
|
+
# Clear cache
|
|
151
|
+
Rails.cache.delete("beskar:banned_ip:#{ip_address}")
|
|
152
|
+
true
|
|
153
|
+
else
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Load all active bans into cache (called on app startup)
|
|
159
|
+
def preload_cache!
|
|
160
|
+
active.find_each do |banned_ip|
|
|
161
|
+
cache_key = "beskar:banned_ip:#{banned_ip.ip_address}"
|
|
162
|
+
ttl = banned_ip.permanent? ? 30.days : [(banned_ip.expires_at - Time.current).to_i, 60].max
|
|
163
|
+
Rails.cache.write(cache_key, true, expires_in: ttl)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Clean up expired bans
|
|
168
|
+
def cleanup_expired!
|
|
169
|
+
expired.destroy_all
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def update_cache
|
|
176
|
+
return unless active?
|
|
177
|
+
|
|
178
|
+
cache_key = "beskar:banned_ip:#{ip_address}"
|
|
179
|
+
ttl = calculate_cache_ttl
|
|
180
|
+
Rails.cache.write(cache_key, true, expires_in: ttl)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def clear_cache
|
|
184
|
+
Rails.cache.delete("beskar:banned_ip:#{ip_address}")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def calculate_cache_ttl
|
|
188
|
+
return nil if permanent? || expires_at.nil?
|
|
189
|
+
|
|
190
|
+
(expires_at - Time.current).to_i
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
class SecurityEvent < ApplicationRecord
|
|
3
|
+
belongs_to :user, polymorphic: true, optional: true
|
|
4
|
+
|
|
5
|
+
validates :event_type, presence: true
|
|
6
|
+
validates :ip_address, presence: true
|
|
7
|
+
validates :risk_score, numericality: {in: 0..100}
|
|
8
|
+
|
|
9
|
+
scope :login_failures, -> { where(event_type: "login_failure") }
|
|
10
|
+
scope :login_successes, -> { where(event_type: "login_success") }
|
|
11
|
+
scope :recent, ->(time = 1.hour.ago) { where("created_at >= ?", time) }
|
|
12
|
+
scope :by_ip, ->(ip) { where(ip_address: ip) }
|
|
13
|
+
scope :high_risk, -> { where("risk_score >= ?", 70) }
|
|
14
|
+
scope :critical_risk, -> { where("risk_score >= ?", 90) }
|
|
15
|
+
|
|
16
|
+
def critical_threat?
|
|
17
|
+
risk_score >= 90
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def high_risk?
|
|
21
|
+
risk_score >= 70
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def login_failure?
|
|
25
|
+
event_type == "login_failure"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def login_success?
|
|
29
|
+
event_type == "login_success"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def attempted_email
|
|
33
|
+
read_attribute(:attempted_email) || metadata&.dig("attempted_email")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def attempted_email=(value)
|
|
37
|
+
write_attribute(:attempted_email, value)
|
|
38
|
+
# Also store in metadata for backwards compatibility
|
|
39
|
+
self.metadata = (metadata || {}).merge("attempted_email" => value) if value.present?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def device_info
|
|
43
|
+
metadata&.dig("device_info") || {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def geolocation
|
|
47
|
+
metadata&.dig("geolocation") || {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def details
|
|
51
|
+
# Extract details from metadata if available
|
|
52
|
+
# Check multiple possible fields where details might be stored
|
|
53
|
+
return nil unless metadata.present?
|
|
54
|
+
|
|
55
|
+
metadata["details"] ||
|
|
56
|
+
metadata["description"] ||
|
|
57
|
+
metadata["message"] ||
|
|
58
|
+
metadata["reason"] ||
|
|
59
|
+
metadata["error"] ||
|
|
60
|
+
metadata["info"] ||
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
class BannedIpManager
|
|
3
|
+
attr_reader :banned_ip, :errors
|
|
4
|
+
|
|
5
|
+
def initialize(params)
|
|
6
|
+
@params = params
|
|
7
|
+
@errors = []
|
|
8
|
+
@banned_ip = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
@banned_ip = BannedIp.new(base_attributes)
|
|
13
|
+
configure_ban_duration
|
|
14
|
+
|
|
15
|
+
@banned_ip.save
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def success?
|
|
19
|
+
@banned_ip&.persisted?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def base_attributes
|
|
25
|
+
{
|
|
26
|
+
ip_address: @params[:ip_address],
|
|
27
|
+
reason: @params[:reason],
|
|
28
|
+
details: @params[:details],
|
|
29
|
+
violation_count: @params[:violation_count] || 1,
|
|
30
|
+
metadata: @params[:metadata] || {},
|
|
31
|
+
banned_at: Time.current
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def configure_ban_duration
|
|
36
|
+
return set_permanent_ban if permanent_ban?
|
|
37
|
+
|
|
38
|
+
set_temporary_ban
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def permanent_ban?
|
|
42
|
+
@params[:ban_type] == 'permanent'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def set_permanent_ban
|
|
46
|
+
@banned_ip.permanent = true
|
|
47
|
+
@banned_ip.expires_at = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def set_temporary_ban
|
|
51
|
+
@banned_ip.permanent = false
|
|
52
|
+
@banned_ip.expires_at = calculate_expiry_time
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def calculate_expiry_time
|
|
56
|
+
return custom_expiry_time if custom_expiry_time.present?
|
|
57
|
+
return preset_duration_expiry if preset_duration.present?
|
|
58
|
+
|
|
59
|
+
default_expiry_time
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def custom_expiry_time
|
|
63
|
+
@params[:expires_at]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def preset_duration
|
|
67
|
+
@params[:duration]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def preset_duration_expiry
|
|
71
|
+
Time.current + preset_duration.to_i.seconds
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def default_expiry_time
|
|
75
|
+
24.hours.from_now
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
<% content_for :page_title, "Edit Ban: #{@banned_ip.ip_address}" %>
|
|
2
|
+
|
|
3
|
+
<% content_for :header_actions do %>
|
|
4
|
+
<%= link_to "← Cancel", banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
|
|
5
|
+
<% end %>
|
|
6
|
+
|
|
7
|
+
<div class="card">
|
|
8
|
+
<div class="card-header">
|
|
9
|
+
<h3 class="card-title">Edit IP Ban</h3>
|
|
10
|
+
<div class="card-subtitle">Modify ban settings for <%= @banned_ip.ip_address %></div>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="card-body">
|
|
13
|
+
<%= form_with model: @banned_ip, url: banned_ip_path(@banned_ip), method: :patch, local: true do |f| %>
|
|
14
|
+
<% if @banned_ip.errors.any? %>
|
|
15
|
+
<div class="alert alert-danger">
|
|
16
|
+
<h4 style="font-size: 14px; font-weight: 600; margin-bottom: 0.5rem;">
|
|
17
|
+
<%= pluralize(@banned_ip.errors.count, "error") %> prohibited this ban from being saved:
|
|
18
|
+
</h4>
|
|
19
|
+
<ul style="margin: 0; padding-left: 1.5rem;">
|
|
20
|
+
<% @banned_ip.errors.full_messages.each do |message| %>
|
|
21
|
+
<li style="font-size: 13px;"><%= message %></li>
|
|
22
|
+
<% end %>
|
|
23
|
+
</ul>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<div style="display: grid; grid-template-columns: 1fr; gap: 1.5rem; max-width: 600px;">
|
|
28
|
+
<!-- IP Address (Read-only) -->
|
|
29
|
+
<div class="form-group">
|
|
30
|
+
<%= f.label :ip_address, "IP Address" %>
|
|
31
|
+
<%= f.text_field :ip_address,
|
|
32
|
+
disabled: true,
|
|
33
|
+
style: "font-family: monospace; background: #F4F5F7; cursor: not-allowed;" %>
|
|
34
|
+
<div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
|
|
35
|
+
IP addresses cannot be changed. To ban a different IP, create a new ban.
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<!-- Current Status -->
|
|
40
|
+
<div class="form-group">
|
|
41
|
+
<label>Current Status</label>
|
|
42
|
+
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
43
|
+
<% if @banned_ip.permanent? %>
|
|
44
|
+
<span class="badge badge-critical">Permanent Ban</span>
|
|
45
|
+
<% elsif @banned_ip.active? %>
|
|
46
|
+
<span class="badge badge-danger">Active</span>
|
|
47
|
+
<span style="font-size: 13px; color: #697386;">
|
|
48
|
+
Expires in <%= distance_of_time_in_words(Time.current, @banned_ip.expires_at) %>
|
|
49
|
+
</span>
|
|
50
|
+
<% else %>
|
|
51
|
+
<span class="badge badge-neutral">Expired</span>
|
|
52
|
+
<span style="font-size: 13px; color: #697386;">
|
|
53
|
+
Expired <%= time_ago_in_words(@banned_ip.expires_at) %> ago
|
|
54
|
+
</span>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<!-- Reason -->
|
|
60
|
+
<div class="form-group">
|
|
61
|
+
<%= f.label :reason, "Ban Reason *" %>
|
|
62
|
+
<%= f.select :reason,
|
|
63
|
+
options_for_select([
|
|
64
|
+
['Rate Limit Abuse', 'rate_limit_abuse'],
|
|
65
|
+
['Authentication Abuse', 'authentication_abuse'],
|
|
66
|
+
['WAF Violation', 'waf_violation'],
|
|
67
|
+
['Brute Force Attack', 'brute_force_attack'],
|
|
68
|
+
['Suspicious Activity', 'suspicious_activity'],
|
|
69
|
+
['Manual Ban', 'manual_ban'],
|
|
70
|
+
['Other', 'other']
|
|
71
|
+
], @banned_ip.reason),
|
|
72
|
+
{ prompt: 'Select a reason...' },
|
|
73
|
+
{ required: true } %>
|
|
74
|
+
<div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
|
|
75
|
+
Update the reason for this ban if needed.
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<!-- Ban Duration -->
|
|
80
|
+
<div class="form-group">
|
|
81
|
+
<label>Ban Duration</label>
|
|
82
|
+
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
|
83
|
+
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
|
84
|
+
<%= f.radio_button :permanent, false,
|
|
85
|
+
onchange: "toggleDurationFields()",
|
|
86
|
+
checked: !@banned_ip.permanent? %>
|
|
87
|
+
<span style="font-weight: normal;">Temporary Ban</span>
|
|
88
|
+
</label>
|
|
89
|
+
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
|
90
|
+
<%= f.radio_button :permanent, true,
|
|
91
|
+
onchange: "toggleDurationFields()",
|
|
92
|
+
checked: @banned_ip.permanent? %>
|
|
93
|
+
<span style="font-weight: normal; color: #D32F2F;">Permanent Ban</span>
|
|
94
|
+
</label>
|
|
95
|
+
</div>
|
|
96
|
+
<% if @banned_ip.permanent? %>
|
|
97
|
+
<div class="alert alert-warning" style="margin-top: 0.5rem;">
|
|
98
|
+
<strong>Warning:</strong> This is currently a permanent ban. Changing it to temporary will set an expiration date.
|
|
99
|
+
</div>
|
|
100
|
+
<% end %>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<!-- Temporary Ban Options -->
|
|
104
|
+
<div id="temporary-options" style="<%= @banned_ip.permanent? ? 'display: none;' : '' %>">
|
|
105
|
+
<div class="form-group">
|
|
106
|
+
<%= f.label :expires_at, "Expiry Date/Time" %>
|
|
107
|
+
<%= f.datetime_field :expires_at,
|
|
108
|
+
value: @banned_ip.expires_at&.strftime('%Y-%m-%dT%H:%M'),
|
|
109
|
+
min: DateTime.now.strftime('%Y-%m-%dT%H:%M') %>
|
|
110
|
+
<div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
|
|
111
|
+
Set when this ban should expire. Leave empty for default duration.
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<!-- Quick Extension Options -->
|
|
116
|
+
<div class="form-group">
|
|
117
|
+
<label>Quick Extension</label>
|
|
118
|
+
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem;">
|
|
119
|
+
<button type="button" onclick="extendBan(1)" class="btn btn-sm btn-secondary">+1 Hour</button>
|
|
120
|
+
<button type="button" onclick="extendBan(24)" class="btn btn-sm btn-secondary">+1 Day</button>
|
|
121
|
+
<button type="button" onclick="extendBan(168)" class="btn btn-sm btn-secondary">+7 Days</button>
|
|
122
|
+
<button type="button" onclick="extendBan(720)" class="btn btn-sm btn-secondary">+30 Days</button>
|
|
123
|
+
<button type="button" onclick="extendBan(2160)" class="btn btn-sm btn-secondary">+90 Days</button>
|
|
124
|
+
<button type="button" onclick="makePermanent()" class="btn btn-sm btn-danger">Make Permanent</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<!-- Details -->
|
|
130
|
+
<div class="form-group">
|
|
131
|
+
<%= f.label :details, "Additional Details" %>
|
|
132
|
+
<%= f.text_area :details,
|
|
133
|
+
rows: 3,
|
|
134
|
+
placeholder: "Provide any additional context or details about this ban..." %>
|
|
135
|
+
<div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
|
|
136
|
+
Update any relevant information about why this IP is banned.
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<!-- Violation Count -->
|
|
141
|
+
<div class="form-group">
|
|
142
|
+
<%= f.label :violation_count, "Violation Count" %>
|
|
143
|
+
<%= f.number_field :violation_count, min: 0 %>
|
|
144
|
+
<div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
|
|
145
|
+
Current violation count: <%= @banned_ip.violation_count %>. Higher counts may result in longer bans for repeat offenders.
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<hr style="border: none; border-top: 1px solid #E3E8EE; margin: 1.5rem 0;">
|
|
150
|
+
|
|
151
|
+
<!-- Ban History -->
|
|
152
|
+
<div class="form-group">
|
|
153
|
+
<label>Ban History</label>
|
|
154
|
+
<div style="background: #FAFBFC; border: 1px solid #E3E8EE; border-radius: 6px; padding: 1rem;">
|
|
155
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem;">
|
|
156
|
+
<div>
|
|
157
|
+
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
|
|
158
|
+
Originally Banned
|
|
159
|
+
</div>
|
|
160
|
+
<div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
|
|
161
|
+
<%= @banned_ip.banned_at.strftime("%Y-%m-%d %H:%M:%S") %>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<div>
|
|
165
|
+
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
|
|
166
|
+
Time Banned
|
|
167
|
+
</div>
|
|
168
|
+
<div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
|
|
169
|
+
<%= time_ago_in_words(@banned_ip.banned_at) %>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
<% if @banned_ip.updated_at != @banned_ip.created_at %>
|
|
173
|
+
<div>
|
|
174
|
+
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
|
|
175
|
+
Last Modified
|
|
176
|
+
</div>
|
|
177
|
+
<div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
|
|
178
|
+
<%= @banned_ip.updated_at.strftime("%Y-%m-%d %H:%M:%S") %>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
<% end %>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<!-- Submit Buttons -->
|
|
187
|
+
<div style="display: flex; gap: 0.5rem;">
|
|
188
|
+
<%= f.submit "Update Ban", class: "btn btn-primary" %>
|
|
189
|
+
<%= link_to "Cancel", banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
|
|
190
|
+
<%= link_to "Delete Ban", banned_ip_path(@banned_ip),
|
|
191
|
+
data: {
|
|
192
|
+
"turbo-method": "delete",
|
|
193
|
+
"turbo-confirm": "Are you sure you want to unban #{@banned_ip.ip_address}?"
|
|
194
|
+
},
|
|
195
|
+
class: "btn btn-danger" %>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
<% end %>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<script>
|
|
203
|
+
function toggleDurationFields() {
|
|
204
|
+
const isPermanent = document.querySelector('input[name="banned_ip[permanent]"]:checked').value === 'true';
|
|
205
|
+
const temporaryOptions = document.getElementById('temporary-options');
|
|
206
|
+
|
|
207
|
+
if (isPermanent) {
|
|
208
|
+
temporaryOptions.style.display = 'none';
|
|
209
|
+
document.getElementById('banned_ip_expires_at').value = '';
|
|
210
|
+
} else {
|
|
211
|
+
temporaryOptions.style.display = 'block';
|
|
212
|
+
// If switching from permanent to temporary, set a default expiry
|
|
213
|
+
if (!document.getElementById('banned_ip_expires_at').value) {
|
|
214
|
+
const defaultExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours from now
|
|
215
|
+
document.getElementById('banned_ip_expires_at').value = defaultExpiry.toISOString().slice(0, 16);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function extendBan(hours) {
|
|
221
|
+
const expiresField = document.getElementById('banned_ip_expires_at');
|
|
222
|
+
let currentExpiry;
|
|
223
|
+
|
|
224
|
+
if (expiresField.value) {
|
|
225
|
+
currentExpiry = new Date(expiresField.value);
|
|
226
|
+
} else {
|
|
227
|
+
// Use current ban expiry or current time
|
|
228
|
+
<% if @banned_ip.expires_at %>
|
|
229
|
+
currentExpiry = new Date('<%= @banned_ip.expires_at.iso8601 %>');
|
|
230
|
+
<% else %>
|
|
231
|
+
currentExpiry = new Date();
|
|
232
|
+
<% end %>
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Add the specified hours
|
|
236
|
+
currentExpiry.setHours(currentExpiry.getHours() + hours);
|
|
237
|
+
|
|
238
|
+
// Update the field
|
|
239
|
+
expiresField.value = currentExpiry.toISOString().slice(0, 16);
|
|
240
|
+
|
|
241
|
+
// Show a visual feedback
|
|
242
|
+
const originalBg = expiresField.style.background;
|
|
243
|
+
expiresField.style.background = '#E8F5E9';
|
|
244
|
+
setTimeout(() => {
|
|
245
|
+
expiresField.style.background = originalBg;
|
|
246
|
+
}, 500);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function makePermanent() {
|
|
250
|
+
document.querySelector('input[name="banned_ip[permanent]"][value="true"]').checked = true;
|
|
251
|
+
toggleDurationFields();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Initialize on page load
|
|
255
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
256
|
+
// Set initial state
|
|
257
|
+
toggleDurationFields();
|
|
258
|
+
});
|
|
259
|
+
</script>
|