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.
@@ -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