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.
Files changed (165) hide show
  1. checksums.yaml +4 -4
  2. data/.node-version +1 -1
  3. data/.yarn/releases/yarn-4.18.1.cjs +1000 -0
  4. data/.yarnrc.yml +3 -5
  5. data/AGENTS.md +7 -4
  6. data/CHANGELOG.md +48 -0
  7. data/Gemfile.lock +35 -1
  8. data/Makefile +12 -0
  9. data/app/assets/icons/trek/passkey.svg +5 -0
  10. data/app/assets/stylesheets/trek/base.css +6 -0
  11. data/app/components/trek/entries/item_component/item_component.css +6 -0
  12. data/app/components/trek/form/image_field_component/image_field_component.html.slim +9 -0
  13. data/app/components/trek/form/password_strength_component/password_strength_component.css +56 -0
  14. data/app/components/trek/form/password_strength_component/password_strength_component.html.slim +8 -0
  15. data/app/components/trek/form/password_strength_component/password_strength_component.js +74 -0
  16. data/app/components/trek/form/password_strength_component.rb +29 -0
  17. data/app/components/trek/gate_component/gate_component.css +7 -0
  18. data/app/components/trek/icon/sprite_component.rb +3 -2
  19. data/app/components/trek/section_component/section_component.css +22 -0
  20. data/app/components/trek/section_component/section_component.html.slim +5 -0
  21. data/app/components/trek/section_component/section_component.js +1 -0
  22. data/app/components/trek/section_component.rb +14 -0
  23. data/app/controllers/concerns/trek/auditable.rb +103 -0
  24. data/app/controllers/concerns/trek/filters.rb +2 -0
  25. data/app/controllers/concerns/trek/passkeys.rb +37 -0
  26. data/app/controllers/trek/csp_reports_controller.rb +62 -0
  27. data/app/controllers/trek/resource_controller.rb +7 -1
  28. data/app/helpers/trek/audit_logs_helper.rb +21 -0
  29. data/app/helpers/trek/user_agents_helper.rb +36 -0
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/passkey_controller.js +118 -0
  32. data/app/javascript/trek.js +4 -0
  33. data/app/jobs/trek/audit_log/purge_job.rb +18 -0
  34. data/app/jobs/trek/purge_expired_sessions_job.rb +14 -0
  35. data/app/jobs/trek/resolve_session_hostname_job.rb +17 -0
  36. data/app/models/concerns/trek/audit_log.rb +30 -0
  37. data/app/models/concerns/trek/users/passkeys.rb +75 -0
  38. data/app/models/concerns/trek/users/password_history.rb +51 -0
  39. data/app/models/concerns/trek/users/password_policy.rb +85 -0
  40. data/app/models/concerns/trek/users/password_renewal.rb +51 -0
  41. data/app/models/concerns/trek/users/session_lifetime.rb +39 -0
  42. data/app/models/concerns/trek/users/sign_in_lockout.rb +67 -0
  43. data/app/models/concerns/trek/users/sign_ins.rb +31 -0
  44. data/app/models/concerns/trek/users/validations.rb +3 -2
  45. data/app/models/trek/feature.rb +6 -0
  46. data/app/models/trek/pwned_passwords.rb +40 -0
  47. data/app/models/trek/user_session.rb +10 -0
  48. data/app/policies/trek/audit_log_policy.rb +34 -0
  49. data/app/policies/trek/user_policy.rb +5 -0
  50. data/app/validators/not_disposable_validator.rb +18 -0
  51. data/config/brakeman.ignore +22 -0
  52. data/config/i18n-tasks.yml +7 -0
  53. data/config/locales/trek.en.yml +144 -39
  54. data/config/locales/trek.fr.yml +145 -40
  55. data/docs/.vitepress/config.mjs +8 -3
  56. data/docs/deploy.md +2 -0
  57. data/docs/proxies.md +53 -0
  58. data/docs/reference/components.md +1 -0
  59. data/docs/reference/environment-variables.md +2 -0
  60. data/docs/reference/generators/admin-user.md +1 -1
  61. data/docs/reference/generators/audit-log.md +113 -0
  62. data/docs/reference/generators/install.md +12 -3
  63. data/docs/reference/generators/security-headers.md +109 -0
  64. data/docs/reference/index.md +3 -0
  65. data/docs/reference/models/tag.md +4 -2
  66. data/docs/reference/models/user.md +123 -3
  67. data/docs/reference/validators.md +94 -0
  68. data/docs/security.md +90 -0
  69. data/lefthook.yml +8 -0
  70. data/lib/generators/trek/install/admin/user_password_resets_generator.rb +5 -0
  71. data/lib/generators/trek/install/admin/user_sessions_generator.rb +5 -0
  72. data/lib/generators/trek/install/admin/users_generator.rb +23 -0
  73. data/lib/generators/trek/install/audit_log_generator.rb +56 -0
  74. data/lib/generators/trek/install/authentication_generator.rb +77 -3
  75. data/lib/generators/trek/install/esbuild_generator.rb +1 -2
  76. data/lib/generators/trek/install/mailer_generator.rb +18 -0
  77. data/lib/generators/trek/install/model_specs_generator.rb +6 -4
  78. data/lib/generators/trek/install/postcss_generator.rb +1 -4
  79. data/lib/generators/trek/install/security_headers_generator.rb +49 -0
  80. data/lib/generators/trek/install/yarn_generator.rb +1 -1
  81. data/lib/generators/trek/install_generator.rb +62 -54
  82. data/lib/generators/trek/taxonomies_generator.rb +5 -0
  83. data/lib/generators/trek/templates/.gitlab-ci.yml +18 -2
  84. data/lib/generators/trek/templates/controllers/admin/active_sessions_controller.rb +21 -0
  85. data/lib/generators/trek/templates/controllers/admin/audit_logs_controller.rb +30 -0
  86. data/lib/generators/trek/templates/controllers/admin/passkey_sessions_controller.rb +41 -0
  87. data/lib/generators/trek/templates/controllers/admin/passkeys_controller.rb +37 -0
  88. data/lib/generators/trek/templates/controllers/admin/user_password_resets_controller.rb +4 -4
  89. data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +53 -4
  90. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +20 -0
  91. data/lib/generators/trek/templates/controllers/concerns/trek/authentication.rb +58 -7
  92. data/lib/generators/trek/templates/helpers/application_helper.rb +0 -4
  93. data/lib/generators/trek/templates/initializers/audit_log.rb +11 -0
  94. data/lib/generators/trek/templates/initializers/content_security_policy.rb +49 -0
  95. data/lib/generators/trek/templates/initializers/permissions_policy.rb +19 -0
  96. data/lib/generators/trek/templates/jobs/admin/password_reset_job.rb +9 -0
  97. data/lib/generators/trek/templates/layouts/admin/auth.html.slim +0 -5
  98. data/lib/generators/trek/templates/layouts/admin.html.slim +0 -3
  99. data/lib/generators/trek/templates/mailers/admin/passwords_mailer.rb +1 -1
  100. data/lib/generators/trek/templates/mailers/admin_mailer.rb +28 -1
  101. data/lib/generators/trek/templates/migrations/audit_logs.rb.erb +27 -0
  102. data/lib/generators/trek/templates/migrations/tags.rb.erb +6 -1
  103. data/lib/generators/trek/templates/migrations/user_passkeys.rb.erb +24 -0
  104. data/lib/generators/trek/templates/migrations/user_password_renewal.rb.erb +8 -0
  105. data/lib/generators/trek/templates/migrations/user_previous_passwords.rb.erb +10 -0
  106. data/lib/generators/trek/templates/migrations/user_session_tracking.rb.erb +11 -0
  107. data/lib/generators/trek/templates/migrations/user_sign_in_lockout.rb.erb +7 -0
  108. data/lib/generators/trek/templates/migrations/user_sign_ins.rb.erb +13 -0
  109. data/lib/generators/trek/templates/models/audit_log.rb +3 -0
  110. data/lib/generators/trek/templates/models/tag_category.rb +2 -1
  111. data/lib/generators/trek/templates/models/user/passkey.rb +17 -0
  112. data/lib/generators/trek/templates/models/user/previous_password.rb +4 -0
  113. data/lib/generators/trek/templates/models/user/session.rb +8 -0
  114. data/lib/generators/trek/templates/policies/audit_log.rb +4 -0
  115. data/lib/generators/trek/templates/spec/mailers/admin/passwords_mailer_spec.rb +16 -0
  116. data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +50 -0
  117. data/lib/generators/trek/templates/spec/models/audit_log_spec.rb +43 -0
  118. data/lib/generators/trek/templates/spec/models/tag_spec.rb +52 -0
  119. data/lib/generators/trek/templates/spec/models/user/session_spec.rb +68 -0
  120. data/lib/generators/trek/templates/spec/models/user_spec.rb +142 -4
  121. data/lib/generators/trek/templates/spec/requests/admin/active_sessions_spec.rb +119 -0
  122. data/lib/generators/trek/templates/spec/requests/admin/audit_logs_spec.rb +149 -0
  123. data/lib/generators/trek/templates/spec/requests/admin/passkey_sessions_spec.rb +103 -0
  124. data/lib/generators/trek/templates/spec/requests/admin/passkeys_spec.rb +104 -0
  125. data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +83 -0
  126. data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +95 -0
  127. data/lib/generators/trek/templates/spec/requests/admin/sign_in_notices_spec.rb +93 -0
  128. data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +43 -6
  129. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +25 -2
  130. data/lib/generators/trek/templates/views/admin/audit_logs/index.html.slim +35 -0
  131. data/lib/generators/trek/templates/views/admin/audit_logs/show.html.slim +50 -0
  132. data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +16 -6
  133. data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +2 -1
  134. data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +27 -1
  135. data/lib/generators/trek/templates/views/admin/users/_passkeys.html.slim +67 -0
  136. data/lib/generators/trek/templates/views/admin/users/_properties.html.slim +21 -0
  137. data/lib/generators/trek/templates/views/admin/users/_sessions.html.slim +70 -0
  138. data/lib/generators/trek/templates/views/admin/users/index.html.slim +13 -0
  139. data/lib/generators/trek/templates/views/admin/users/show.html.slim +10 -21
  140. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +22 -0
  141. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +16 -0
  142. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +22 -0
  143. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +16 -0
  144. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.html.slim +17 -0
  145. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.text.erb +10 -0
  146. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.html.slim +18 -0
  147. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.text.erb +10 -0
  148. data/lib/trek/admin_ip_allowlist.rb +52 -0
  149. data/lib/trek/csp_reports.rb +99 -0
  150. data/lib/trek/data/common_passwords.txt +857 -0
  151. data/lib/trek/data/passkey_providers.json +404 -0
  152. data/lib/trek/disposable_email_domains.rb +53 -0
  153. data/lib/trek/engine.rb +12 -0
  154. data/lib/trek/passkey_providers.rb +19 -0
  155. data/lib/trek/reverse_dns.rb +27 -0
  156. data/lib/trek/version.rb +1 -1
  157. data/lib/trek.rb +13 -0
  158. data/package.json +3 -5
  159. data/renovate.json +11 -1
  160. data/trek.gemspec +3 -0
  161. data/yarn.lock +1757 -2019
  162. metadata +132 -4
  163. data/.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs +0 -541
  164. data/.yarn/releases/yarn-3.8.0.cjs +0 -875
  165. 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
@@ -20,6 +20,8 @@ module Trek
20
20
  # rubocop:disable Rails/LexicallyScopedActionFilter
21
21
  after_action :ensure_filters_applied, only: :index
22
22
  # rubocop:enable Rails/LexicallyScopedActionFilter
23
+
24
+ helper_method :filter_params
23
25
  end
24
26
 
25
27
  def apply_filters(scope, filters)
@@ -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
- @object.destroy
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
+ }
@@ -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