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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
require "csv"
|
|
2
|
+
|
|
3
|
+
module Beskar
|
|
4
|
+
class SecurityEventsController < ApplicationController
|
|
5
|
+
include Controllers::AuditExport
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@events = Beskar::SecurityEvent.preload(:user).order(created_at: :desc, id: :desc)
|
|
9
|
+
|
|
10
|
+
# Apply filters
|
|
11
|
+
apply_filters!
|
|
12
|
+
|
|
13
|
+
# Paginate results
|
|
14
|
+
@pagination = paginate(@events, per_page: params[:per_page]&.to_i || 25)
|
|
15
|
+
@events = @pagination[:records]
|
|
16
|
+
|
|
17
|
+
# Get filter options for dropdowns
|
|
18
|
+
@event_types = Beskar::SecurityEvent.distinct.pluck(:event_type).sort
|
|
19
|
+
@risk_levels = RiskLevel.filter_options
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def show
|
|
23
|
+
@event = Beskar::SecurityEvent.find(params[:id])
|
|
24
|
+
|
|
25
|
+
# Find related events
|
|
26
|
+
@related_events = Beskar::SecurityEvent
|
|
27
|
+
.preload(:user)
|
|
28
|
+
.where.not(id: @event.id)
|
|
29
|
+
.where(ip_address: @event.ip_address)
|
|
30
|
+
.order(created_at: :desc, id: :desc)
|
|
31
|
+
.limit(10)
|
|
32
|
+
|
|
33
|
+
# Get user's recent events if user exists
|
|
34
|
+
if @event.user.present?
|
|
35
|
+
@user_events = @event.user.security_events
|
|
36
|
+
.where.not(id: @event.id)
|
|
37
|
+
.order(created_at: :desc)
|
|
38
|
+
.limit(10)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check if IP is banned
|
|
42
|
+
@ip_ban = Beskar::BannedIp.find_by(ip_address: @event.ip_address)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def export
|
|
46
|
+
@events = Beskar::SecurityEvent.preload(:user)
|
|
47
|
+
|
|
48
|
+
# Apply same filters as index
|
|
49
|
+
apply_filters!
|
|
50
|
+
records = export_records(@events)
|
|
51
|
+
return if performed?
|
|
52
|
+
|
|
53
|
+
respond_to do |format|
|
|
54
|
+
format.csv do
|
|
55
|
+
send_data generate_csv(records),
|
|
56
|
+
filename: "security-events-#{Date.current}.csv",
|
|
57
|
+
type: "text/csv"
|
|
58
|
+
end
|
|
59
|
+
format.json do
|
|
60
|
+
render json: records.map { |event| export_event(event) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def export_event(event)
|
|
68
|
+
attributes = event.attributes.slice("id", "event_type", "ip_address", "user_id", "user_type",
|
|
69
|
+
"user_agent", "attempted_email", "risk_score", "metadata", "created_at")
|
|
70
|
+
if event.user
|
|
71
|
+
attributes["user"] = {id: event.user.id, email: Services::AuditData.user_email(event.user)}
|
|
72
|
+
end
|
|
73
|
+
attributes
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply_filters!
|
|
77
|
+
# Filter by event type
|
|
78
|
+
if params[:event_type].present?
|
|
79
|
+
@events = @events.where(event_type: params[:event_type])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Filter by risk level
|
|
83
|
+
if params[:risk_level].present?
|
|
84
|
+
@events = @events.with_risk_level(params[:risk_level])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Filter by IP address
|
|
88
|
+
if params[:ip_address].present?
|
|
89
|
+
@events = @events.where("ip_address LIKE ?", "%#{params[:ip_address]}%")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Filter by user email (if attempted_email is stored)
|
|
93
|
+
if params[:email].present?
|
|
94
|
+
@events = Services::EventSearch.new(@events).email(params[:email])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Filter by date range
|
|
98
|
+
if params[:start_date].present?
|
|
99
|
+
begin
|
|
100
|
+
start_date = Date.parse(params[:start_date])
|
|
101
|
+
@events = @events.where("created_at >= ?", start_date.beginning_of_day)
|
|
102
|
+
rescue ArgumentError
|
|
103
|
+
# Invalid date, ignore filter
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if params[:end_date].present?
|
|
108
|
+
begin
|
|
109
|
+
end_date = Date.parse(params[:end_date])
|
|
110
|
+
@events = @events.where("created_at <= ?", end_date.end_of_day)
|
|
111
|
+
rescue ArgumentError
|
|
112
|
+
# Invalid date, ignore filter
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Quick time range filters
|
|
117
|
+
if params[:time_range].present?
|
|
118
|
+
start_time = case params[:time_range]
|
|
119
|
+
when "last_hour"
|
|
120
|
+
1.hour.ago
|
|
121
|
+
when "last_24h"
|
|
122
|
+
24.hours.ago
|
|
123
|
+
when "last_7d"
|
|
124
|
+
7.days.ago
|
|
125
|
+
when "last_30d"
|
|
126
|
+
30.days.ago
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@events = @events.where("created_at >= ?", start_time) if start_time
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Filter by threat level
|
|
133
|
+
if params[:threats_only] == "true"
|
|
134
|
+
@events = @events.high_risk
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Search in metadata
|
|
138
|
+
if params[:search].present?
|
|
139
|
+
@events = Services::EventSearch.new(@events).search(params[:search])
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def generate_csv(events)
|
|
144
|
+
require "csv"
|
|
145
|
+
|
|
146
|
+
CSV.generate(headers: true, force_quotes: true) do |csv|
|
|
147
|
+
csv << ["ID", "Date/Time", "Event Type", "IP Address", "User", "Risk Score", "User Agent", "Details"]
|
|
148
|
+
|
|
149
|
+
events.each do |event|
|
|
150
|
+
csv << [
|
|
151
|
+
event.id,
|
|
152
|
+
event.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
153
|
+
event.event_type,
|
|
154
|
+
event.ip_address,
|
|
155
|
+
audit_user_label(event),
|
|
156
|
+
event.risk_score,
|
|
157
|
+
event.user_agent,
|
|
158
|
+
event.details || event.metadata&.dig("message") || "-"
|
|
159
|
+
].map { |value| Services::AuditData.csv_cell(value) }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Controllers
|
|
3
|
+
module AuditExport
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
LIMIT = 1000
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
rescue_from Services::AdministrativeAudit::Unavailable, ActiveRecord::ActiveRecordError do |error|
|
|
10
|
+
raise error unless action_name == "export"
|
|
11
|
+
Beskar::Logger.warn("Audit export unavailable (#{error.class})")
|
|
12
|
+
render plain: "Audit export unavailable", status: :service_unavailable
|
|
13
|
+
end
|
|
14
|
+
rescue_from Services::AdministrativeBans::InvalidInput do |error|
|
|
15
|
+
render plain: error.message, status: :unprocessable_content
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def export_records(relation)
|
|
22
|
+
response.set_header("Cache-Control", "private, no-store")
|
|
23
|
+
response.set_header("X-Content-Type-Options", "nosniff")
|
|
24
|
+
unless request.format.csv? || request.format.json?
|
|
25
|
+
head :not_acceptable
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
cursor = params[:before_id]
|
|
29
|
+
if cursor.present?
|
|
30
|
+
unless cursor.is_a?(String) && cursor.match?(/\A[1-9]\d{0,18}\z/) && cursor.to_i <= 9_223_372_036_854_775_807
|
|
31
|
+
render json: {error: "before_id must be a positive record ID"}, status: :unprocessable_content
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
relation = relation.where(id: ...cursor.to_i)
|
|
35
|
+
end
|
|
36
|
+
records = relation.reorder(id: :desc).limit(LIMIT + 1).to_a
|
|
37
|
+
truncated = records.length > LIMIT
|
|
38
|
+
records = records.first(LIMIT)
|
|
39
|
+
# Persist before disclosing any body. This records an authorized export
|
|
40
|
+
# preparation, not proof the client finished receiving the response.
|
|
41
|
+
Services::AdministrativeAudit.record!(actor: administrative_actor!,
|
|
42
|
+
reason: request.headers["X-Beskar-Audit-Reason"] || params[:audit_reason], request_id: request.request_id,
|
|
43
|
+
action: "audit_exported", target_type: relation.klass.name.demodulize,
|
|
44
|
+
after_state: {format: request.format.to_s, count: records.length, highest_id: records.first&.id,
|
|
45
|
+
lowest_id: records.last&.id, before_id: cursor, truncated: truncated,
|
|
46
|
+
filters: request.query_parameters.except("audit_reason", "controller", "action", "format")})
|
|
47
|
+
response.set_header("X-Beskar-Export-Limit", LIMIT.to_s)
|
|
48
|
+
response.set_header("X-Beskar-Export-Truncated", truncated.to_s)
|
|
49
|
+
response.set_header("X-Beskar-Next-Cursor", records.last.id.to_s) if truncated
|
|
50
|
+
records
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,69 +1,97 @@
|
|
|
1
1
|
module Beskar
|
|
2
2
|
module Controllers
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
3
|
+
# Rails-native integration:
|
|
4
|
+
# before_action -> { admit_authentication_attempt(User, :user) }, only: :create
|
|
5
|
+
# After User.authenticate_by succeeds:
|
|
6
|
+
# return unless complete_authentication(user) { start_new_session_for(user) }
|
|
7
|
+
# Existing-session readers must also call user.beskar_access_allowed?(request).
|
|
20
8
|
module SecurityTracking
|
|
21
9
|
extend ActiveSupport::Concern
|
|
22
10
|
|
|
23
11
|
private
|
|
24
12
|
|
|
25
|
-
#
|
|
26
|
-
#
|
|
13
|
+
# Run before password verification. Credentials are identity fields only;
|
|
14
|
+
# passwords must never be included in a persistent counter key.
|
|
15
|
+
def admit_authentication_attempt(model, scope = :user, credentials: nil)
|
|
16
|
+
values = params[scope].is_a?(ActionController::Parameters) ? params[scope] : params
|
|
17
|
+
key = model.attribute_names.include?("email_address") ? :email_address : :email
|
|
18
|
+
credentials ||= {key => values[key]}
|
|
19
|
+
user = model.find_by(credentials)
|
|
20
|
+
@beskar_authentication_attempt = Services::AuthenticationAttempt.reserve(request,
|
|
21
|
+
model: model, scope: scope, user: user, credentials: credentials, cache: true)
|
|
22
|
+
return true if @beskar_authentication_attempt.allowed?
|
|
23
|
+
|
|
24
|
+
model.track_failed_authentication(request, scope, attempt: @beskar_authentication_attempt)
|
|
25
|
+
render_authentication_response(@beskar_authentication_attempt.response)
|
|
26
|
+
false
|
|
27
|
+
rescue Services::AuthenticationAttempt::Unavailable, ActiveRecord::ActiveRecordError
|
|
28
|
+
render_authentication_response(Services::AuthenticationAttempt.unavailable_response)
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The preferred Rails-native API. Final audit persistence happens after the
|
|
33
|
+
# session guard, so a denied session is never learned as a successful login.
|
|
34
|
+
def complete_authentication(user)
|
|
35
|
+
return false unless user
|
|
36
|
+
attempt = native_authentication_attempt(user)
|
|
37
|
+
user.track_authentication_event(request, :success, attempt: attempt, persist: false)
|
|
38
|
+
if attempt.allowed?
|
|
39
|
+
admitted = user.with_beskar_session(request, generation: attempt.session_token) { yield }
|
|
40
|
+
attempt.deny!(:account_locked) unless admitted
|
|
41
|
+
end
|
|
42
|
+
if attempt.event
|
|
43
|
+
attempt.event.event_type = "authentication_blocked" unless attempt.allowed?
|
|
44
|
+
attempt.event.metadata = (attempt.event.metadata || {}).merge("authentication" => attempt.metadata)
|
|
45
|
+
if Beskar.configuration.track_successful_logins?
|
|
46
|
+
user.send(:persist_authentication_audit, attempt.event)
|
|
47
|
+
user.analyze_suspicious_patterns_async if attempt.allowed? && Beskar.configuration.auto_analyze_patterns?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
render_authentication_response(attempt.response) unless attempt.allowed?
|
|
51
|
+
attempt.allowed?
|
|
52
|
+
rescue Services::AuthenticationAttempt::Unavailable, ActiveRecord::ActiveRecordError
|
|
53
|
+
render_authentication_response(Services::AuthenticationAttempt.unavailable_response)
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Compatibility API for custom integrations; callers must honor the boolean
|
|
58
|
+
# result and guard session creation. Prefer complete_authentication above.
|
|
27
59
|
def track_authentication_success(user)
|
|
28
|
-
return unless user
|
|
29
|
-
|
|
60
|
+
return false unless user
|
|
61
|
+
attempt = native_authentication_attempt(user)
|
|
62
|
+
user.track_authentication_event(request, :success, attempt: attempt)
|
|
63
|
+
render_authentication_response(attempt.response) unless attempt.allowed?
|
|
64
|
+
attempt.allowed?
|
|
65
|
+
rescue Services::AuthenticationAttempt::Unavailable
|
|
66
|
+
render_authentication_response(Services::AuthenticationAttempt.unavailable_response)
|
|
67
|
+
false
|
|
68
|
+
end
|
|
30
69
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Rails.logger.error "[Beskar] Failed to track authentication success: #{e.message}"
|
|
70
|
+
def native_authentication_attempt(user)
|
|
71
|
+
@beskar_authentication_attempt ||= Services::AuthenticationAttempt.reserve(request,
|
|
72
|
+
model: user.class, scope: user.class.name.underscore, user: user, cache: true)
|
|
35
73
|
end
|
|
36
74
|
|
|
37
|
-
# Track failed authentication attempt
|
|
38
|
-
# This should be called when authentication fails
|
|
39
75
|
def track_authentication_failure(model_class, scope = :user)
|
|
40
|
-
|
|
76
|
+
model_class.track_failed_authentication(request, scope, attempt: @beskar_authentication_attempt)
|
|
77
|
+
end
|
|
41
78
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
79
|
+
def render_authentication_response(response)
|
|
80
|
+
return if performed?
|
|
81
|
+
status, headers, body = response
|
|
82
|
+
headers.each { |name, value| self.response.set_header(name, value) }
|
|
83
|
+
render body: body.join, status: status
|
|
46
84
|
end
|
|
47
85
|
|
|
48
|
-
# Track logout event
|
|
49
86
|
def track_logout(user)
|
|
50
|
-
return unless user
|
|
51
|
-
return unless Beskar.configuration.security_tracking_enabled?
|
|
52
|
-
|
|
87
|
+
return unless user && Beskar.configuration.security_tracking_enabled?
|
|
53
88
|
user.security_events.create!(
|
|
54
|
-
event_type: "logout",
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
metadata: {
|
|
58
|
-
timestamp: Time.current.iso8601,
|
|
59
|
-
session_id: request.session.id,
|
|
60
|
-
request_path: request.path
|
|
61
|
-
},
|
|
62
|
-
risk_score: 0
|
|
89
|
+
event_type: "logout", ip_address: Services::RequestContext.ip(request),
|
|
90
|
+
user_agent: Services::RequestContext.text(request.user_agent), risk_score: 0,
|
|
91
|
+
metadata: {timestamp: Time.current.iso8601, request_path: Services::RequestContext.path(request)}
|
|
63
92
|
)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
Rails.logger.error "[Beskar] Failed to track logout: #{e.message}"
|
|
93
|
+
rescue => error
|
|
94
|
+
Beskar::Logger.warn("Logout audit unavailable (#{error.class})")
|
|
67
95
|
end
|
|
68
96
|
end
|
|
69
97
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Controllers
|
|
3
|
+
# Include in the host API base controller, after host credential resolution.
|
|
4
|
+
# The two readers are host methods; the generation must come from a verified
|
|
5
|
+
# credential, not a freshly queried value or an unsigned request parameter.
|
|
6
|
+
module SessionSecurity
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
before_action :require_beskar_session!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def require_beskar_session!
|
|
16
|
+
unless respond_to?(:beskar_authenticated_user, true) && respond_to?(:beskar_authenticated_generation, true)
|
|
17
|
+
raise Services::AuthenticationAttempt::Unavailable, "Session adapter is not configured"
|
|
18
|
+
end
|
|
19
|
+
unless Services::SessionRevocation.allowed?(beskar_authenticated_user, request: request, token: beskar_authenticated_generation)
|
|
20
|
+
response.headers["Cache-Control"] = "no-store"
|
|
21
|
+
head :unauthorized
|
|
22
|
+
end
|
|
23
|
+
rescue Services::AuthenticationAttempt::Unavailable
|
|
24
|
+
response.headers["Cache-Control"] = "no-store"
|
|
25
|
+
head :service_unavailable
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
class NotificationJob < ApplicationJob
|
|
3
|
+
self.log_arguments = false
|
|
4
|
+
queue_as :beskar_notifications
|
|
5
|
+
|
|
6
|
+
retry_on Services::Notifications::DeliveryError, wait: :polynomially_longer, attempts: 5
|
|
7
|
+
|
|
8
|
+
def perform(user_type:, user_id:, kind:, recipient_index: nil)
|
|
9
|
+
return unless Services::Notifications.enabled?(kind)
|
|
10
|
+
ConfigurationValidator.new(Beskar.configuration).validate_notifications!
|
|
11
|
+
delivery = SecurityMailer.notification(user_type, user_id, kind, recipient_index)
|
|
12
|
+
message = delivery.message
|
|
13
|
+
# A deleted account or removed recipient has no message to deliver.
|
|
14
|
+
return unless message.to.present?
|
|
15
|
+
unless message.perform_deliveries && message.raise_delivery_errors
|
|
16
|
+
raise Services::Notifications::DeliveryError, "Security mail requires enabled deliveries and delivery errors"
|
|
17
|
+
end
|
|
18
|
+
raise Services::Notifications::DeliveryError, "Security mail delivery was aborted" unless delivery.deliver_now
|
|
19
|
+
rescue => error
|
|
20
|
+
# Prevent Active Job's failure/retry logs from dumping transport credentials
|
|
21
|
+
# or arbitrary host callback exception messages (including exception causes).
|
|
22
|
+
raise Services::Notifications::DeliveryError, "Security notification delivery failed (#{error.class})", cause: nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def retry_job(options = {})
|
|
26
|
+
result = super
|
|
27
|
+
raise Services::Notifications::DeliveryError, "Security notification retry was not enqueued" unless result
|
|
28
|
+
result
|
|
29
|
+
rescue => error
|
|
30
|
+
raise Services::Notifications::DeliveryError, "Security notification retry failed (#{error.class})", cause: nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
class SecurityMailer < ApplicationMailer
|
|
3
|
+
layout false
|
|
4
|
+
|
|
5
|
+
# Only scalar identity fields enter Action Mailer's process instrumentation.
|
|
6
|
+
def notification(user_type, user_id, kind, recipient_index = nil)
|
|
7
|
+
return unless Services::Notifications.enabled?(kind)
|
|
8
|
+
ConfigurationValidator.new(Beskar.configuration).validate_notifications!
|
|
9
|
+
model = user_type.safe_constantize if user_type.is_a?(String)
|
|
10
|
+
unless model.is_a?(Class) && model < ActiveRecord::Base && model.reflect_on_association(:security_events)
|
|
11
|
+
raise Configuration::Error, "Notification account model is unsupported"
|
|
12
|
+
end
|
|
13
|
+
user = model.find_by(id: user_id)
|
|
14
|
+
return unless user
|
|
15
|
+
|
|
16
|
+
settings = Beskar.configuration.notifications
|
|
17
|
+
if kind == "security_team_reset"
|
|
18
|
+
return unless recipient_index.is_a?(Integer) && recipient_index >= 0
|
|
19
|
+
recipient = settings[:security_team_recipients][recipient_index]
|
|
20
|
+
return unless recipient
|
|
21
|
+
subject = "Security notice: account password invalidated"
|
|
22
|
+
body = "A precautionary emergency password reset was performed for #{user.class.base_class.name} ID #{user.id}.\n" \
|
|
23
|
+
"Review the authenticated security dashboard and follow your incident response policy.\n" \
|
|
24
|
+
"This notification is not proof that the account was compromised.\n"
|
|
25
|
+
else
|
|
26
|
+
recipient = user.respond_to?(:email_address) ? user.email_address : user.email
|
|
27
|
+
unless ConfigurationValidator.mailbox?(recipient)
|
|
28
|
+
raise Configuration::Error, "Notification account requires one valid email address"
|
|
29
|
+
end
|
|
30
|
+
subject, explanation = if kind == "account_locked"
|
|
31
|
+
["Security notice: account locked", "Your account was locked as a security precaution."]
|
|
32
|
+
else
|
|
33
|
+
["Security notice: password reset required", "Your password was invalidated as a security precaution. Your previous password no longer works."]
|
|
34
|
+
end
|
|
35
|
+
body = "#{explanation}\n\nVisit the application's recovery page for help:\n#{settings[:recovery_url]}\n\n" \
|
|
36
|
+
"A password reset does not necessarily unlock your account. Contact your administrator if it remains locked.\n" \
|
|
37
|
+
"This notice does not contain a password or a sign-in link.\n"
|
|
38
|
+
end
|
|
39
|
+
mail(from: settings[:from], to: recipient, subject: subject, body: body, content_type: "text/plain")
|
|
40
|
+
rescue => error
|
|
41
|
+
raise Services::Notifications::DeliveryError, "Security mail preparation failed (#{error.class})", cause: nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Keep this mailer's delivery instrumentation free of addresses, message
|
|
45
|
+
# bodies, and raw transport errors. Do not change logging for host mailers.
|
|
46
|
+
def self.deliver_mail(message)
|
|
47
|
+
ActiveSupport::Notifications.instrument("deliver.beskar_notification", mailer: name) do
|
|
48
|
+
# Recheck after host interceptors; Mail otherwise silently skips delivery
|
|
49
|
+
# or swallows transport errors when these flags are disabled.
|
|
50
|
+
unless message.perform_deliveries && message.raise_delivery_errors
|
|
51
|
+
raise Services::Notifications::DeliveryError, "Security mail delivery is disabled"
|
|
52
|
+
end
|
|
53
|
+
yield
|
|
54
|
+
rescue => error
|
|
55
|
+
raise Services::Notifications::DeliveryError, "Security mail transport failed (#{error.class})", cause: nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
class AdministrativeAction < ApplicationRecord
|
|
3
|
+
attribute :before_state, default: -> { {} }
|
|
4
|
+
attribute :after_state, default: -> { {} }
|
|
5
|
+
|
|
6
|
+
ACTIONS = %w[ban_created ban_updated ban_unbanned ban_extended ban_made_permanent audit_exported configuration_changed].freeze
|
|
7
|
+
before_validation :sanitize_audit_fields
|
|
8
|
+
after_find :sanitize_audit_fields
|
|
9
|
+
|
|
10
|
+
validates :actor, :operation_id, :request_id, :reason, presence: true
|
|
11
|
+
validates :actor, :request_id, length: {maximum: 200}
|
|
12
|
+
validates :reason, length: {maximum: 1000}
|
|
13
|
+
validates :action, inclusion: {in: ACTIONS}
|
|
14
|
+
validates :target_type, inclusion: {in: %w[BannedIp SecurityEvent Configuration]}
|
|
15
|
+
validates :target_id, numericality: {only_integer: true, greater_than: 0}, allow_nil: true
|
|
16
|
+
validates :target_id, presence: true, if: -> { action&.start_with?("ban_") }
|
|
17
|
+
|
|
18
|
+
# Reject ordinary instance saves/updates and destruction. Low-level counter,
|
|
19
|
+
# bulk and SQL APIs can bypass this; this is not tamper-proof storage.
|
|
20
|
+
def readonly?
|
|
21
|
+
persisted? || super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Active Record deliberately bypasses readonly? in its instance delete API.
|
|
25
|
+
def delete
|
|
26
|
+
raise ActiveRecord::ReadOnlyRecord, "Administrative history is append-only" if persisted?
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def sanitize_audit_fields
|
|
33
|
+
{actor: 200, request_id: 200, reason: 1000}.each do |field, limit|
|
|
34
|
+
self[field] = Services::AuditData.field(field, self[field], limit: limit) if has_attribute?(field)
|
|
35
|
+
end
|
|
36
|
+
%i[before_state after_state].each do |field|
|
|
37
|
+
self[field] = Services::AuditData.metadata(self[field]) if has_attribute?(field)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|