trek 2.3.2 → 2.4.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/.node-version +1 -1
- data/.yarn/releases/yarn-4.18.1.cjs +1000 -0
- data/.yarnrc.yml +3 -5
- data/AGENTS.md +7 -4
- data/CHANGELOG.md +48 -0
- data/Gemfile.lock +35 -1
- data/Makefile +12 -0
- data/app/assets/icons/trek/passkey.svg +5 -0
- data/app/assets/stylesheets/trek/base.css +6 -0
- data/app/components/trek/entries/item_component/item_component.css +6 -0
- data/app/components/trek/form/image_field_component/image_field_component.html.slim +9 -0
- data/app/components/trek/form/password_strength_component/password_strength_component.css +56 -0
- data/app/components/trek/form/password_strength_component/password_strength_component.html.slim +8 -0
- data/app/components/trek/form/password_strength_component/password_strength_component.js +74 -0
- data/app/components/trek/form/password_strength_component.rb +29 -0
- data/app/components/trek/gate_component/gate_component.css +7 -0
- data/app/components/trek/icon/sprite_component.rb +3 -2
- data/app/components/trek/section_component/section_component.css +22 -0
- data/app/components/trek/section_component/section_component.html.slim +5 -0
- data/app/components/trek/section_component/section_component.js +1 -0
- data/app/components/trek/section_component.rb +14 -0
- data/app/controllers/concerns/trek/auditable.rb +103 -0
- data/app/controllers/concerns/trek/filters.rb +2 -0
- data/app/controllers/concerns/trek/passkeys.rb +37 -0
- data/app/controllers/trek/csp_reports_controller.rb +62 -0
- data/app/controllers/trek/resource_controller.rb +7 -1
- data/app/helpers/trek/audit_logs_helper.rb +21 -0
- data/app/helpers/trek/user_agents_helper.rb +36 -0
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/passkey_controller.js +118 -0
- data/app/javascript/trek.js +4 -0
- data/app/jobs/trek/audit_log/purge_job.rb +18 -0
- data/app/jobs/trek/purge_expired_sessions_job.rb +14 -0
- data/app/jobs/trek/resolve_session_hostname_job.rb +17 -0
- data/app/models/concerns/trek/audit_log.rb +30 -0
- data/app/models/concerns/trek/users/passkeys.rb +75 -0
- data/app/models/concerns/trek/users/password_history.rb +51 -0
- data/app/models/concerns/trek/users/password_policy.rb +85 -0
- data/app/models/concerns/trek/users/password_renewal.rb +51 -0
- data/app/models/concerns/trek/users/session_lifetime.rb +39 -0
- data/app/models/concerns/trek/users/sign_in_lockout.rb +67 -0
- data/app/models/concerns/trek/users/sign_ins.rb +31 -0
- data/app/models/concerns/trek/users/validations.rb +3 -2
- data/app/models/trek/feature.rb +6 -0
- data/app/models/trek/pwned_passwords.rb +40 -0
- data/app/models/trek/user_session.rb +10 -0
- data/app/policies/trek/audit_log_policy.rb +34 -0
- data/app/policies/trek/user_policy.rb +5 -0
- data/app/validators/not_disposable_validator.rb +18 -0
- data/config/brakeman.ignore +22 -0
- data/config/i18n-tasks.yml +7 -0
- data/config/locales/trek.en.yml +144 -39
- data/config/locales/trek.fr.yml +145 -40
- data/docs/.vitepress/config.mjs +8 -3
- data/docs/deploy.md +2 -0
- data/docs/proxies.md +53 -0
- data/docs/reference/components.md +1 -0
- data/docs/reference/environment-variables.md +2 -0
- data/docs/reference/generators/admin-user.md +1 -1
- data/docs/reference/generators/audit-log.md +113 -0
- data/docs/reference/generators/install.md +12 -3
- data/docs/reference/generators/security-headers.md +109 -0
- data/docs/reference/index.md +3 -0
- data/docs/reference/models/tag.md +4 -2
- data/docs/reference/models/user.md +123 -3
- data/docs/reference/validators.md +94 -0
- data/docs/security.md +90 -0
- data/lefthook.yml +8 -0
- data/lib/generators/trek/install/admin/user_password_resets_generator.rb +5 -0
- data/lib/generators/trek/install/admin/user_sessions_generator.rb +5 -0
- data/lib/generators/trek/install/admin/users_generator.rb +23 -0
- data/lib/generators/trek/install/audit_log_generator.rb +56 -0
- data/lib/generators/trek/install/authentication_generator.rb +77 -3
- data/lib/generators/trek/install/esbuild_generator.rb +1 -2
- data/lib/generators/trek/install/mailer_generator.rb +18 -0
- data/lib/generators/trek/install/model_specs_generator.rb +6 -4
- data/lib/generators/trek/install/postcss_generator.rb +1 -4
- data/lib/generators/trek/install/security_headers_generator.rb +49 -0
- data/lib/generators/trek/install/yarn_generator.rb +1 -1
- data/lib/generators/trek/install_generator.rb +62 -54
- data/lib/generators/trek/taxonomies_generator.rb +5 -0
- data/lib/generators/trek/templates/.gitlab-ci.yml +18 -2
- data/lib/generators/trek/templates/controllers/admin/active_sessions_controller.rb +21 -0
- data/lib/generators/trek/templates/controllers/admin/audit_logs_controller.rb +30 -0
- data/lib/generators/trek/templates/controllers/admin/passkey_sessions_controller.rb +41 -0
- data/lib/generators/trek/templates/controllers/admin/passkeys_controller.rb +37 -0
- data/lib/generators/trek/templates/controllers/admin/user_password_resets_controller.rb +4 -4
- data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +53 -4
- data/lib/generators/trek/templates/controllers/admin/users_controller.rb +20 -0
- data/lib/generators/trek/templates/controllers/concerns/trek/authentication.rb +58 -7
- data/lib/generators/trek/templates/helpers/application_helper.rb +0 -4
- data/lib/generators/trek/templates/initializers/audit_log.rb +11 -0
- data/lib/generators/trek/templates/initializers/content_security_policy.rb +49 -0
- data/lib/generators/trek/templates/initializers/permissions_policy.rb +19 -0
- data/lib/generators/trek/templates/jobs/admin/password_reset_job.rb +9 -0
- data/lib/generators/trek/templates/layouts/admin/auth.html.slim +0 -5
- data/lib/generators/trek/templates/layouts/admin.html.slim +0 -3
- data/lib/generators/trek/templates/mailers/admin/passwords_mailer.rb +1 -1
- data/lib/generators/trek/templates/mailers/admin_mailer.rb +28 -1
- data/lib/generators/trek/templates/migrations/audit_logs.rb.erb +27 -0
- data/lib/generators/trek/templates/migrations/tags.rb.erb +6 -1
- data/lib/generators/trek/templates/migrations/user_passkeys.rb.erb +24 -0
- data/lib/generators/trek/templates/migrations/user_password_renewal.rb.erb +8 -0
- data/lib/generators/trek/templates/migrations/user_previous_passwords.rb.erb +10 -0
- data/lib/generators/trek/templates/migrations/user_session_tracking.rb.erb +11 -0
- data/lib/generators/trek/templates/migrations/user_sign_in_lockout.rb.erb +7 -0
- data/lib/generators/trek/templates/migrations/user_sign_ins.rb.erb +13 -0
- data/lib/generators/trek/templates/models/audit_log.rb +3 -0
- data/lib/generators/trek/templates/models/tag_category.rb +2 -1
- data/lib/generators/trek/templates/models/user/passkey.rb +17 -0
- data/lib/generators/trek/templates/models/user/previous_password.rb +4 -0
- data/lib/generators/trek/templates/models/user/session.rb +8 -0
- data/lib/generators/trek/templates/policies/audit_log.rb +4 -0
- data/lib/generators/trek/templates/spec/mailers/admin/passwords_mailer_spec.rb +16 -0
- data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +50 -0
- data/lib/generators/trek/templates/spec/models/audit_log_spec.rb +43 -0
- data/lib/generators/trek/templates/spec/models/tag_spec.rb +52 -0
- data/lib/generators/trek/templates/spec/models/user/session_spec.rb +68 -0
- data/lib/generators/trek/templates/spec/models/user_spec.rb +142 -4
- data/lib/generators/trek/templates/spec/requests/admin/active_sessions_spec.rb +119 -0
- data/lib/generators/trek/templates/spec/requests/admin/audit_logs_spec.rb +149 -0
- data/lib/generators/trek/templates/spec/requests/admin/passkey_sessions_spec.rb +103 -0
- data/lib/generators/trek/templates/spec/requests/admin/passkeys_spec.rb +104 -0
- data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +83 -0
- data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +95 -0
- data/lib/generators/trek/templates/spec/requests/admin/sign_in_notices_spec.rb +93 -0
- data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +43 -6
- data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +25 -2
- data/lib/generators/trek/templates/views/admin/audit_logs/index.html.slim +35 -0
- data/lib/generators/trek/templates/views/admin/audit_logs/show.html.slim +50 -0
- data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +16 -6
- data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +2 -1
- data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +27 -1
- data/lib/generators/trek/templates/views/admin/users/_passkeys.html.slim +67 -0
- data/lib/generators/trek/templates/views/admin/users/_properties.html.slim +21 -0
- data/lib/generators/trek/templates/views/admin/users/_sessions.html.slim +70 -0
- data/lib/generators/trek/templates/views/admin/users/index.html.slim +13 -0
- data/lib/generators/trek/templates/views/admin/users/show.html.slim +10 -21
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +22 -0
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +16 -0
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +22 -0
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +16 -0
- data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.html.slim +17 -0
- data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.text.erb +10 -0
- data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.html.slim +18 -0
- data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.text.erb +10 -0
- data/lib/trek/admin_ip_allowlist.rb +52 -0
- data/lib/trek/csp_reports.rb +99 -0
- data/lib/trek/data/common_passwords.txt +857 -0
- data/lib/trek/data/passkey_providers.json +404 -0
- data/lib/trek/disposable_email_domains.rb +53 -0
- data/lib/trek/engine.rb +12 -0
- data/lib/trek/passkey_providers.rb +19 -0
- data/lib/trek/reverse_dns.rb +27 -0
- data/lib/trek/version.rb +1 -1
- data/lib/trek.rb +13 -0
- data/package.json +3 -5
- data/renovate.json +11 -1
- data/trek.gemspec +3 -0
- data/yarn.lock +1757 -2019
- metadata +132 -4
- data/.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs +0 -541
- data/.yarn/releases/yarn-3.8.0.cjs +0 -875
- data/lib/generators/trek/templates/models/user_session.rb +0 -3
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
# Records an AuditLog row for each request: who (`audit_actor`), what (`controller#action`, the
|
|
5
|
+
# HTTP method, path and parameter names), on which record (`audit_target`), and how it ended (the
|
|
6
|
+
# status, or the class of the error raised).
|
|
7
|
+
#
|
|
8
|
+
# Only mutations are recorded: GET and HEAD are skipped, unless a controller sets
|
|
9
|
+
# `self.audit_reads = true`. A read on sensitive data can be logged explicitly with `audit!`.
|
|
10
|
+
#
|
|
11
|
+
# Auditing never breaks the request: a failed write is reported through `Rails.error`.
|
|
12
|
+
# FEATURE_AUDIT_LOG=false switches it off, read on each request.
|
|
13
|
+
module Auditable
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
class_attribute :audit_reads, default: false
|
|
18
|
+
# The parameters whose values are recorded, at any depth; the others are recorded by name only,
|
|
19
|
+
# so the log doesn't copy the data the forms send. `config.filter_parameters` still applies.
|
|
20
|
+
class_attribute :audit_param_values, default: %w[id role]
|
|
21
|
+
|
|
22
|
+
# Prepended so it wraps authentication too: requests refused or redirected by
|
|
23
|
+
# `require_authentication`, and failed sign-ins, are recorded as well.
|
|
24
|
+
prepend_around_action :record_audit_log
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Records `name` on `target` now, whatever the HTTP method, e.g. `audit!(:show_answers, @object)`
|
|
30
|
+
# in a GET action showing sensitive data.
|
|
31
|
+
def audit!(name, target = audit_target)
|
|
32
|
+
write_audit_log(action: name, target:) if Trek::Feature.audit_log?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def record_audit_log
|
|
36
|
+
return yield unless Trek::Feature.audit_log?
|
|
37
|
+
return yield if read_only_request? && !audit_reads
|
|
38
|
+
|
|
39
|
+
error = nil
|
|
40
|
+
begin
|
|
41
|
+
yield
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
error = e
|
|
44
|
+
raise
|
|
45
|
+
ensure
|
|
46
|
+
write_audit_log(status: (response.status unless error), error:)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Rails answers `get?` with false for a HEAD, which uptime monitors use.
|
|
51
|
+
def read_only_request?
|
|
52
|
+
request.get? || request.head?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def write_audit_log(action: action_name, target: audit_target, status: nil, error: nil)
|
|
56
|
+
::AuditLog.create!(
|
|
57
|
+
actor: audit_actor,
|
|
58
|
+
auditable: target,
|
|
59
|
+
action: "#{controller_path}##{action}",
|
|
60
|
+
http_method: request.request_method,
|
|
61
|
+
path: request.path,
|
|
62
|
+
http_status: status,
|
|
63
|
+
params: audit_params,
|
|
64
|
+
ip_address: request.remote_ip,
|
|
65
|
+
user_agent: request.user_agent,
|
|
66
|
+
request_id: request.request_id,
|
|
67
|
+
error_class: error&.class&.name,
|
|
68
|
+
**audit_attributes
|
|
69
|
+
)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
Rails.error.report(e, handled: true, context: { action: "#{controller_path}##{action}" })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Who made the request; nil when nobody is signed in.
|
|
75
|
+
def audit_actor
|
|
76
|
+
Current.user
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The record the action worked on: `@object`, as Trek::ResourceController sets it, once saved
|
|
80
|
+
# (not after a failed create) or destroyed. Override for other controllers.
|
|
81
|
+
def audit_target
|
|
82
|
+
@object if @object.is_a?(ActiveRecord::Base) && (@object.persisted? || @object.destroyed?)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The request's parameter names, with the values of `audit_param_values` only.
|
|
86
|
+
def audit_params
|
|
87
|
+
redact_audit_values(request.filtered_parameters.except("controller", "action"))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def redact_audit_values(value, key = nil)
|
|
91
|
+
case value
|
|
92
|
+
when Hash then value.to_h { |k, v| [k, redact_audit_values(v, k)] }
|
|
93
|
+
when Array then value.map { |v| redact_audit_values(v, key) }
|
|
94
|
+
else audit_param_values.map(&:to_s).include?(key.to_s) ? value : "[FILTERED]"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Values for the app's own columns, e.g. `{ event: audit_target.try(:event) }`.
|
|
99
|
+
def audit_attributes
|
|
100
|
+
{}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
# The WebAuthn relying party and challenges, for controllers adding passkeys or signing in with one
|
|
5
|
+
# (Trek::Users::Passkeys).
|
|
6
|
+
module Passkeys
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
# How long the browser has to answer, and the user to touch their authenticator
|
|
10
|
+
CHALLENGE_LIFETIME = 5.minutes
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Passkeys belong to a domain: the request's, so they work on any environment without
|
|
15
|
+
# configuration, each with its own passkeys. Behind a proxy, it must pass the original host and
|
|
16
|
+
# scheme on (see the docs on proxies).
|
|
17
|
+
def passkey_relying_party
|
|
18
|
+
@passkey_relying_party ||= WebAuthn::RelyingParty.new(
|
|
19
|
+
id: request.host,
|
|
20
|
+
allowed_origins: [request.base_url],
|
|
21
|
+
name: t("application_name")
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Renders the options as JSON for the browser, keeping their challenge for the answer
|
|
26
|
+
def render_passkey_options(options)
|
|
27
|
+
session[:passkey_challenge] = { "value" => options.challenge, "at" => Time.current.to_i }
|
|
28
|
+
render json: options
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The challenge the browser was given, once only, and while it's fresh
|
|
32
|
+
def take_passkey_challenge
|
|
33
|
+
challenge = session.delete(:passkey_challenge) || {}
|
|
34
|
+
challenge["value"] if challenge["at"].to_i > CHALLENGE_LIFETIME.ago.to_i
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
# Receives the reports browsers send about Content Security Policy violations, at the policy's
|
|
5
|
+
# report-uri (`post "csp-reports"`, from `trek:install:security_headers`). Each violation is logged,
|
|
6
|
+
# and reported to AppSignal when the app uses it: in a "csp" namespace, as one incident per
|
|
7
|
+
# directive and blocked origin, so a new violation is notified once.
|
|
8
|
+
#
|
|
9
|
+
# Anyone can send reports, so a flood mustn't bury the real violations in AppSignal: it gets each
|
|
10
|
+
# violation once an hour at most, and NEW_VIOLATIONS_PER_HOUR different ones at most, the others
|
|
11
|
+
# being logged only. Requests are rate-limited per IP address, and for all of them together. The
|
|
12
|
+
# counters are in Rails.cache: a cache per process or server counts on its own.
|
|
13
|
+
#
|
|
14
|
+
# Browsers send no CSRF token nor session: the endpoint needs neither, sets no cookie, and answers
|
|
15
|
+
# 204 (429 past the rate limits).
|
|
16
|
+
class CspReportsController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
|
17
|
+
# A report is a few hundred bytes; the Reporting API may group a few
|
|
18
|
+
MAX_BODY = 64.kilobytes
|
|
19
|
+
NEW_VIOLATIONS_PER_HOUR = 20
|
|
20
|
+
|
|
21
|
+
# Browsers send no CSRF token; the endpoint only logs and reports, with no session nor user
|
|
22
|
+
skip_forgery_protection
|
|
23
|
+
# Rails 7.2+
|
|
24
|
+
if respond_to?(:rate_limit)
|
|
25
|
+
rate_limit to: 60, within: 1.minute, with: -> { head :too_many_requests }
|
|
26
|
+
# Against a flood from many addresses
|
|
27
|
+
rate_limit to: 600, within: 1.minute, by: -> { "all" }, with: -> { head :too_many_requests }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create
|
|
31
|
+
body = request.body.read(MAX_BODY + 1).to_s
|
|
32
|
+
CspReports.parse(body, host: request.host).each { |violation| report(violation) } if body.bytesize <= MAX_BODY
|
|
33
|
+
|
|
34
|
+
head :no_content
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def report(violation)
|
|
40
|
+
logger.warn("[CSP] #{violation}")
|
|
41
|
+
return unless defined?(::Appsignal) && ::Appsignal.respond_to?(:report_error) && first_this_hour?(violation)
|
|
42
|
+
|
|
43
|
+
::Appsignal.report_error(CspViolation.new(violation.to_s)) do
|
|
44
|
+
::Appsignal.set_namespace("csp")
|
|
45
|
+
::Appsignal.set_action(incident(violation))
|
|
46
|
+
::Appsignal.add_tags(page: violation.page, disposition: violation.disposition)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# AppSignal groups the violations by action: the directive and the blocked origin
|
|
51
|
+
def incident(violation)
|
|
52
|
+
"#{violation.directive} #{violation.blocked}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Not reported yet this hour, and among the hour's first new violations
|
|
56
|
+
def first_this_hour?(violation)
|
|
57
|
+
return false unless Rails.cache.write("trek/csp_reports/#{incident(violation)}", true, expires_in: 1.hour, unless_exist: true)
|
|
58
|
+
|
|
59
|
+
Rails.cache.increment("trek/csp_reports/new", 1, expires_in: 1.hour).to_i <= NEW_VIOLATIONS_PER_HOUR
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -62,7 +62,13 @@ module Trek
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def destroy
|
|
65
|
-
|
|
65
|
+
# Refused by the model, e.g. with `dependent: :restrict_with_error`: says why
|
|
66
|
+
unless @object.destroy
|
|
67
|
+
return respond_to do |format|
|
|
68
|
+
format.html { redirect_back_or_to [:admin, @object], alert: @object.errors.full_messages.to_sentence }
|
|
69
|
+
format.json { render json: @object.errors, status: :unprocessable_entity }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
66
72
|
|
|
67
73
|
destroy_hook
|
|
68
74
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
module AuditLogsHelper
|
|
5
|
+
# A record's name, linked to its admin page when the viewer may open it. Records without an
|
|
6
|
+
# admin page, or deleted since, show their name only.
|
|
7
|
+
def audit_log_record_link(record)
|
|
8
|
+
return if record.nil?
|
|
9
|
+
return record.to_s unless allowed_to?(:show?, record)
|
|
10
|
+
|
|
11
|
+
link_to record.to_s, [:admin, record]
|
|
12
|
+
rescue ActionPolicy::NotFound, NoMethodError, ActionController::UrlGenerationError
|
|
13
|
+
record.to_s
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The audit log of a record, for a button on its admin page.
|
|
17
|
+
def audit_log_path_for(record)
|
|
18
|
+
admin_audit_logs_path(filters: { auditable_type: record.class.polymorphic_name, auditable_id: record.id })
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
module UserAgentsHelper
|
|
5
|
+
# Checked in order: Edge and Opera also name Chrome, Chrome also names Safari.
|
|
6
|
+
BROWSERS = {
|
|
7
|
+
"Edge" => %r{Edg/},
|
|
8
|
+
"Opera" => %r{OPR/},
|
|
9
|
+
"Firefox" => %r{Firefox/},
|
|
10
|
+
"Chrome" => %r{Chrome/},
|
|
11
|
+
"Safari" => %r{Safari/}
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
# Checked in order: an iPad also names Mac OS X, Android also names Linux.
|
|
15
|
+
SYSTEMS = {
|
|
16
|
+
"iOS" => /iPhone|iPad/,
|
|
17
|
+
"Android" => /Android/,
|
|
18
|
+
"macOS" => /Mac OS X/,
|
|
19
|
+
"Windows" => /Windows/,
|
|
20
|
+
"Linux" => /Linux/
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# The browser and system a User-Agent header names, e.g. "Firefox on Windows"
|
|
24
|
+
def user_agent_summary(user_agent)
|
|
25
|
+
user_agent = user_agent.to_s
|
|
26
|
+
browser = BROWSERS.find { |_name, pattern| user_agent.match?(pattern) }&.first
|
|
27
|
+
system = SYSTEMS.find { |_name, pattern| user_agent.match?(pattern) }&.first
|
|
28
|
+
|
|
29
|
+
if browser && system
|
|
30
|
+
t("trek.user_agents.browser_on_system", browser:, system:)
|
|
31
|
+
else
|
|
32
|
+
browser || system || t("trek.user_agents.unknown")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -7,6 +7,9 @@ import { application } from "./application";
|
|
|
7
7
|
import ElementController from "./element_controller";
|
|
8
8
|
application.register("element", ElementController);
|
|
9
9
|
|
|
10
|
+
import PasskeyController from "./passkey_controller";
|
|
11
|
+
application.register("passkey", PasskeyController);
|
|
12
|
+
|
|
10
13
|
import RemovableElementController from "./removable_element_controller";
|
|
11
14
|
application.register("removable-element", RemovableElementController);
|
|
12
15
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
|
|
3
|
+
// Undefined in browsers without WebAuthn, and unknown to ESLint's browser globals
|
|
4
|
+
const { PublicKeyCredential } = window;
|
|
5
|
+
|
|
6
|
+
// Adds a passkey, or signs in with one, from a form posting the credential: fetches the options
|
|
7
|
+
// from the server, lets the browser create or get the credential, then submits the form with it.
|
|
8
|
+
//
|
|
9
|
+
// Its element stays hidden in browsers without WebAuthn's JSON methods (Safari before 18.4…), so
|
|
10
|
+
// it also hides a link leading to passkeys, without any action. With `conditional`, the site's
|
|
11
|
+
// passkeys are also offered in the autofill of the email field (`autocomplete="username webauthn"`).
|
|
12
|
+
export default class extends Controller {
|
|
13
|
+
static targets = ["credential", "error", "alreadyAdded"];
|
|
14
|
+
static values = { optionsUrl: String, conditional: Boolean };
|
|
15
|
+
|
|
16
|
+
connect() {
|
|
17
|
+
if (!supported()) return;
|
|
18
|
+
|
|
19
|
+
this.element.hidden = false;
|
|
20
|
+
if (this.conditionalValue) this.offerInAutofill();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
disconnect() {
|
|
24
|
+
this.autofill?.abort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Submitting the form (its button, or Enter in a field) creates the passkey first
|
|
28
|
+
submit(event) {
|
|
29
|
+
if (this.credentialTarget.value) return;
|
|
30
|
+
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
this.create();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
create() {
|
|
36
|
+
this.run((options) =>
|
|
37
|
+
navigator.credentials.create({
|
|
38
|
+
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get() {
|
|
44
|
+
// The button replaces the pending autofill request: the browser runs one at a time
|
|
45
|
+
this.autofill?.abort();
|
|
46
|
+
this.run((options) =>
|
|
47
|
+
navigator.credentials.get({
|
|
48
|
+
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async offerInAutofill() {
|
|
54
|
+
if (!(await PublicKeyCredential.isConditionalMediationAvailable?.())) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.autofill = new AbortController();
|
|
59
|
+
const { signal } = this.autofill;
|
|
60
|
+
// The user asked for nothing yet: a failure here stays silent
|
|
61
|
+
this.run(
|
|
62
|
+
(options) =>
|
|
63
|
+
navigator.credentials.get({
|
|
64
|
+
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
|
|
65
|
+
mediation: "conditional",
|
|
66
|
+
signal,
|
|
67
|
+
}),
|
|
68
|
+
{ silent: true }
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async run(ceremony, { silent = false } = {}) {
|
|
73
|
+
if (!silent) this.hideError();
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const credential = await ceremony(await this.fetchOptions());
|
|
77
|
+
this.credentialTarget.value = JSON.stringify(credential.toJSON());
|
|
78
|
+
this.element.requestSubmit();
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!silent) this.showError(error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async fetchOptions() {
|
|
85
|
+
const response = await fetch(this.optionsUrlValue, {
|
|
86
|
+
headers: { Accept: "application/json" },
|
|
87
|
+
});
|
|
88
|
+
if (!response.ok) throw new Error(`Passkey options: ${response.status}`);
|
|
89
|
+
|
|
90
|
+
return response.json();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
showError(error) {
|
|
94
|
+
// Cancelled by the user, timed out, or replaced by another request: nothing to say
|
|
95
|
+
if (["NotAllowedError", "AbortError"].includes(error.name)) return;
|
|
96
|
+
// The authenticator already holds one of the user's passkeys
|
|
97
|
+
if (error.name === "InvalidStateError" && this.hasAlreadyAddedTarget) {
|
|
98
|
+
this.alreadyAddedTarget.hidden = false;
|
|
99
|
+
} else if (this.hasErrorTarget) {
|
|
100
|
+
this.errorTarget.hidden = false;
|
|
101
|
+
} else {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
hideError() {
|
|
107
|
+
[...this.errorTargets, ...this.alreadyAddedTargets].forEach((target) => {
|
|
108
|
+
target.hidden = true;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function supported() {
|
|
114
|
+
return (
|
|
115
|
+
PublicKeyCredential !== undefined &&
|
|
116
|
+
"parseCreationOptionsFromJSON" in PublicKeyCredential
|
|
117
|
+
);
|
|
118
|
+
}
|
data/app/javascript/trek.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import "@hotwired/turbo-rails";
|
|
2
2
|
import "./controllers";
|
|
3
3
|
import "normalize.css";
|
|
4
|
+
// The admin's font, served by the app rather than Google Fonts
|
|
5
|
+
import "@fontsource/inter/400.css";
|
|
6
|
+
import "@fontsource/inter/500.css";
|
|
7
|
+
import "@fontsource/inter/600.css";
|
|
4
8
|
import "../assets/stylesheets/trek/base.css";
|
|
5
9
|
import "../components";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
module AuditLog
|
|
5
|
+
# Deletes the audit log rows older than the retention window: 6 months by default, the low end of
|
|
6
|
+
# the CNIL's recommendation for logs (6 months to 1 year). Schedule it daily with the app's Active
|
|
7
|
+
# Job backend, e.g. a GoodJob cron entry.
|
|
8
|
+
# rubocop:disable Rails/ApplicationJob -- the gem can't rely on the host app's ApplicationJob
|
|
9
|
+
class PurgeJob < ActiveJob::Base
|
|
10
|
+
class_attribute :retention, default: 6.months
|
|
11
|
+
|
|
12
|
+
def perform(retention: self.class.retention)
|
|
13
|
+
::AuditLog.older_than(retention.ago).in_batches(of: 1000).delete_all
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
# rubocop:enable Rails/ApplicationJob
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
# Deletes the signed-in browsers past their idle timeout or lifetime (Trek::Users::SessionLifetime):
|
|
5
|
+
# they can't sign anyone in anymore. Schedule it daily with the app's Active Job backend, e.g. a
|
|
6
|
+
# GoodJob cron entry.
|
|
7
|
+
# rubocop:disable Rails/ApplicationJob -- the gem can't rely on the host app's ApplicationJob
|
|
8
|
+
class PurgeExpiredSessionsJob < ActiveJob::Base
|
|
9
|
+
def perform
|
|
10
|
+
::User::Session.expired.in_batches(of: 1000).delete_all
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
# rubocop:enable Rails/ApplicationJob
|
|
14
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
# Records the host name of the IP address a session signed in from (Trek::ReverseDns), after the
|
|
5
|
+
# sign-in rather than during it: the lookup can take up to a second.
|
|
6
|
+
# rubocop:disable Rails/ApplicationJob -- the gem can't rely on the host app's ApplicationJob
|
|
7
|
+
class ResolveSessionHostnameJob < ActiveJob::Base
|
|
8
|
+
# The session may be gone by then: signed out, or purged
|
|
9
|
+
discard_on ActiveJob::DeserializationError
|
|
10
|
+
|
|
11
|
+
def perform(session)
|
|
12
|
+
hostname = ReverseDns.hostname(session.ip_address)
|
|
13
|
+
session.update_column(:ip_hostname, hostname) if hostname # rubocop:disable Rails/SkipsModelValidations
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
# rubocop:enable Rails/ApplicationJob
|
|
17
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
# One row per audited request, written by Trek::Auditable. Append-only: rows are never updated,
|
|
5
|
+
# only purged after the retention window by Trek::AuditLog::PurgeJob.
|
|
6
|
+
module AuditLog
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
belongs_to :actor, polymorphic: true, optional: true
|
|
11
|
+
belongs_to :auditable, polymorphic: true, optional: true
|
|
12
|
+
|
|
13
|
+
scope :for_actor, ->(actor) { where(actor:) }
|
|
14
|
+
scope :for_auditable, ->(auditable) { where(auditable:) }
|
|
15
|
+
scope :failures, -> { where(http_status: 400..).or(where.not(error_class: nil)) }
|
|
16
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
17
|
+
scope :ordered, -> { recent }
|
|
18
|
+
scope :older_than, ->(time) { where(created_at: ...time) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Rows can't be changed once written; the purge uses delete_all, which skips this.
|
|
22
|
+
def readonly?
|
|
23
|
+
persisted? || super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_s
|
|
27
|
+
[created_at&.iso8601, action, http_status].compact.join(" – ")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
module Users
|
|
5
|
+
# Passkeys (WebAuthn credentials) a user signs in with instead of their password, through the
|
|
6
|
+
# webauthn gem. The authenticator must verify the user (fingerprint, face, PIN…), so a passkey is
|
|
7
|
+
# two factors in itself: something they have, and something they are or know.
|
|
8
|
+
#
|
|
9
|
+
# The relying party, which ties passkeys to the site's domain, comes from the controller
|
|
10
|
+
# (Trek::Passkeys), as does the challenge each ceremony signs.
|
|
11
|
+
#
|
|
12
|
+
# Needs the `user_passkeys` table (User::Passkey) and the users' `webauthn_id` column.
|
|
13
|
+
module Passkeys
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
has_many :passkeys, dependent: :destroy
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class_methods do
|
|
21
|
+
# Asks for any passkey of the site, so the user picks theirs without typing their email
|
|
22
|
+
def passkey_request_options(relying_party)
|
|
23
|
+
relying_party.options_for_authentication(user_verification: "required")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The user whose passkey signed the challenge, among this scope (e.g. User.privileged), or nil
|
|
27
|
+
def authenticate_by_passkey(relying_party, credential, challenge:)
|
|
28
|
+
credential, passkey = relying_party.verify_authentication(
|
|
29
|
+
JSON.parse(credential.to_s), challenge.to_s, user_verification: true
|
|
30
|
+
) { |webauthn_credential| User::Passkey.find_by!(external_id: webauthn_credential.id) }
|
|
31
|
+
user = find_by(id: passkey.user_id)
|
|
32
|
+
# The id the passkey was created for, as the authenticator returns it with the signature
|
|
33
|
+
return unless user && credential.user_handle == user.webauthn_id
|
|
34
|
+
|
|
35
|
+
passkey.update!(sign_count: credential.sign_count, last_used_at: Time.current)
|
|
36
|
+
user
|
|
37
|
+
rescue WebAuthn::Error, ActiveRecord::RecordNotFound, JSON::ParserError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Asks the browser for a passkey the authenticator keeps (a "resident" one, which signing in
|
|
43
|
+
# without an email needs), and verifies the user. The user's passkeys are excluded, so an
|
|
44
|
+
# authenticator already holding one says so rather than adding another.
|
|
45
|
+
def passkey_creation_options(relying_party)
|
|
46
|
+
update!(webauthn_id: WebAuthn.generate_user_id) unless webauthn_id
|
|
47
|
+
|
|
48
|
+
relying_party.options_for_registration(
|
|
49
|
+
user: { id: webauthn_id, name: email, display_name: to_s },
|
|
50
|
+
exclude: passkeys.pluck(:external_id),
|
|
51
|
+
authenticator_selection: { resident_key: "required", user_verification: "required" }
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The passkey the browser created for the challenge, or nil when it doesn't verify. `name`, e.g.
|
|
56
|
+
# the browser it was added from, shows when its provider is unknown (User::Passkey#to_s).
|
|
57
|
+
def add_passkey(relying_party, credential, challenge:, name:)
|
|
58
|
+
credential = relying_party.verify_registration(
|
|
59
|
+
JSON.parse(credential.to_s), challenge.to_s, user_verification: true
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
passkeys.create!(
|
|
63
|
+
name:,
|
|
64
|
+
external_id: credential.id,
|
|
65
|
+
public_key: credential.public_key,
|
|
66
|
+
sign_count: credential.sign_count,
|
|
67
|
+
transports: Array(credential.response.transports),
|
|
68
|
+
aaguid: credential.response.aaguid
|
|
69
|
+
)
|
|
70
|
+
rescue WebAuthn::Error, JSON::ParserError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
module Users
|
|
5
|
+
# Refuses a password among the user's last `password_history_size` ones (5 by default): the
|
|
6
|
+
# current one, and the previous ones, whose digests are kept as User::PreviousPassword records.
|
|
7
|
+
# Set the size in the model, e.g. `self.password_history_size = 10`; 0 turns the check off.
|
|
8
|
+
# Assumes the including model has has_secure_password.
|
|
9
|
+
module PasswordHistory
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
class_attribute :password_history_size, default: 5
|
|
14
|
+
|
|
15
|
+
has_many :previous_passwords, dependent: :delete_all
|
|
16
|
+
|
|
17
|
+
validate :password_not_recently_used, if: -> { password.present? && password_digest_changed? }
|
|
18
|
+
after_update :remember_previous_password, if: :saved_change_to_password_digest?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def password_not_recently_used
|
|
24
|
+
return unless recent_password_digests.any? { |digest| password_digest_matches?(digest) }
|
|
25
|
+
|
|
26
|
+
errors.add(:password, :recently_used, count: password_history_size)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The current password's digest and the previous ones, password_history_size in all
|
|
30
|
+
def recent_password_digests
|
|
31
|
+
return [] unless password_history_size.positive?
|
|
32
|
+
|
|
33
|
+
previous = previous_passwords.order(id: :desc).limit(password_history_size - 1).pluck(:password_digest)
|
|
34
|
+
[password_digest_was, *previous].compact_blank
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Checked the way has_secure_password checks the current password
|
|
38
|
+
def password_digest_matches?(digest)
|
|
39
|
+
BCrypt::Password.new(digest).is_password?(password)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def remember_previous_password
|
|
43
|
+
return unless password_history_size > 1
|
|
44
|
+
|
|
45
|
+
previous_passwords.create!(password_digest: password_digest_before_last_save) if password_digest_before_last_save.present?
|
|
46
|
+
kept = previous_passwords.order(id: :desc).limit(password_history_size - 1).select(:id)
|
|
47
|
+
previous_passwords.where.not(id: kept).delete_all
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|