beskar 0.0.2 → 0.2.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 +274 -0
- data/README.md +412 -204
- data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
- data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
- data/app/controllers/beskar/application_controller.rb +214 -0
- data/app/controllers/beskar/banned_ips_controller.rb +255 -0
- data/app/controllers/beskar/dashboard_controller.rb +62 -0
- data/app/controllers/beskar/security_events_controller.rb +164 -0
- data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
- data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
- data/app/jobs/beskar/notification_job.rb +33 -0
- data/app/mailers/beskar/security_mailer.rb +59 -0
- data/app/models/beskar/administrative_action.rb +41 -0
- data/app/models/beskar/banned_ip.rb +105 -105
- data/app/models/beskar/security_event.rb +51 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +88 -0
- data/app/views/beskar/administrative_actions/index.html.erb +33 -0
- data/app/views/beskar/administrative_actions/show.html.erb +21 -0
- data/app/views/beskar/banned_ips/edit.html.erb +195 -0
- data/app/views/beskar/banned_ips/index.html.erb +319 -0
- data/app/views/beskar/banned_ips/new.html.erb +190 -0
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +304 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +302 -0
- data/app/views/beskar/security_events/show.html.erb +293 -0
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/layouts/beskar/_behavior.html.erb +121 -0
- data/app/views/layouts/beskar/application.html.erb +581 -6
- data/config/routes.rb +30 -0
- data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
- data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
- data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
- data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
- data/docs/README.md +73 -0
- data/docs/archive/project-documentation.md +659 -0
- data/docs/audits/project-review.md +437 -0
- data/docs/audits/repair-status.md +216 -0
- data/docs/guides/audit-and-waf.md +175 -0
- data/docs/guides/audit-lifecycle.md +172 -0
- data/docs/guides/authentication.md +213 -0
- data/docs/guides/configuration.md +182 -0
- data/docs/guides/dashboard-and-search.md +251 -0
- data/docs/guides/notifications-and-recovery.md +157 -0
- data/docs/guides/risk-scoring.md +116 -0
- data/docs/operations/monitor-only-mode.md +85 -0
- data/docs/operations/security-hardening.md +167 -0
- data/docs/operations/state-storage.md +144 -0
- data/docs/research/rust-performance-assessment.md +69 -0
- data/lib/beskar/configuration.rb +105 -20
- data/lib/beskar/configuration_validator.rb +188 -0
- data/lib/beskar/devise_authentication.rb +24 -0
- data/lib/beskar/engine.rb +21 -88
- data/lib/beskar/logger.rb +288 -0
- data/lib/beskar/middleware/request_analyzer.rb +133 -99
- data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
- data/lib/beskar/models/security_trackable_devise.rb +34 -25
- data/lib/beskar/models/security_trackable_generic.rb +171 -214
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +90 -81
- data/lib/beskar/services/administrative_audit.rb +36 -0
- data/lib/beskar/services/administrative_bans.rb +104 -0
- data/lib/beskar/services/audit_data.rb +72 -0
- data/lib/beskar/services/authentication.rb +31 -0
- data/lib/beskar/services/authentication_attempt.rb +141 -0
- data/lib/beskar/services/ban_expiry.rb +28 -0
- data/lib/beskar/services/device_detector.rb +32 -41
- data/lib/beskar/services/event_search.rb +58 -0
- data/lib/beskar/services/geolocation_service.rb +83 -114
- data/lib/beskar/services/ip_whitelist.rb +31 -40
- data/lib/beskar/services/location_assessment.rb +109 -0
- data/lib/beskar/services/native_account_lock.rb +82 -0
- data/lib/beskar/services/notifications.rb +46 -0
- data/lib/beskar/services/rate_limiter.rb +99 -125
- data/lib/beskar/services/request_context.rb +64 -0
- data/lib/beskar/services/risk_assessment.rb +58 -0
- data/lib/beskar/services/session_revocation.rb +62 -0
- data/lib/beskar/services/waf.rb +311 -198
- data/lib/beskar/services/waf_request.rb +60 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar/warden_authentication.rb +53 -0
- data/lib/beskar.rb +54 -4
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
- data/lib/tasks/beskar_tasks.rake +25 -20
- metadata +93 -12
- data/lib/beskar/templates/beskar_initializer.rb +0 -107
|
@@ -5,7 +5,7 @@ module Beskar
|
|
|
5
5
|
# Service for locking user accounts based on risk scores
|
|
6
6
|
#
|
|
7
7
|
# This service provides a modular approach to account locking that can work
|
|
8
|
-
# with Devise's lockable module or
|
|
8
|
+
# with Devise's lockable module or the Rails-native lock adapter. It keeps
|
|
9
9
|
# Devise-specific code isolated for maintainability.
|
|
10
10
|
#
|
|
11
11
|
# @example Basic usage with Devise lockable
|
|
@@ -30,7 +30,7 @@ module Beskar
|
|
|
30
30
|
@user = user
|
|
31
31
|
@risk_score = risk_score
|
|
32
32
|
@reason = reason
|
|
33
|
-
@metadata = metadata
|
|
33
|
+
@metadata = metadata.deep_symbolize_keys
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
# Check if account should be locked based on configuration
|
|
@@ -39,6 +39,7 @@ module Beskar
|
|
|
39
39
|
def should_lock?
|
|
40
40
|
return false unless Beskar.configuration.risk_based_locking_enabled?
|
|
41
41
|
return false unless user
|
|
42
|
+
return false unless RequestContext.enforce?(metadata[:ip_address])
|
|
42
43
|
return false if user_already_locked?
|
|
43
44
|
|
|
44
45
|
risk_score >= Beskar.configuration.risk_threshold
|
|
@@ -57,16 +58,19 @@ module Beskar
|
|
|
57
58
|
# @return [Boolean] true if lock was successful, false otherwise
|
|
58
59
|
def lock!
|
|
59
60
|
return false unless user
|
|
61
|
+
return false unless RequestContext.enforce?(metadata[:ip_address])
|
|
60
62
|
|
|
61
|
-
strategy =
|
|
63
|
+
strategy = effective_strategy
|
|
62
64
|
|
|
63
65
|
result = case strategy
|
|
64
66
|
when :devise_lockable
|
|
65
67
|
lock_with_devise_lockable
|
|
66
|
-
when :
|
|
67
|
-
|
|
68
|
+
when :rails_auth
|
|
69
|
+
NativeAccountLock.lock!(user, duration: Beskar.configuration.auto_unlock_time, metadata: metadata)
|
|
70
|
+
when :none
|
|
71
|
+
false
|
|
68
72
|
else
|
|
69
|
-
|
|
73
|
+
Beskar::Logger.warn("Unknown lock strategy: #{strategy}", component: :AccountLocker)
|
|
70
74
|
false
|
|
71
75
|
end
|
|
72
76
|
|
|
@@ -89,18 +93,20 @@ module Beskar
|
|
|
89
93
|
def unlock!
|
|
90
94
|
return false unless user
|
|
91
95
|
|
|
92
|
-
strategy =
|
|
96
|
+
strategy = effective_strategy
|
|
93
97
|
|
|
94
98
|
result = case strategy
|
|
95
99
|
when :devise_lockable
|
|
96
100
|
unlock_with_devise_lockable
|
|
97
|
-
when :
|
|
98
|
-
|
|
101
|
+
when :rails_auth
|
|
102
|
+
NativeAccountLock.unlock!(user)
|
|
103
|
+
when :none
|
|
104
|
+
false
|
|
99
105
|
else
|
|
100
106
|
false
|
|
101
107
|
end
|
|
102
108
|
|
|
103
|
-
# Log unlock
|
|
109
|
+
# Log the administrative unlock; this does not establish trusted history.
|
|
104
110
|
if result && Beskar.configuration.log_lock_events?
|
|
105
111
|
log_unlock_event
|
|
106
112
|
end
|
|
@@ -115,13 +121,32 @@ module Beskar
|
|
|
115
121
|
user_already_locked?
|
|
116
122
|
end
|
|
117
123
|
|
|
124
|
+
def supported?
|
|
125
|
+
case effective_strategy
|
|
126
|
+
when :devise_lockable then devise_lockable_available?
|
|
127
|
+
when :rails_auth then user.respond_to?(:beskar_access_locked?) && user.respond_to?(:sessions)
|
|
128
|
+
else false
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
118
132
|
private
|
|
119
133
|
|
|
134
|
+
def effective_strategy
|
|
135
|
+
strategy = Beskar.configuration.lock_strategy
|
|
136
|
+
if strategy == :devise_lockable && user.respond_to?(:beskar_access_locked?)
|
|
137
|
+
:rails_auth
|
|
138
|
+
else
|
|
139
|
+
strategy
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
120
143
|
# Check if user is already locked
|
|
121
144
|
def user_already_locked?
|
|
122
145
|
return false unless user
|
|
123
146
|
|
|
124
|
-
if user.respond_to?(:
|
|
147
|
+
if user.respond_to?(:beskar_access_locked?)
|
|
148
|
+
user.beskar_access_locked?
|
|
149
|
+
elsif user.respond_to?(:access_locked?)
|
|
125
150
|
user.access_locked?
|
|
126
151
|
elsif user.respond_to?(:locked_at)
|
|
127
152
|
user.locked_at.present?
|
|
@@ -133,61 +158,48 @@ module Beskar
|
|
|
133
158
|
# Lock account using Devise's lockable module
|
|
134
159
|
def lock_with_devise_lockable
|
|
135
160
|
unless devise_lockable_available?
|
|
136
|
-
|
|
161
|
+
Beskar::Logger.warn("Devise lockable not available for #{user.class.name}", component: :AccountLocker)
|
|
137
162
|
return false
|
|
138
163
|
end
|
|
139
164
|
|
|
140
165
|
begin
|
|
141
166
|
# Use Devise's lock_access! method
|
|
142
|
-
user.lock_access!(send_instructions: false)
|
|
143
|
-
|
|
144
|
-
# Set automatic unlock time if configured and supported
|
|
145
|
-
if Beskar.configuration.auto_unlock_time && user.respond_to?(:locked_at=)
|
|
146
|
-
user.update_column(:locked_at, Time.current)
|
|
167
|
+
unless user.lock_access!(send_instructions: false)
|
|
168
|
+
raise AuthenticationAttempt::Unavailable, "Account lock could not be persisted"
|
|
147
169
|
end
|
|
148
170
|
|
|
149
|
-
|
|
171
|
+
# Devise owns its unlock policy (unlock_strategy/unlock_in). Beskar's
|
|
172
|
+
# auto_unlock_time applies to Rails-native locks, not Devise columns.
|
|
173
|
+
|
|
174
|
+
Beskar::Logger.info("Locked account #{user.id} (#{user.class.name}) - Risk: #{risk_score}, Reason: #{reason}", component: :AccountLocker)
|
|
150
175
|
true
|
|
176
|
+
rescue ActiveRecord::ActiveRecordError
|
|
177
|
+
raise AuthenticationAttempt::Unavailable, "Account lock could not be persisted"
|
|
178
|
+
rescue AuthenticationAttempt::Unavailable
|
|
179
|
+
raise
|
|
151
180
|
rescue => e
|
|
152
|
-
|
|
153
|
-
|
|
181
|
+
Beskar::Logger.error("Failed to lock account (#{e.class})", component: :AccountLocker)
|
|
182
|
+
raise AuthenticationAttempt::Unavailable, "Account lock could not be persisted"
|
|
154
183
|
end
|
|
155
184
|
end
|
|
156
185
|
|
|
157
186
|
# Unlock account using Devise's lockable module
|
|
158
187
|
def unlock_with_devise_lockable
|
|
159
188
|
unless devise_lockable_available?
|
|
160
|
-
|
|
189
|
+
Beskar::Logger.warn("Devise lockable not available for #{user.class.name}", component: :AccountLocker)
|
|
161
190
|
return false
|
|
162
191
|
end
|
|
163
192
|
|
|
164
193
|
begin
|
|
165
194
|
user.unlock_access!
|
|
166
|
-
|
|
195
|
+
Beskar::Logger.info("Unlocked account #{user.id} (#{user.class.name})", component: :AccountLocker)
|
|
167
196
|
true
|
|
168
197
|
rescue => e
|
|
169
|
-
|
|
198
|
+
Beskar::Logger.error("Failed to unlock account: #{e.class}", component: :AccountLocker)
|
|
170
199
|
false
|
|
171
200
|
end
|
|
172
201
|
end
|
|
173
202
|
|
|
174
|
-
# Lock account using custom strategy (to be implemented by application)
|
|
175
|
-
def lock_with_custom_strategy
|
|
176
|
-
# Applications can implement this by:
|
|
177
|
-
# 1. Adding a locked_by_beskar column to users table
|
|
178
|
-
# 2. Checking this in authentication callbacks
|
|
179
|
-
# 3. Implementing unlock logic
|
|
180
|
-
|
|
181
|
-
Rails.logger.warn "[Beskar::AccountLocker] Custom lock strategy not implemented"
|
|
182
|
-
false
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
# Unlock using custom strategy
|
|
186
|
-
def unlock_with_custom_strategy
|
|
187
|
-
Rails.logger.warn "[Beskar::AccountLocker] Custom unlock strategy not implemented"
|
|
188
|
-
false
|
|
189
|
-
end
|
|
190
|
-
|
|
191
203
|
# Check if Devise lockable is available for this user
|
|
192
204
|
def devise_lockable_available?
|
|
193
205
|
defined?(Devise) &&
|
|
@@ -202,61 +214,58 @@ module Beskar
|
|
|
202
214
|
return unless user.respond_to?(:security_events)
|
|
203
215
|
|
|
204
216
|
begin
|
|
205
|
-
event_type = lock_succeeded ?
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
event_type = lock_succeeded ? "account_locked" : "lock_attempted"
|
|
218
|
+
|
|
219
|
+
Beskar::SecurityEvent.transaction(requires_new: true) do
|
|
220
|
+
Beskar::SecurityEvent.create!(
|
|
221
|
+
user_type: user.class.polymorphic_name, user_id: user.id,
|
|
222
|
+
event_type: event_type,
|
|
223
|
+
ip_address: metadata[:ip_address] || "system",
|
|
224
|
+
user_agent: metadata[:user_agent] || "beskar_system",
|
|
225
|
+
risk_score: risk_score,
|
|
226
|
+
metadata: {
|
|
227
|
+
reason: reason,
|
|
228
|
+
risk_threshold: Beskar.configuration.risk_threshold,
|
|
229
|
+
lock_strategy: effective_strategy,
|
|
230
|
+
auto_unlock_time: Beskar.configuration.auto_unlock_time,
|
|
231
|
+
locked_at: Time.current.iso8601,
|
|
232
|
+
lock_succeeded: lock_succeeded,
|
|
233
|
+
additional_context: metadata
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
end
|
|
222
237
|
rescue => e
|
|
223
|
-
|
|
238
|
+
Beskar::Logger.warn("Failed to log lock event: #{e.class}", component: :AccountLocker)
|
|
224
239
|
end
|
|
225
240
|
end
|
|
226
241
|
|
|
227
|
-
# Log
|
|
228
|
-
# This helps establish patterns - if user unlocks and logs in successfully,
|
|
229
|
-
# that context becomes "established" and trusted
|
|
242
|
+
# Log the administrative action without granting IP/device trust.
|
|
230
243
|
def log_unlock_event
|
|
231
244
|
return unless user.respond_to?(:security_events)
|
|
232
245
|
|
|
233
246
|
begin
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
247
|
+
Beskar::SecurityEvent.transaction(requires_new: true) do
|
|
248
|
+
Beskar::SecurityEvent.create!(
|
|
249
|
+
user_type: user.class.polymorphic_name, user_id: user.id,
|
|
250
|
+
event_type: "account_unlocked",
|
|
251
|
+
ip_address: metadata[:ip_address] || "system",
|
|
252
|
+
user_agent: metadata[:user_agent] || "beskar_system",
|
|
253
|
+
risk_score: 0, # Unlock has no risk
|
|
254
|
+
metadata: {
|
|
255
|
+
unlocked_at: Time.current.iso8601,
|
|
256
|
+
unlock_method: "manual",
|
|
257
|
+
additional_context: metadata
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
end
|
|
245
261
|
rescue => e
|
|
246
|
-
|
|
262
|
+
Beskar::Logger.warn("Failed to log unlock event: #{e.class}", component: :AccountLocker)
|
|
247
263
|
end
|
|
248
264
|
end
|
|
249
265
|
|
|
250
266
|
# Notify user about account lock
|
|
251
267
|
def notify_user
|
|
252
|
-
|
|
253
|
-
# For now, just log it
|
|
254
|
-
Rails.logger.info "[Beskar::AccountLocker] User #{user.id} should be notified of account lock"
|
|
255
|
-
|
|
256
|
-
# Future implementation:
|
|
257
|
-
# if defined?(Beskar::AccountLockMailer)
|
|
258
|
-
# Beskar::AccountLockMailer.account_locked(user, risk_score, reason).deliver_later
|
|
259
|
-
# end
|
|
268
|
+
Notifications.enqueue(user, "account_locked")
|
|
260
269
|
end
|
|
261
270
|
end
|
|
262
271
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Services
|
|
3
|
+
class AdministrativeAudit
|
|
4
|
+
class Unavailable < StandardError; end
|
|
5
|
+
|
|
6
|
+
def self.record!(actor:, reason:, request_id:, action:, target_type:, before_state: {}, after_state: {})
|
|
7
|
+
raise AdministrativeBans::InvalidInput, "Administrative actor must be an opaque identifier" unless AdministrativeBans.valid_actor?(actor)
|
|
8
|
+
unless reason.is_a?(String) && reason.strip.present? && reason.length <= 1000
|
|
9
|
+
raise AdministrativeBans::InvalidInput, "An administrative reason of 1 to 1000 characters is required"
|
|
10
|
+
end
|
|
11
|
+
AdministrativeAction.create!(actor: actor, reason: reason.strip, request_id: request_id,
|
|
12
|
+
operation_id: SecureRandom.uuid, action: action, target_type: target_type,
|
|
13
|
+
before_state: before_state, after_state: after_state)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.configuration_snapshot(config)
|
|
17
|
+
values = (Configuration::SECTIONS + [:monitor_only, :ip_whitelist]).to_h { |name| [name, config.public_send(name)] }
|
|
18
|
+
%i[authenticate_admin audit_actor authorize_admin authorize_configuration].each do |name|
|
|
19
|
+
values[name] = config.public_send(name).present? ? "[CALLBACK]" : nil
|
|
20
|
+
end
|
|
21
|
+
AuditData.metadata(configuration_value(values))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.configuration_value(value)
|
|
25
|
+
case value
|
|
26
|
+
when Hash then value.transform_values { |item| configuration_value(item) }
|
|
27
|
+
when Array then value.map { |item| configuration_value(item) }
|
|
28
|
+
when ActiveSupport::Duration then value.to_f
|
|
29
|
+
when Regexp then {pattern: value.source, options: value.options}
|
|
30
|
+
when Class then value.name
|
|
31
|
+
else value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Services
|
|
3
|
+
# All selected changes and their required history share one writer transaction.
|
|
4
|
+
# The existing ban coordination keys also serialize with automatic escalation.
|
|
5
|
+
class AdministrativeBans
|
|
6
|
+
class InvalidInput < ArgumentError; end
|
|
7
|
+
MAX_BATCH = 100
|
|
8
|
+
DURATIONS = {"1h" => 1.hour, "6h" => 6.hours, "24h" => 24.hours,
|
|
9
|
+
"7d" => 7.days, "30d" => 30.days}.freeze
|
|
10
|
+
SNAPSHOT_FIELDS = %w[id ip_address reason details permanent banned_at expires_at violation_count metadata].freeze
|
|
11
|
+
ACTION_NAMES = {"update" => "ban_updated", "unban" => "ban_unbanned", "extend" => "ban_extended", "make_permanent" => "ban_made_permanent"}.freeze
|
|
12
|
+
UPDATE_FIELDS = %w[ip_address reason details permanent expires_at violation_count metadata].freeze
|
|
13
|
+
|
|
14
|
+
def self.valid_actor?(actor)
|
|
15
|
+
actor.is_a?(String) && actor.match?(/\A[a-zA-Z0-9][a-zA-Z0-9:_.\/-]{0,199}\z/)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(actor:, reason:, request_id:)
|
|
19
|
+
unless self.class.valid_actor?(actor)
|
|
20
|
+
raise InvalidInput, "Administrative actor must be an opaque identifier"
|
|
21
|
+
end
|
|
22
|
+
unless reason.is_a?(String) && reason.strip.present? && reason.length <= 1000
|
|
23
|
+
raise InvalidInput, "An administrative reason of 1 to 1000 characters is required"
|
|
24
|
+
end
|
|
25
|
+
@context = {actor: actor, reason: reason.strip, request_id: AuditData.field(:request_id, request_id, limit: 200),
|
|
26
|
+
operation_id: SecureRandom.uuid}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create!(ban)
|
|
30
|
+
raise InvalidInput, "Create requires a new ban" unless ban.new_record?
|
|
31
|
+
raise ActiveRecord::RecordInvalid, ban unless ban.valid?
|
|
32
|
+
SecurityState.mutate("ban:#{ban.ip_address}", ttl: 1.day) do
|
|
33
|
+
ban.save!
|
|
34
|
+
record!(ban, "ban_created", {})
|
|
35
|
+
end
|
|
36
|
+
ban
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def change!(ids, action:, attributes: {}, duration: nil)
|
|
40
|
+
unless %w[update unban extend make_permanent].include?(action)
|
|
41
|
+
raise InvalidInput, "Unknown administrative action"
|
|
42
|
+
end
|
|
43
|
+
unless attributes.is_a?(Hash) && (attributes.keys.map(&:to_s) - UPDATE_FIELDS).empty?
|
|
44
|
+
raise InvalidInput, "Unsupported ban attributes"
|
|
45
|
+
end
|
|
46
|
+
extension = DURATIONS[duration] if action == "extend"
|
|
47
|
+
raise InvalidInput, "Unsupported extension duration" if action == "extend" && !extension
|
|
48
|
+
ids = normalize_ids(ids)
|
|
49
|
+
targets = BannedIp.where(id: ids).order(:id).pluck(:id, :ip_address)
|
|
50
|
+
raise ActiveRecord::RecordNotFound unless targets.size == ids.size
|
|
51
|
+
|
|
52
|
+
changed = 0
|
|
53
|
+
SecurityState.mutate(targets.map { |_, ip| "ban:#{ip}" }, ttl: 1.day) do
|
|
54
|
+
changed = 0 # The block can be retried after an optimistic conflict.
|
|
55
|
+
bans = BannedIp.where(id: ids).order(:id).lock.to_a
|
|
56
|
+
raise ActiveRecord::RecordNotFound unless bans.size == ids.size
|
|
57
|
+
raise InvalidInput, "Ban identity changed; reload before retrying" unless bans.map { |ban| [ban.id, ban.ip_address] } == targets
|
|
58
|
+
bans.each do |ban|
|
|
59
|
+
before = snapshot(ban)
|
|
60
|
+
case action
|
|
61
|
+
when "unban" then ban.destroy!
|
|
62
|
+
when "make_permanent" then ban.update!(permanent: true, expires_at: nil)
|
|
63
|
+
when "extend"
|
|
64
|
+
raise InvalidInput, "Cannot extend a permanent ban" if ban.permanent?
|
|
65
|
+
ban.update!(expires_at: [ban.expires_at || Time.current, Time.current].max + extension)
|
|
66
|
+
when "update"
|
|
67
|
+
ban.assign_attributes(attributes)
|
|
68
|
+
if ban.ip_address_changed?
|
|
69
|
+
ban.errors.add(:ip_address, "cannot be changed")
|
|
70
|
+
raise ActiveRecord::RecordInvalid, ban
|
|
71
|
+
end
|
|
72
|
+
ban.save!
|
|
73
|
+
end
|
|
74
|
+
after = ban.destroyed? ? {} : snapshot(ban)
|
|
75
|
+
# Filtered/bounded snapshots can look identical even when stored
|
|
76
|
+
# fields changed (for example subsecond deadlines or redacted data).
|
|
77
|
+
next unless ban.destroyed? || ban.saved_changes.except("updated_at").present?
|
|
78
|
+
record!(ban, ACTION_NAMES.fetch(action), before, after)
|
|
79
|
+
changed += 1
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
changed
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def normalize_ids(ids)
|
|
88
|
+
values = Array(ids)
|
|
89
|
+
unless values.size.between?(1, MAX_BATCH) && values.all? { |id| id.to_s.match?(/\A[1-9]\d{0,18}\z/) && id.to_s.to_i <= 9_223_372_036_854_775_807 }
|
|
90
|
+
raise InvalidInput, "Select 1 to 100 valid ban IDs"
|
|
91
|
+
end
|
|
92
|
+
values.map(&:to_i).uniq.sort
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def snapshot(ban)
|
|
96
|
+
AuditData.metadata(ban.attributes.slice(*SNAPSHOT_FIELDS))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def record!(ban, action, before, after = snapshot(ban))
|
|
100
|
+
AdministrativeAction.create!(@context.merge(action: action, target_id: ban.id, before_state: before, after_state: after))
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "active_support/parameter_filter"
|
|
2
|
+
|
|
3
|
+
module Beskar
|
|
4
|
+
module Services
|
|
5
|
+
# Defense at capture and export boundaries, including legacy records. This
|
|
6
|
+
# cannot identify arbitrary secrets embedded in otherwise legitimate prose.
|
|
7
|
+
module AuditData
|
|
8
|
+
FILTERED = "[FILTERED]"
|
|
9
|
+
MAX_DEPTH = 10
|
|
10
|
+
MAX_NODES = 512
|
|
11
|
+
MAX_BYTES = 65_536
|
|
12
|
+
SENSITIVE_KEYS = /password|secret|token|authorization|cookie|session_id|csrf|exception_message|fullpath|matched_path/i
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def metadata(value)
|
|
17
|
+
bounded = bound(value.is_a?(Hash) ? value : {}, depth: 0, budget: [MAX_NODES])
|
|
18
|
+
filters = [SENSITIVE_KEYS] + Array(Rails.application.config.filter_parameters)
|
|
19
|
+
filtered = ActiveSupport::ParameterFilter.new(filters).filter(bounded)
|
|
20
|
+
filtered = bound(filtered, depth: 0, budget: [MAX_NODES])
|
|
21
|
+
(filtered.to_json.bytesize <= MAX_BYTES) ? filtered : {"_truncated" => true}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def text(value, limit: 2048)
|
|
25
|
+
RequestContext.text(value, limit: limit)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def field(name, value, limit: 2048)
|
|
29
|
+
metadata(name.to_s => text(value, limit: limit))[name.to_s]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def user_email(user)
|
|
33
|
+
return unless user
|
|
34
|
+
if user.try(:email).present?
|
|
35
|
+
field(:email, user.email, limit: 320)
|
|
36
|
+
elsif user.try(:email_address).present?
|
|
37
|
+
field(:email, field(:email_address, user.email_address, limit: 320), limit: 320)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def bound(value, depth:, budget:)
|
|
42
|
+
return "[TRUNCATED]" if depth > MAX_DEPTH || (budget[0] -= 1) < 0
|
|
43
|
+
case value
|
|
44
|
+
when Hash
|
|
45
|
+
value.first(64).each_with_object({}) do |(key, child), result|
|
|
46
|
+
next unless key.is_a?(String) || key.is_a?(Symbol)
|
|
47
|
+
key = key.to_s
|
|
48
|
+
next if key.bytesize > 128
|
|
49
|
+
result[key] = bound(child, depth: depth + 1, budget: budget)
|
|
50
|
+
end
|
|
51
|
+
when Array then value.first(50).map { |child| bound(child, depth: depth + 1, budget: budget) }
|
|
52
|
+
when String, Symbol then text(value)
|
|
53
|
+
when Float then value.finite? ? value : nil
|
|
54
|
+
when Integer, TrueClass, FalseClass, NilClass then value
|
|
55
|
+
when Time, DateTime, Date then value.iso8601
|
|
56
|
+
else "[UNSUPPORTED]"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# CSV quoting handles delimiters, but does not neutralize spreadsheet
|
|
61
|
+
# formulas. Prefix dangerous textual cells, including obscured/fullwidth
|
|
62
|
+
# prefixes. JSON exports preserve text without this spreadsheet marker.
|
|
63
|
+
def csv_cell(value)
|
|
64
|
+
return value if value.is_a?(Numeric) || value.nil?
|
|
65
|
+
string = text(value)
|
|
66
|
+
probe = string.unicode_normalize(:nfkc)
|
|
67
|
+
dangerous = probe.match?(/\A[\p{Space}\p{Cf}\p{Cc}]*[=+\-@]/)
|
|
68
|
+
dangerous ? "text: #{string}" : string
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Services
|
|
3
|
+
# Framework-neutral admission for token issuance, OAuth and custom APIs.
|
|
4
|
+
# The host verifies credentials in the block and returns the authenticated
|
|
5
|
+
# model (or nil). This method never issues a credential or grants access.
|
|
6
|
+
class Authentication
|
|
7
|
+
def self.authenticate(request, model:, scope:, credentials: {})
|
|
8
|
+
user = model.find_by(credentials) unless credentials.empty?
|
|
9
|
+
attempt = AuthenticationAttempt.reserve(request, model: model, scope: scope,
|
|
10
|
+
user: user, credentials: credentials)
|
|
11
|
+
unless attempt.allowed?
|
|
12
|
+
model.track_failed_authentication(request, scope, attempt: attempt)
|
|
13
|
+
return attempt
|
|
14
|
+
end
|
|
15
|
+
resource = yield
|
|
16
|
+
if resource
|
|
17
|
+
raise AuthenticationAttempt::Unavailable, "Authentication identity mismatch" unless resource.is_a?(model) && resource.persisted?
|
|
18
|
+
resource.track_authentication_event(request, :success, attempt: attempt, persist: false)
|
|
19
|
+
attempt.verify_generation!
|
|
20
|
+
attempt.persist_outcome!
|
|
21
|
+
else
|
|
22
|
+
model.track_failed_authentication(request, scope, attempt: attempt)
|
|
23
|
+
attempt.deny!(:invalid_credentials)
|
|
24
|
+
end
|
|
25
|
+
attempt
|
|
26
|
+
rescue ActiveRecord::ActiveRecordError
|
|
27
|
+
raise AuthenticationAttempt::Unavailable, "Authentication temporarily unavailable"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Beskar
|
|
4
|
+
module Services
|
|
5
|
+
# Request-local decisions are independent of optional SecurityEvent writes.
|
|
6
|
+
# Adapters reserve before password verification and pass the same attempt to
|
|
7
|
+
# the outcome callback, so a single attempt cannot consume capacity twice.
|
|
8
|
+
class AuthenticationAttempt
|
|
9
|
+
class Unavailable < StandardError; end
|
|
10
|
+
|
|
11
|
+
attr_reader :id, :scope, :rate_limit, :ip_address, :attempted_email, :request_path
|
|
12
|
+
attr_accessor :user, :event, :locked_now, :completed
|
|
13
|
+
|
|
14
|
+
def self.current(request, scope)
|
|
15
|
+
request.env.dig("beskar.authentication_attempts", scope.to_s) if request.respond_to?(:env) && request.env
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.reserve(request, model:, scope:, user: nil, credentials: {}, cache: false)
|
|
19
|
+
existing = current(request, scope) if cache
|
|
20
|
+
return existing if existing
|
|
21
|
+
|
|
22
|
+
attempt = new(request, scope: scope, user: user, model: model, credentials: credentials)
|
|
23
|
+
if cache && request.respond_to?(:env) && request.env
|
|
24
|
+
(request.env["beskar.authentication_attempts"] ||= {})[scope.to_s] = attempt
|
|
25
|
+
end
|
|
26
|
+
attempt
|
|
27
|
+
rescue ActiveRecord::ActiveRecordError => error
|
|
28
|
+
Beskar::Logger.error("Authentication state unavailable (#{error.class})")
|
|
29
|
+
raise Unavailable, "Authentication temporarily unavailable"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(request, scope:, user:, model:, credentials:)
|
|
33
|
+
@id = SecureRandom.uuid
|
|
34
|
+
@scope = scope.to_s
|
|
35
|
+
@ip_address = RequestContext.ip(request)
|
|
36
|
+
@request_path = RequestContext.path(request)
|
|
37
|
+
@user = user
|
|
38
|
+
@generation = SessionRevocation.token(user) if user
|
|
39
|
+
identity = credentials.to_h.stringify_keys
|
|
40
|
+
@attempted_email = identity["email"] || identity["email_address"]
|
|
41
|
+
@locked_now = false
|
|
42
|
+
@completed = false
|
|
43
|
+
@identity_reserved = user || credentials.present?
|
|
44
|
+
@rate_limit = RateLimiter.check_authentication_attempt(request, :attempt, user,
|
|
45
|
+
account_key: user ? nil : self.class.credential_key(model, credentials))
|
|
46
|
+
@allowed = !RequestContext.enforce?(ip_address) || rate_limit[:allowed]
|
|
47
|
+
@reason = :rate_limit_exceeded unless @allowed
|
|
48
|
+
if user&.respond_to?(:beskar_access_locked?) && RequestContext.enforce?(ip_address) && user.beskar_access_locked?
|
|
49
|
+
deny!(:account_locked)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.credential_key(model, credentials)
|
|
54
|
+
return if credentials.empty?
|
|
55
|
+
normalized = credentials.to_h.sort_by { |key, _| key.to_s }.map do |key, value|
|
|
56
|
+
value = value.to_s
|
|
57
|
+
value = value.strip if !model.respond_to?(:strip_whitespace_keys) || model.strip_whitespace_keys.map(&:to_s).include?(key.to_s)
|
|
58
|
+
value = value.downcase if !model.respond_to?(:case_insensitive_keys) || model.case_insensitive_keys.map(&:to_s).include?(key.to_s)
|
|
59
|
+
[key.to_s, value]
|
|
60
|
+
end
|
|
61
|
+
"#{model.name}:credentials:#{Digest::SHA256.hexdigest(normalized.to_json)}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def allowed?
|
|
65
|
+
@allowed
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def session_token
|
|
69
|
+
@generation
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Opaque strategies cannot identify an account until verification succeeds.
|
|
73
|
+
# Charge that account once, independently of the already reserved IP tier.
|
|
74
|
+
def bind_user!(resource)
|
|
75
|
+
if user && user != resource
|
|
76
|
+
deny!(:identity_changed)
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
unless user
|
|
80
|
+
@user = resource
|
|
81
|
+
@generation = SessionRevocation.token(resource)
|
|
82
|
+
account_result = RateLimiter.reserve_account(resource, ip_address: ip_address)
|
|
83
|
+
if RequestContext.enforce?(ip_address) && !account_result[:allowed]
|
|
84
|
+
@rate_limit = account_result
|
|
85
|
+
deny!(:rate_limit_exceeded)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
verify_generation!
|
|
89
|
+
if RequestContext.enforce?(ip_address)
|
|
90
|
+
locked = resource.respond_to?(:beskar_access_locked?) ? resource.beskar_access_locked? : (resource.respond_to?(:access_locked?) && resource.reload.access_locked?)
|
|
91
|
+
deny!(:account_locked) if locked
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def verify_generation!
|
|
96
|
+
deny!(:session_revoked) if user && @generation != SessionRevocation.token(user)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def bind_identity!(model, credentials)
|
|
100
|
+
return if user || @identity_reserved || credentials.empty?
|
|
101
|
+
@identity_reserved = true
|
|
102
|
+
result = RateLimiter.reserve_account(nil, ip_address: ip_address,
|
|
103
|
+
account_key: self.class.credential_key(model, credentials))
|
|
104
|
+
if RequestContext.enforce?(ip_address) && !result[:allowed]
|
|
105
|
+
@rate_limit = result
|
|
106
|
+
deny!(:rate_limit_exceeded)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def persist_outcome!
|
|
111
|
+
return unless event && !event.persisted? && Beskar.configuration.track_successful_logins?
|
|
112
|
+
event.event_type = "authentication_blocked" unless allowed?
|
|
113
|
+
event.metadata = (event.metadata || {}).merge("authentication" => metadata)
|
|
114
|
+
user.send(:persist_authentication_audit, event)
|
|
115
|
+
user.analyze_suspicious_patterns_async if allowed? && Beskar.configuration.auto_analyze_patterns?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def deny!(reason)
|
|
119
|
+
@allowed = false
|
|
120
|
+
@reason = reason
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def metadata
|
|
124
|
+
{"attempt_id" => id, "scope" => scope, "allowed" => allowed?,
|
|
125
|
+
"reason" => @reason&.to_s, "rate_limit_allowed" => rate_limit[:allowed], "locked_now" => !!locked_now}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def response
|
|
129
|
+
status = (@reason == :rate_limit_exceeded) ? 429 : 403
|
|
130
|
+
headers = {"content-type" => "application/json", "cache-control" => "no-store"}
|
|
131
|
+
headers["retry-after"] = [rate_limit[:retry_after].to_i, 1].max.to_s if status == 429
|
|
132
|
+
[status, headers, [{error: (status == 429) ? "Too many authentication attempts" : "Authentication denied"}.to_json]]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.unavailable_response
|
|
136
|
+
[503, {"content-type" => "application/json", "cache-control" => "no-store", "retry-after" => "60"},
|
|
137
|
+
[{error: "Authentication temporarily unavailable"}.to_json]]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|