beskar 0.0.1 → 0.0.2
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/README.md +796 -18
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +70 -0
- data/app/models/beskar/banned_ip.rb +152 -0
- data/app/models/beskar/security_event.rb +50 -0
- data/config/locales/en.yml +10 -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 +200 -0
- data/lib/beskar/engine.rb +105 -0
- data/lib/beskar/middleware/request_analyzer.rb +230 -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 +322 -0
- data/lib/beskar/templates/beskar_initializer.rb +107 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar.rb +31 -1
- data/lib/tasks/beskar_tasks.rake +112 -4
- metadata +108 -4
|
@@ -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
|
+
Rails.logger.info "[Beskar] Tracked successful authentication for user #{user.id}"
|
|
33
|
+
rescue => e
|
|
34
|
+
Rails.logger.error "[Beskar] 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
|
+
Rails.logger.info "[Beskar] Tracked failed authentication for scope #{scope}"
|
|
44
|
+
rescue => e
|
|
45
|
+
Rails.logger.error "[Beskar] 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
|
+
Rails.logger.info "[Beskar] Tracked logout for user #{user.id}"
|
|
65
|
+
rescue => e
|
|
66
|
+
Rails.logger.error "[Beskar] Failed to track logout: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
|
16
|
+
scope :permanent, -> { where(permanent: true) }
|
|
17
|
+
scope :temporary, -> { where(permanent: false) }
|
|
18
|
+
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
|
|
19
|
+
scope :by_reason, ->(reason) { where(reason: reason) }
|
|
20
|
+
|
|
21
|
+
# Check if a ban is currently active
|
|
22
|
+
def active?
|
|
23
|
+
permanent? || (expires_at.present? && expires_at > Time.current)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Check if ban has expired
|
|
27
|
+
def expired?
|
|
28
|
+
!permanent? && expires_at.present? && expires_at <= Time.current
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Extend ban duration (for repeat offenders)
|
|
32
|
+
def extend_ban!(additional_time = nil)
|
|
33
|
+
self.violation_count += 1
|
|
34
|
+
|
|
35
|
+
if permanent?
|
|
36
|
+
# Already permanent, just increment violation count
|
|
37
|
+
save!
|
|
38
|
+
elsif additional_time
|
|
39
|
+
self.expires_at = [expires_at || Time.current, Time.current].max + additional_time
|
|
40
|
+
save!
|
|
41
|
+
else
|
|
42
|
+
# Calculate exponential backoff based on violation count
|
|
43
|
+
# 1 hour, 6 hours, 24 hours, 7 days, permanent
|
|
44
|
+
duration = case violation_count
|
|
45
|
+
when 1 then 1.hour
|
|
46
|
+
when 2 then 6.hours
|
|
47
|
+
when 3 then 24.hours
|
|
48
|
+
when 4 then 7.days
|
|
49
|
+
else
|
|
50
|
+
self.permanent = true
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if duration
|
|
55
|
+
self.expires_at = Time.current + duration
|
|
56
|
+
end
|
|
57
|
+
save!
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Unban an IP address
|
|
62
|
+
def unban!
|
|
63
|
+
destroy
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Class methods for ban management
|
|
67
|
+
class << self
|
|
68
|
+
# Ban an IP address
|
|
69
|
+
def ban!(ip_address, reason:, duration: nil, permanent: false, details: nil, metadata: {})
|
|
70
|
+
banned_ip = find_or_initialize_by(ip_address: ip_address)
|
|
71
|
+
|
|
72
|
+
if banned_ip.persisted?
|
|
73
|
+
# Existing ban - extend it
|
|
74
|
+
banned_ip.extend_ban!(duration)
|
|
75
|
+
banned_ip.details = details if details
|
|
76
|
+
# Deep stringify keys to avoid duplicate key issues
|
|
77
|
+
if metadata.any?
|
|
78
|
+
banned_ip.metadata = banned_ip.metadata.deep_stringify_keys.merge(metadata.deep_stringify_keys)
|
|
79
|
+
end
|
|
80
|
+
banned_ip.save!
|
|
81
|
+
else
|
|
82
|
+
# New ban
|
|
83
|
+
banned_ip.assign_attributes(
|
|
84
|
+
reason: reason,
|
|
85
|
+
banned_at: Time.current,
|
|
86
|
+
expires_at: permanent ? nil : (Time.current + (duration || 1.hour)),
|
|
87
|
+
permanent: permanent,
|
|
88
|
+
details: details,
|
|
89
|
+
metadata: metadata
|
|
90
|
+
)
|
|
91
|
+
banned_ip.save!
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Update cache
|
|
95
|
+
cache_key = "beskar:banned_ip:#{ip_address}"
|
|
96
|
+
Rails.cache.write(cache_key, true, expires_in: permanent ? nil : (duration || 1.hour))
|
|
97
|
+
|
|
98
|
+
banned_ip
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Check if an IP is banned (cache-first approach)
|
|
102
|
+
def banned?(ip_address)
|
|
103
|
+
# Check cache first for performance
|
|
104
|
+
cache_key = "beskar:banned_ip:#{ip_address}"
|
|
105
|
+
cached_result = Rails.cache.read(cache_key)
|
|
106
|
+
return true if cached_result == true
|
|
107
|
+
return false if cached_result == false
|
|
108
|
+
|
|
109
|
+
# Check database
|
|
110
|
+
banned_record = active.find_by(ip_address: ip_address)
|
|
111
|
+
is_banned = banned_record&.active? || false
|
|
112
|
+
|
|
113
|
+
# Update cache
|
|
114
|
+
if is_banned && banned_record
|
|
115
|
+
ttl = banned_record.permanent? ? 30.days : (banned_record.expires_at - Time.current).to_i
|
|
116
|
+
Rails.cache.write(cache_key, true, expires_in: ttl)
|
|
117
|
+
else
|
|
118
|
+
Rails.cache.write(cache_key, false, expires_in: 5.minutes)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
is_banned
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Unban an IP address
|
|
125
|
+
def unban!(ip_address)
|
|
126
|
+
banned_ip = find_by(ip_address: ip_address)
|
|
127
|
+
if banned_ip
|
|
128
|
+
banned_ip.destroy
|
|
129
|
+
# Clear cache
|
|
130
|
+
Rails.cache.delete("beskar:banned_ip:#{ip_address}")
|
|
131
|
+
true
|
|
132
|
+
else
|
|
133
|
+
false
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Load all active bans into cache (called on app startup)
|
|
138
|
+
def preload_cache!
|
|
139
|
+
active.find_each do |banned_ip|
|
|
140
|
+
cache_key = "beskar:banned_ip:#{banned_ip.ip_address}"
|
|
141
|
+
ttl = banned_ip.permanent? ? 30.days : [(banned_ip.expires_at - Time.current).to_i, 60].max
|
|
142
|
+
Rails.cache.write(cache_key, true, expires_in: ttl)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Clean up expired bans
|
|
147
|
+
def cleanup_expired!
|
|
148
|
+
expired.destroy_all
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
en:
|
|
2
|
+
devise:
|
|
3
|
+
failure:
|
|
4
|
+
account_locked_due_to_high_risk: "Your account has been locked due to suspicious activity. Please contact support or wait for automatic unlock."
|
|
5
|
+
|
|
6
|
+
beskar:
|
|
7
|
+
account_locking:
|
|
8
|
+
high_risk_detected: "High risk activity detected on your account"
|
|
9
|
+
locked_for_security: "Account locked for security reasons"
|
|
10
|
+
contact_support: "Please contact support to unlock your account"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBeskarSecurityEvents < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :beskar_security_events do |t|
|
|
6
|
+
t.references :user, polymorphic: true, null: true, index: true
|
|
7
|
+
t.string :event_type, null: false
|
|
8
|
+
t.string :ip_address
|
|
9
|
+
t.string :attempted_email
|
|
10
|
+
t.text :user_agent
|
|
11
|
+
t.json :metadata, default: {}
|
|
12
|
+
t.integer :risk_score
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
add_index :beskar_security_events, :ip_address
|
|
18
|
+
add_index :beskar_security_events, :event_type
|
|
19
|
+
add_index :beskar_security_events, :attempted_email
|
|
20
|
+
add_index :beskar_security_events, :created_at
|
|
21
|
+
add_index :beskar_security_events, :risk_score
|
|
22
|
+
add_index :beskar_security_events, [:ip_address, :event_type, :created_at],
|
|
23
|
+
name: 'index_security_events_on_ip_event_time'
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBeskarBannedIps < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :beskar_banned_ips do |t|
|
|
6
|
+
t.string :ip_address, null: false
|
|
7
|
+
t.string :reason, null: false
|
|
8
|
+
t.text :details
|
|
9
|
+
t.datetime :banned_at, null: false
|
|
10
|
+
t.datetime :expires_at
|
|
11
|
+
t.boolean :permanent, default: false, null: false
|
|
12
|
+
t.integer :violation_count, default: 1, null: false
|
|
13
|
+
t.text :metadata # Using text to store JSON (compatible with SQLite and PostgreSQL)
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :beskar_banned_ips, :ip_address, unique: true
|
|
19
|
+
add_index :beskar_banned_ips, :banned_at
|
|
20
|
+
add_index :beskar_banned_ips, :expires_at
|
|
21
|
+
add_index :beskar_banned_ips, [:ip_address, :expires_at]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :rate_limiting, :security_tracking, :risk_based_locking, :geolocation, :ip_whitelist, :waf, :authentication_models, :emergency_password_reset
|
|
4
|
+
|
|
5
|
+
def initialize
|
|
6
|
+
@ip_whitelist = [] # Array of IP addresses or CIDR ranges
|
|
7
|
+
|
|
8
|
+
# Authentication models configuration
|
|
9
|
+
# Auto-detect by default, or can be explicitly configured
|
|
10
|
+
@authentication_models = {
|
|
11
|
+
devise: [], # Will be auto-detected: [:devise_user, :admin, etc.]
|
|
12
|
+
rails_auth: [], # Will be auto-detected: [:user, etc.]
|
|
13
|
+
auto_detect: true # Set to false to use only explicitly configured models
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@waf = {
|
|
17
|
+
enabled: false, # Master switch for WAF
|
|
18
|
+
auto_block: true, # Automatically block IPs after threshold
|
|
19
|
+
block_threshold: 3, # Number of violations before blocking
|
|
20
|
+
violation_window: 1.hour, # Time window to count violations
|
|
21
|
+
block_durations: [ 1.hour, 6.hours, 24.hours, 7.days ], # Escalating block durations
|
|
22
|
+
permanent_block_after: 5, # Permanent block after N violations (nil = never)
|
|
23
|
+
create_security_events: true, # Create SecurityEvent records
|
|
24
|
+
monitor_only: false # If true, log but don't block (even if auto_block is true)
|
|
25
|
+
}
|
|
26
|
+
@security_tracking = {
|
|
27
|
+
enabled: true,
|
|
28
|
+
track_successful_logins: true,
|
|
29
|
+
track_failed_logins: true,
|
|
30
|
+
auto_analyze_patterns: true
|
|
31
|
+
}
|
|
32
|
+
@rate_limiting = {
|
|
33
|
+
ip_attempts: {
|
|
34
|
+
limit: 10,
|
|
35
|
+
period: 1.hour,
|
|
36
|
+
exponential_backoff: true
|
|
37
|
+
},
|
|
38
|
+
account_attempts: {
|
|
39
|
+
limit: 5,
|
|
40
|
+
period: 15.minutes,
|
|
41
|
+
exponential_backoff: true
|
|
42
|
+
},
|
|
43
|
+
global_attempts: {
|
|
44
|
+
limit: 100,
|
|
45
|
+
period: 1.minute,
|
|
46
|
+
exponential_backoff: false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
@risk_based_locking = {
|
|
50
|
+
enabled: false, # Master switch for risk-based locking
|
|
51
|
+
risk_threshold: 75, # Lock account if risk score >= this value
|
|
52
|
+
lock_strategy: :devise_lockable, # Strategy: :devise_lockable, :custom, :none
|
|
53
|
+
auto_unlock_time: 1.hour, # Time until automatic unlock (if supported by strategy)
|
|
54
|
+
notify_user: true, # Send notification on lock
|
|
55
|
+
log_lock_events: true, # Create security event for locks
|
|
56
|
+
immediate_signout: false # Sign out user immediately via Warden callback (requires :lockable)
|
|
57
|
+
}
|
|
58
|
+
@geolocation = {
|
|
59
|
+
provider: :mock, # Provider: :maxmind, :mock
|
|
60
|
+
maxmind_city_db_path: nil, # Path to MaxMind GeoLite2-City.mmdb or GeoIP2-City.mmdb
|
|
61
|
+
cache_ttl: 4.hours # How long to cache geolocation results
|
|
62
|
+
}
|
|
63
|
+
@emergency_password_reset = {
|
|
64
|
+
enabled: false, # Master switch for emergency password reset
|
|
65
|
+
impossible_travel_threshold: 3, # Reset after N impossible travel events in 24h
|
|
66
|
+
suspicious_device_threshold: 5, # Reset after N suspicious device events in 24h
|
|
67
|
+
total_locks_threshold: 5, # Reset after N total locks in 24h (any reason)
|
|
68
|
+
send_notification: true, # Send email to user about reset
|
|
69
|
+
notify_security_team: true, # Alert security team about automatic resets
|
|
70
|
+
require_manual_unlock: false # Require manual admin unlock after reset
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def security_tracking_enabled?
|
|
75
|
+
@security_tracking[:enabled]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def track_successful_logins?
|
|
79
|
+
security_tracking_enabled? && @security_tracking[:track_successful_logins]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def track_failed_logins?
|
|
83
|
+
security_tracking_enabled? && @security_tracking[:track_failed_logins]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def auto_analyze_patterns?
|
|
87
|
+
security_tracking_enabled? && @security_tracking[:auto_analyze_patterns]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Risk-based locking configuration helpers
|
|
91
|
+
def risk_based_locking_enabled?
|
|
92
|
+
@risk_based_locking[:enabled]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def risk_threshold
|
|
96
|
+
@risk_based_locking[:risk_threshold] || 75
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def lock_strategy
|
|
100
|
+
@risk_based_locking[:lock_strategy] || :devise_lockable
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def auto_unlock_time
|
|
104
|
+
@risk_based_locking[:auto_unlock_time] || 1.hour
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def notify_user_on_lock?
|
|
108
|
+
@risk_based_locking[:notify_user] != false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def log_lock_events?
|
|
112
|
+
@risk_based_locking[:log_lock_events] != false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def immediate_signout?
|
|
116
|
+
@risk_based_locking[:immediate_signout] == true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Geolocation configuration helpers
|
|
120
|
+
def geolocation_provider
|
|
121
|
+
@geolocation[:provider] || :mock
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def maxmind_city_db_path
|
|
125
|
+
@geolocation[:maxmind_city_db_path]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def geolocation_cache_ttl
|
|
129
|
+
@geolocation[:cache_ttl] || 4.hours
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# WAF configuration helpers
|
|
133
|
+
def waf_enabled?
|
|
134
|
+
@waf && @waf[:enabled]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def waf_auto_block?
|
|
138
|
+
waf_enabled? && @waf[:auto_block] && !@waf[:monitor_only]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def waf_monitor_only?
|
|
142
|
+
@waf[:monitor_only] == true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# IP Whitelist configuration helpers
|
|
146
|
+
def ip_whitelist_enabled?
|
|
147
|
+
@ip_whitelist.is_a?(Array) && @ip_whitelist.any?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Authentication models helpers
|
|
151
|
+
def devise_scopes
|
|
152
|
+
return @authentication_models[:devise] unless @authentication_models[:auto_detect]
|
|
153
|
+
|
|
154
|
+
# Auto-detect Devise models
|
|
155
|
+
detected = []
|
|
156
|
+
if defined?(Devise)
|
|
157
|
+
Devise.mappings.keys.each do |scope|
|
|
158
|
+
detected << scope
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Merge with explicitly configured models
|
|
163
|
+
(detected + Array(@authentication_models[:devise])).uniq
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def rails_auth_scopes
|
|
167
|
+
return @authentication_models[:rails_auth] unless @authentication_models[:auto_detect]
|
|
168
|
+
|
|
169
|
+
# Auto-detect Rails authentication models (has_secure_password)
|
|
170
|
+
detected = []
|
|
171
|
+
if defined?(ActiveRecord::Base)
|
|
172
|
+
# Try to find models with has_secure_password
|
|
173
|
+
# This is a heuristic - models that have password_digest column
|
|
174
|
+
ActiveRecord::Base.descendants.each do |model|
|
|
175
|
+
next unless model.table_exists?
|
|
176
|
+
if model.column_names.include?("password_digest")
|
|
177
|
+
scope = model.name.underscore.to_sym
|
|
178
|
+
detected << scope unless devise_scopes.include?(scope)
|
|
179
|
+
end
|
|
180
|
+
rescue => e
|
|
181
|
+
# Ignore errors during detection
|
|
182
|
+
Rails.logger.debug "[Beskar] Error detecting Rails auth model #{model.name}: #{e.message}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Merge with explicitly configured models
|
|
187
|
+
(detected + Array(@authentication_models[:rails_auth])).uniq
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def all_auth_scopes
|
|
191
|
+
(devise_scopes + rails_auth_scopes).uniq
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def model_class_for_scope(scope)
|
|
195
|
+
scope.to_s.camelize.constantize
|
|
196
|
+
rescue NameError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
data/lib/beskar/engine.rb
CHANGED
|
@@ -1,5 +1,110 @@
|
|
|
1
1
|
module Beskar
|
|
2
2
|
class Engine < ::Rails::Engine
|
|
3
3
|
isolate_namespace Beskar
|
|
4
|
+
|
|
5
|
+
initializer "beskar.middleware" do |app|
|
|
6
|
+
app.config.middleware.use ::Beskar::Middleware::RequestAnalyzer
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Preload banned IPs into cache on startup
|
|
10
|
+
config.after_initialize do
|
|
11
|
+
if defined?(Beskar::BannedIp)
|
|
12
|
+
Rails.application.executor.wrap do
|
|
13
|
+
Beskar::BannedIp.preload_cache!
|
|
14
|
+
Rails.logger.info "[Beskar] Preloaded banned IPs into cache"
|
|
15
|
+
rescue => e
|
|
16
|
+
Rails.logger.warn "[Beskar] Failed to preload banned IPs: #{e.message}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
initializer "beskar.warden_callbacks", after: :load_config_initializers do |app|
|
|
22
|
+
if defined?(Warden)
|
|
23
|
+
# Track successful authentication and check for high-risk locks
|
|
24
|
+
Warden::Manager.after_set_user except: :fetch do |user, auth, opts|
|
|
25
|
+
# Only proceed if Beskar security tracking is available and enabled
|
|
26
|
+
if user.respond_to?(:track_authentication_event) && auth.request
|
|
27
|
+
# Track the authentication event (creates security event)
|
|
28
|
+
security_event = user.track_authentication_event(auth.request, :success)
|
|
29
|
+
|
|
30
|
+
# Check if account was locked due to high risk (only if immediate_signout is enabled)
|
|
31
|
+
# This happens AFTER successful authentication but BEFORE the request completes
|
|
32
|
+
# Requires :lockable module to be enabled on the user model
|
|
33
|
+
if Beskar.configuration.immediate_signout? &&
|
|
34
|
+
Beskar.configuration.risk_based_locking_enabled? &&
|
|
35
|
+
security_event &&
|
|
36
|
+
user_was_just_locked?(user, security_event) &&
|
|
37
|
+
user.respond_to?(:access_locked?) && user.access_locked?
|
|
38
|
+
Rails.logger.warn "[Beskar] Signing out user #{user.id} due to high-risk lock"
|
|
39
|
+
auth.logout
|
|
40
|
+
throw :warden, scope: opts[:scope], message: :account_locked_due_to_high_risk
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Alternative approach using after_authentication is available but not enabled by default
|
|
46
|
+
# Uncomment this to use the alternative approach (more targeted, only on authentication)
|
|
47
|
+
# Warden::Manager.after_authentication do |user, auth, opts|
|
|
48
|
+
# if user.respond_to?(:check_high_risk_lock_and_signout)
|
|
49
|
+
# user.check_high_risk_lock_and_signout(auth)
|
|
50
|
+
# end
|
|
51
|
+
# end
|
|
52
|
+
|
|
53
|
+
Warden::Manager.before_failure do |env, opts|
|
|
54
|
+
if env
|
|
55
|
+
request = ActionDispatch::Request.new(env)
|
|
56
|
+
scope = opts[:scope]
|
|
57
|
+
|
|
58
|
+
# Try to get model class from configuration
|
|
59
|
+
model_class = Beskar.configuration&.model_class_for_scope(scope)
|
|
60
|
+
|
|
61
|
+
if model_class && model_class.respond_to?(:track_failed_authentication)
|
|
62
|
+
model_class.track_failed_authentication(request, scope)
|
|
63
|
+
else
|
|
64
|
+
Rails.logger.debug "[Beskar] No trackable model found for scope: #{scope}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Helper method to check if user was just locked
|
|
72
|
+
def self.user_was_just_locked?(user, security_event)
|
|
73
|
+
return false unless Beskar.configuration.risk_based_locking_enabled?
|
|
74
|
+
return false unless security_event
|
|
75
|
+
return false unless user&.respond_to?(:security_events)
|
|
76
|
+
|
|
77
|
+
# Check if an account_locked or lock_attempted event was just created
|
|
78
|
+
recent_lock = user.security_events
|
|
79
|
+
.where(event_type: [ "account_locked", "lock_attempted" ])
|
|
80
|
+
.where("created_at >= ?", 10.seconds.ago)
|
|
81
|
+
.order(created_at: :desc)
|
|
82
|
+
.first
|
|
83
|
+
|
|
84
|
+
recent_lock.present?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Add engine migrations to host app's migration paths
|
|
88
|
+
initializer "beskar.append_migrations" do |app|
|
|
89
|
+
# Don't add migrations if we're inside the engine itself (testing)
|
|
90
|
+
if !root.to_s.include?(app.root.to_s) && !app.root.to_s.include?(root.to_s)
|
|
91
|
+
engine_migrations = root.join("db", "migrate").to_s
|
|
92
|
+
|
|
93
|
+
# Add to Rails paths
|
|
94
|
+
app.config.paths["db/migrate"] << engine_migrations
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Ensure ActiveRecord sees the engine migrations after initialization
|
|
99
|
+
# config.after_initialize do |app|
|
|
100
|
+
# unless root.to_s.include?(app.root.to_s)
|
|
101
|
+
# engine_migrations = root.join("db", "migrate").to_s
|
|
102
|
+
|
|
103
|
+
# # Update ActiveRecord::Tasks paths
|
|
104
|
+
# current_paths = Array(ActiveRecord::Tasks::DatabaseTasks.migrations_paths)
|
|
105
|
+
# current_paths << engine_migrations
|
|
106
|
+
# ActiveRecord::Tasks::DatabaseTasks.migrations_paths = current_paths.uniq
|
|
107
|
+
# end
|
|
108
|
+
# end
|
|
4
109
|
end
|
|
5
110
|
end
|