trek 2.3.1 → 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 (167) 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 +55 -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/concerns/trek/scopes.rb +9 -2
  27. data/app/controllers/trek/csp_reports_controller.rb +62 -0
  28. data/app/controllers/trek/resource_controller.rb +7 -1
  29. data/app/helpers/trek/audit_logs_helper.rb +21 -0
  30. data/app/helpers/trek/user_agents_helper.rb +36 -0
  31. data/app/javascript/controllers/index.js +3 -0
  32. data/app/javascript/controllers/passkey_controller.js +118 -0
  33. data/app/javascript/trek.js +4 -0
  34. data/app/jobs/trek/audit_log/purge_job.rb +18 -0
  35. data/app/jobs/trek/purge_expired_sessions_job.rb +14 -0
  36. data/app/jobs/trek/resolve_session_hostname_job.rb +17 -0
  37. data/app/models/concerns/trek/audit_log.rb +30 -0
  38. data/app/models/concerns/trek/users/passkeys.rb +75 -0
  39. data/app/models/concerns/trek/users/password_history.rb +51 -0
  40. data/app/models/concerns/trek/users/password_policy.rb +85 -0
  41. data/app/models/concerns/trek/users/password_renewal.rb +51 -0
  42. data/app/models/concerns/trek/users/session_lifetime.rb +39 -0
  43. data/app/models/concerns/trek/users/sign_in_lockout.rb +67 -0
  44. data/app/models/concerns/trek/users/sign_ins.rb +31 -0
  45. data/app/models/concerns/trek/users/validations.rb +3 -2
  46. data/app/models/trek/feature.rb +6 -0
  47. data/app/models/trek/pwned_passwords.rb +40 -0
  48. data/app/models/trek/user_session.rb +10 -0
  49. data/app/policies/trek/audit_log_policy.rb +34 -0
  50. data/app/policies/trek/user_policy.rb +5 -0
  51. data/app/validators/not_disposable_validator.rb +18 -0
  52. data/config/brakeman.ignore +22 -0
  53. data/config/i18n-tasks.yml +7 -0
  54. data/config/locales/trek.en.yml +144 -39
  55. data/config/locales/trek.fr.yml +145 -40
  56. data/docs/.vitepress/config.mjs +8 -3
  57. data/docs/deploy.md +2 -0
  58. data/docs/proxies.md +53 -0
  59. data/docs/reference/components.md +1 -0
  60. data/docs/reference/controllers.md +1 -1
  61. data/docs/reference/environment-variables.md +2 -0
  62. data/docs/reference/generators/admin-user.md +1 -1
  63. data/docs/reference/generators/audit-log.md +113 -0
  64. data/docs/reference/generators/install.md +12 -3
  65. data/docs/reference/generators/security-headers.md +109 -0
  66. data/docs/reference/index.md +3 -0
  67. data/docs/reference/models/tag.md +4 -2
  68. data/docs/reference/models/user.md +123 -3
  69. data/docs/reference/validators.md +94 -0
  70. data/docs/security.md +90 -0
  71. data/lefthook.yml +8 -0
  72. data/lib/generators/trek/install/admin/user_password_resets_generator.rb +5 -0
  73. data/lib/generators/trek/install/admin/user_sessions_generator.rb +5 -0
  74. data/lib/generators/trek/install/admin/users_generator.rb +23 -0
  75. data/lib/generators/trek/install/audit_log_generator.rb +56 -0
  76. data/lib/generators/trek/install/authentication_generator.rb +77 -3
  77. data/lib/generators/trek/install/esbuild_generator.rb +1 -2
  78. data/lib/generators/trek/install/mailer_generator.rb +18 -0
  79. data/lib/generators/trek/install/model_specs_generator.rb +6 -4
  80. data/lib/generators/trek/install/postcss_generator.rb +1 -4
  81. data/lib/generators/trek/install/security_headers_generator.rb +49 -0
  82. data/lib/generators/trek/install/yarn_generator.rb +1 -1
  83. data/lib/generators/trek/install_generator.rb +62 -54
  84. data/lib/generators/trek/taxonomies_generator.rb +5 -0
  85. data/lib/generators/trek/templates/.gitlab-ci.yml +18 -2
  86. data/lib/generators/trek/templates/controllers/admin/active_sessions_controller.rb +21 -0
  87. data/lib/generators/trek/templates/controllers/admin/audit_logs_controller.rb +30 -0
  88. data/lib/generators/trek/templates/controllers/admin/passkey_sessions_controller.rb +41 -0
  89. data/lib/generators/trek/templates/controllers/admin/passkeys_controller.rb +37 -0
  90. data/lib/generators/trek/templates/controllers/admin/user_password_resets_controller.rb +4 -4
  91. data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +53 -4
  92. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +20 -0
  93. data/lib/generators/trek/templates/controllers/concerns/trek/authentication.rb +58 -7
  94. data/lib/generators/trek/templates/helpers/application_helper.rb +0 -4
  95. data/lib/generators/trek/templates/initializers/audit_log.rb +11 -0
  96. data/lib/generators/trek/templates/initializers/content_security_policy.rb +49 -0
  97. data/lib/generators/trek/templates/initializers/permissions_policy.rb +19 -0
  98. data/lib/generators/trek/templates/jobs/admin/password_reset_job.rb +9 -0
  99. data/lib/generators/trek/templates/layouts/admin/auth.html.slim +0 -5
  100. data/lib/generators/trek/templates/layouts/admin.html.slim +0 -3
  101. data/lib/generators/trek/templates/mailers/admin/passwords_mailer.rb +1 -1
  102. data/lib/generators/trek/templates/mailers/admin_mailer.rb +28 -1
  103. data/lib/generators/trek/templates/migrations/audit_logs.rb.erb +27 -0
  104. data/lib/generators/trek/templates/migrations/tags.rb.erb +6 -1
  105. data/lib/generators/trek/templates/migrations/user_passkeys.rb.erb +24 -0
  106. data/lib/generators/trek/templates/migrations/user_password_renewal.rb.erb +8 -0
  107. data/lib/generators/trek/templates/migrations/user_previous_passwords.rb.erb +10 -0
  108. data/lib/generators/trek/templates/migrations/user_session_tracking.rb.erb +11 -0
  109. data/lib/generators/trek/templates/migrations/user_sign_in_lockout.rb.erb +7 -0
  110. data/lib/generators/trek/templates/migrations/user_sign_ins.rb.erb +13 -0
  111. data/lib/generators/trek/templates/models/audit_log.rb +3 -0
  112. data/lib/generators/trek/templates/models/tag_category.rb +2 -1
  113. data/lib/generators/trek/templates/models/user/passkey.rb +17 -0
  114. data/lib/generators/trek/templates/models/user/previous_password.rb +4 -0
  115. data/lib/generators/trek/templates/models/user/session.rb +8 -0
  116. data/lib/generators/trek/templates/policies/audit_log.rb +4 -0
  117. data/lib/generators/trek/templates/spec/mailers/admin/passwords_mailer_spec.rb +16 -0
  118. data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +50 -0
  119. data/lib/generators/trek/templates/spec/models/audit_log_spec.rb +43 -0
  120. data/lib/generators/trek/templates/spec/models/tag_spec.rb +52 -0
  121. data/lib/generators/trek/templates/spec/models/user/session_spec.rb +68 -0
  122. data/lib/generators/trek/templates/spec/models/user_spec.rb +142 -4
  123. data/lib/generators/trek/templates/spec/requests/admin/active_sessions_spec.rb +119 -0
  124. data/lib/generators/trek/templates/spec/requests/admin/audit_logs_spec.rb +149 -0
  125. data/lib/generators/trek/templates/spec/requests/admin/passkey_sessions_spec.rb +103 -0
  126. data/lib/generators/trek/templates/spec/requests/admin/passkeys_spec.rb +104 -0
  127. data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +83 -0
  128. data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +95 -0
  129. data/lib/generators/trek/templates/spec/requests/admin/sign_in_notices_spec.rb +93 -0
  130. data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +55 -6
  131. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +31 -2
  132. data/lib/generators/trek/templates/views/admin/audit_logs/index.html.slim +35 -0
  133. data/lib/generators/trek/templates/views/admin/audit_logs/show.html.slim +50 -0
  134. data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +17 -7
  135. data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +3 -2
  136. data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +28 -2
  137. data/lib/generators/trek/templates/views/admin/users/_passkeys.html.slim +67 -0
  138. data/lib/generators/trek/templates/views/admin/users/_properties.html.slim +21 -0
  139. data/lib/generators/trek/templates/views/admin/users/_sessions.html.slim +70 -0
  140. data/lib/generators/trek/templates/views/admin/users/index.html.slim +13 -0
  141. data/lib/generators/trek/templates/views/admin/users/show.html.slim +10 -21
  142. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +22 -0
  143. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +16 -0
  144. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +22 -0
  145. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +16 -0
  146. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.html.slim +17 -0
  147. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.text.erb +10 -0
  148. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.html.slim +18 -0
  149. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.text.erb +10 -0
  150. data/lib/trek/admin_ip_allowlist.rb +52 -0
  151. data/lib/trek/csp_reports.rb +99 -0
  152. data/lib/trek/data/common_passwords.txt +857 -0
  153. data/lib/trek/data/passkey_providers.json +404 -0
  154. data/lib/trek/disposable_email_domains.rb +53 -0
  155. data/lib/trek/engine.rb +12 -0
  156. data/lib/trek/passkey_providers.rb +19 -0
  157. data/lib/trek/reverse_dns.rb +27 -0
  158. data/lib/trek/version.rb +1 -1
  159. data/lib/trek.rb +13 -0
  160. data/package.json +3 -5
  161. data/renovate.json +11 -1
  162. data/trek.gemspec +3 -0
  163. data/yarn.lock +1757 -2019
  164. metadata +132 -4
  165. data/.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs +0 -541
  166. data/.yarn/releases/yarn-3.8.0.cjs +0 -875
  167. data/lib/generators/trek/templates/models/user_session.rb +0 -3
@@ -3,9 +3,11 @@
3
3
  | Variable | Purpose |
4
4
  | --- | --- |
5
5
  | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | credentials used by [`trek:admin:user`](/reference/generators/admin-user) |
6
+ | `ADMIN_ALLOWED_IPS` | the IP addresses and ranges the admin answers, e.g. `203.0.113.0/24, 2001:db8::/32`; elsewhere it's a 404 (open to all when unset, see [Proxies](/proxies#restricting-the-admin-to-ip-addresses)) |
6
7
  | `POSTMARK_API_TOKEN` | Postmark email delivery (auto-injected into credentials by the [installer](/reference/generators/install)) |
7
8
  | `APPSIGNAL_PUSH_API_KEY` | AppSignal error tracking and performance monitoring (auto-injected into credentials by the [installer](/reference/generators/install)) |
8
9
  | `APPSIGNAL_APP_ENV` | environment name reported to AppSignal (optional, defaults to `RAILS_ENV`) |
9
10
  | `DEEPL_AUTH_KEY` | automatic locale translation in the [scaffold generator](/reference/generators/scaffold) |
11
+ | `FEATURE_AUDIT_LOG` | `false` stops the [audit log](/reference/generators/audit-log) recording requests (on by default) |
10
12
  | `FEATURE_STORAGE` | `local` (development) or `s3` (production) |
11
13
  | `S3_BUCKET`, `S3_REGION`, `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BASE_URL` | S3 storage configuration for [uploads](/reference/uploaders) |
@@ -11,7 +11,7 @@ Creates an admin user with the credentials defined by the `ADMIN_EMAIL` and `ADM
11
11
  ```sh
12
12
  # .env
13
13
  ADMIN_EMAIL=admin@example.com
14
- ADMIN_PASSWORD=a-strong-password
14
+ ADMIN_PASSWORD=
15
15
  ```
16
16
 
17
17
  The created user gets the `admin` [role](/reference/models/user#roles), which makes it `privileged?` and grants it full access in the default [policies](/reference/policies).
@@ -0,0 +1,113 @@
1
+ # Audit log
2
+
3
+ ```sh
4
+ rails g trek:install:audit_log
5
+ bin/rails db:migrate
6
+ ```
7
+
8
+ Records who changed what in the admin, one row per request, and lists the rows to admins. The generator installs:
9
+
10
+ - the `AuditLog` model, including `Trek::AuditLog`, and its `audit_logs` table
11
+ - `config/initializers/audit_log.rb`, which includes [`Trek::Auditable`](#what-is-recorded) in the admin controllers, the sign-in and the password reset
12
+ - the admin list at `/admin/audit_logs` (controller, views, policy and route), open to the `admin` role only
13
+ - its specs, `spec/models/audit_log_spec.rb` and `spec/requests/admin/audit_logs_spec.rb`
14
+
15
+ To reach the list from the admin menu, add `AuditLog` to `MODELS_IN_MENU` in `app/models/admin/dashboard.rb`.
16
+
17
+ ## What is recorded
18
+
19
+ Each mutation (any request but GET and HEAD) writes a row:
20
+
21
+ | Column | Value |
22
+ | --- | --- |
23
+ | `actor` | `Current.user`, nil when nobody is signed in (a failed sign-in, a request redirected to the sign-in page) |
24
+ | `auditable` | the record the action worked on: `@object` once saved, or once destroyed. For a sign-in, the user signed in to or tried, so an account's failed sign-ins can be listed |
25
+ | `action` | `controller#action`, e.g. `admin/pages#update` |
26
+ | `http_method`, `path`, `http_status` | the request and its response status: a failed sign-in answers 422, a throttled one 429, so both count as failures |
27
+ | `params` | the request's parameter names; their values are replaced with `[FILTERED]`, except for `id` and `role` (see `audit_param_values` below), so the log doesn't copy what the forms send. `config.filter_parameters` (passwords, tokens, emails…) still applies |
28
+ | `ip_address`, `user_agent`, `request_id` | where the request came from |
29
+ | `error_class` | the class of the error the request raised, if any (`http_status` is then nil) |
30
+
31
+ Rows are append-only: a saved row raises `ActiveRecord::ReadOnlyRecord` when updated. Writing one never breaks the request: a failure is reported through `Rails.error`.
32
+
33
+ The admin list filters on `action`, `actor_type`/`actor_id` and `auditable_type`/`auditable_id` (e.g. `/admin/audit_logs?filters[auditable_type]=Page&filters[auditable_id]=12`), and lists failures only (an error status or a raised error) with `?scopes[failures]=1`. `Trek::AuditLogsHelper#audit_log_path_for(record)` links to a record's rows, for a button on its admin page.
34
+
35
+ ## Customizing
36
+
37
+ Include `Trek::Auditable` in any other controller to audit it too, e.g. the front office's `ApplicationController`. Override its hooks there:
38
+
39
+ ```ruby
40
+ class Api::BaseController < ActionController::API
41
+ include Trek::Auditable
42
+
43
+ # Record reads as well as mutations
44
+ self.audit_reads = true
45
+
46
+ # The parameters whose values are recorded, the others by name only (default: id and role)
47
+ self.audit_param_values = %w[id role status]
48
+
49
+ private
50
+
51
+ # Who made the request (default: Current.user)
52
+ def audit_actor = Current.api_client
53
+
54
+ # The record the action worked on (default: @object, once saved or destroyed)
55
+ def audit_target = @order
56
+
57
+ # The parameters to record (default: their names, with the values of audit_param_values)
58
+ def audit_params = super.except("signature")
59
+
60
+ # Values for the app's own columns, added by a migration of its own
61
+ def audit_attributes = { shop_id: Current.shop&.id }
62
+ end
63
+ ```
64
+
65
+ A read showing sensitive data can be recorded explicitly, whatever the HTTP method, with `audit!(name, target)`; the row's action is then `controller#name`:
66
+
67
+ ```ruby
68
+ def show
69
+ super
70
+ audit!(:show_medical_record, @object)
71
+ end
72
+ ```
73
+
74
+ ## Switching it off
75
+
76
+ `FEATURE_AUDIT_LOG=false` stops recording, e.g. if writing the rows slows down a busy app. It is read on each request.
77
+
78
+ ## Purging old rows
79
+
80
+ `Trek::AuditLog::PurgeJob` deletes the rows older than 6 months, the low end of the [CNIL's recommendation](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000044272396) for logs (6 months to 1 year, up to 3 years for a particularly sensitive processing). Schedule it daily with your Active Job backend, e.g. with GoodJob's cron in `config/initializers/good_job.rb`:
81
+
82
+ ```ruby
83
+ Rails.application.configure do
84
+ config.good_job.enable_cron = true
85
+ config.good_job.cron = {
86
+ purge_audit_logs: {
87
+ cron: "0 4 * * *",
88
+ class: "Trek::AuditLog::PurgeJob",
89
+ description: "Delete the audit log rows past the retention window"
90
+ }
91
+ }
92
+ end
93
+ ```
94
+
95
+ To keep the rows longer or shorter, set the retention in an initializer: `Trek::AuditLog::PurgeJob.retention = 1.year`.
96
+
97
+ ## Personal data
98
+
99
+ The rows hold personal data: the author, the IP address, the user agent, and whatever the request's parameters contain. Recording them to secure the app is allowed under the GDPR (its article 32, and recital 49 for people who aren't signed in), on the conditions the [CNIL's recommendation on logs](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000044272396) sets out:
100
+
101
+ - **Inform the people recorded**: the admin users (e.g. in their onboarding or the admin's legal notice), and the site's visitors if the front office is audited (its privacy policy): what is recorded, why, and for how long. List the processing in the app's record of processing activities.
102
+ - **Use the rows for security only**: investigating an incident or a misuse, not, for instance, evaluating the people recorded.
103
+ - **Keep them for a limited time**: 6 months to 1 year (see [Purging old rows](#purging-old-rows)).
104
+ - **Limit who reads them**: the list is open to the `admin` role only; keep that role to the few people who need it.
105
+ - **Record no more than needed**: the log keeps the parameters' names, not their values; add a parameter to `audit_param_values` only when its value matters to security (a role, a status), and never one carrying sensitive data such as health data.
106
+
107
+ ## Moving from an app's own audit log
108
+
109
+ The `audit_logs` table uses the column names of the audit logs some apps wrote themselves (`actor`, `auditable`, `action`, `http_method`, `path`, `http_status`, `params`, `ip_address`, `user_agent`, `request_id`, `error_class`, `created_at`), so existing rows carry over. In such an app, skip the migration and replace the app's own files:
110
+
111
+ 1. Run `rails g trek:install:audit_log`, delete the generated `db/migrate/*_trek_audit_logs.rb`, and let the generator overwrite the model, controller, views and policy.
112
+ 2. Delete the app's `Auditable` concern, its initializer and its purge job, and update the job's cron entry to `Trek::AuditLog::PurgeJob`.
113
+ 3. Move app-specific columns (e.g. a request duration, or a denormalized foreign key) to `audit_attributes`, and their scopes and associations to the `AuditLog` model.
@@ -21,7 +21,7 @@ The installer orchestrates dedicated sub-generators, all under the `trek:install
21
21
 
22
22
  | Area | What gets set up |
23
23
  | --- | --- |
24
- | Authentication | bcrypt and `has_secure_password` on `User`, the `UserSession` model (one per signed-in browser, found through a signed `session_id` cookie), the `Trek::Authentication` controller concern, login/logout & password reset panels |
24
+ | Authentication | bcrypt and `has_secure_password` on `User`, the `User::Session` model (one per signed-in browser, found through a signed `session_id` cookie), the `Trek::Authentication` controller concern, login/logout & password reset panels |
25
25
  | Authorization | ActionPolicy and the base policies |
26
26
  | Models | `User`, `Page`, `PagePath`, `PageVersion`, `Fragment`, `MenuNode`, `ExternalLink`, `Current` — with migrations |
27
27
  | Attachments | Shrine configuration and the uploaders |
@@ -31,12 +31,13 @@ The installer orchestrates dedicated sub-generators, all under the `trek:install
31
31
  | Pagination | Kaminari |
32
32
  | Tooling | esbuild, PostCSS, EditorConfig, Procfiles, Makefile, health check endpoint |
33
33
  | Linting | Standard, ESLint, Stylelint, Prettier, Slim Lint, Lefthook git hooks |
34
- | Security | Brakeman, bundler-audit, ruby_audit |
34
+ | Security | Brakeman, bundler-audit, ruby_audit; HTTPS only (`force_ssl`, with HSTS), a Content Security Policy and the Permissions-Policy header (`trek:install:security_headers`) — see [Security headers](/reference/generators/security-headers) |
35
35
  | Monitoring | AppSignal error tracking and performance monitoring — `config/appsignal.rb`, the push API key in the credentials, and the `AppsignalContext` concern tagging transactions per area and user |
36
36
  | Specs | RSpec, and specs for the models Trek installs (`trek:install:model_specs`): `spec/models/page_spec.rb` and `spec/models/user_spec.rb`, with their test data as [Oaken](https://github.com/kaspth/oaken) seed scripts — defaults in `db/seeds/setup/`, scenarios in `db/seeds/test/cases/` loaded with `seed "cases/…"`. They check Trek's behaviour in your app — slugs and paths, user validations and roles — so a customization that breaks it fails your suite; edit or remove the ones you deliberately change. Trek's own suite runs the same files |
37
37
  | N+1 detection | Prosopite (development/test), scanning every request — see [Controllers › N+1 detection](/reference/controllers#n-1-detection) |
38
- | CI | GitLab CI configuration |
38
+ | CI | GitLab CI configuration: specs, linters, Brakeman, bundler-audit, and, commented out, a [Software Bill of Materials](#software-bill-of-materials) |
39
39
  | Deployment | Scalingo files (`trek:install:scalingo`, run on demand) — see [Deploying to Scalingo](/deploy) |
40
+ | Audit log | The `AuditLog` model recording the admin's requests, and its admin list (`trek:install:audit_log`, run on demand) — see [Audit log](/reference/generators/audit-log) |
40
41
 
41
42
  ## ENV variables read by the installer
42
43
 
@@ -45,3 +46,11 @@ Define these **before** running the installer to have them injected automaticall
45
46
  - `POSTMARK_API_TOKEN` — injected into the Rails credentials for email delivery
46
47
  - `APPSIGNAL_PUSH_API_KEY` — injected into the Rails credentials for error tracking and performance monitoring
47
48
  - `DEEPL_AUTH_KEY` — enables automatic locale translation in the [scaffold generator](/reference/generators/scaffold)
49
+
50
+ ## Software Bill of Materials
51
+
52
+ The generated CI has an `sbom` job, commented out: uncomment it when a client asks for an SBOM, as clients subject to NIS2 may. It lists the gems and npm packages of `Gemfile.lock` and `yarn.lock`, with their versions and licenses, in a [CycloneDX](https://cyclonedx.org) file, `sbom.cdx.json`, running [cdxgen](https://github.com/CycloneDX/cdxgen) on every pipeline. GitLab keeps each branch's latest artifacts by default, so the production branch's SBOM can be downloaded from its last pipeline (**Build › Artifacts**).
53
+
54
+ Looking up the licenses on RubyGems and npm takes a couple of minutes; set `FETCH_LICENSE=false` in the job's command to skip it. The command runs with an empty environment (`env -i`), so cdxgen and the packages `npx` installs for it can't read the CI's tokens and secret variables.
55
+
56
+ Trek's own CI makes one for each released version, on its tag, and keeps it for good.
@@ -0,0 +1,109 @@
1
+ # Security headers
2
+
3
+ ```bash
4
+ rails g trek:install:security_headers
5
+ ```
6
+
7
+ `rails g trek:install` runs it. It sets up the headers that tell browsers what a page may do, so that a script injected into a page (an XSS) can do as little as possible:
8
+
9
+ | Header | Set by | Default |
10
+ | --- | --- | --- |
11
+ | `Content-Security-Policy-Report-Only`, then `Content-Security-Policy` | `config/initializers/content_security_policy.rb` | see [below](#content-security-policy) |
12
+ | `Permissions-Policy` | `config/initializers/permissions_policy.rb` | camera, microphone, geolocation, payment, USB… off |
13
+ | `Strict-Transport-Security` | `config.force_ssl` in production, which `trek:install:config` turns on | 2 years, subdomains included |
14
+ | `Referrer-Policy` | Rails | `strict-origin-when-cross-origin`: other sites only see the origin |
15
+ | `X-Content-Type-Options`, `X-Frame-Options` | Rails | `nosniff`, `SAMEORIGIN` |
16
+
17
+ The headers apply to every response of the app: the admin and the front office alike.
18
+
19
+ An app with its own `content_security_policy.rb` keeps it: the generator only replaces Rails' commented-out example, and says so otherwise. Compare yours with Trek's below.
20
+
21
+ ## Content Security Policy
22
+
23
+ | Directive | Sources | Why |
24
+ | --- | --- | --- |
25
+ | `default-src` | `'self'` | anything else comes from the app |
26
+ | `script-src` | `'self'`, the nonce | no inline script, unless written with the nonce (`javascript_tag nonce: true`) |
27
+ | `style-src` | `'self'`, `'unsafe-inline'` | Trek's components and Backpack's set `style` attributes (colors, custom properties) |
28
+ | `font-src` | `'self'`, `data:` | the admin's font, Inter, comes with Trek's npm package |
29
+ | `img-src`, `media-src` | `'self'`, `data:`, `blob:`, the upload hosts | uploads come from `S3_BASE_URL` once stored, and from `S3_ENDPOINT` just after upload; `blob:` and `data:` for previews |
30
+ | `connect-src` | `'self'` | uploads go through the app's upload endpoint |
31
+ | `report-uri` | `/csp-reports` | see [Violation reports](#violation-reports) |
32
+ | `object-src` | `'none'` | no plugin |
33
+ | `frame-ancestors` | `'self'` | framed by its own pages only (Lookbook), against clickjacking |
34
+ | `base-uri`, `form-action` | `'self'` | |
35
+
36
+ The nonce is the session's, so Turbo, which renders the next pages under the first one's policy, gives their inline scripts a nonce that policy knows. A visitor without a session gets a random one per page: prefer script files to inline scripts on pages Turbo visits before any session.
37
+
38
+ ### Report first, then enforce
39
+
40
+ The policy only **reports** violations at first (`config.content_security_policy_report_only = true`): a missed source would otherwise break pages. Browse the admin and the front office with the browser's console open, and watch the violations visitors' browsers report (below). Once none is left but noise, set `content_security_policy_report_only` to `false`.
41
+
42
+ ### Violation reports
43
+
44
+ Browsers send each violation to the policy's `report-uri`: `/csp-reports`, which `Trek::CspReportsController` answers (the generator adds `post "csp-reports"` to the routes). It reports each violation to **AppSignal**, when the app uses it, in a `csp` namespace: one incident per directive and blocked origin (e.g. `script-src-elem https://cdn.example.net`), tagged with the page and whether the policy was enforced, so a new violation is notified once and closed once fixed. It logs each one too (`[CSP] script-src-elem blocked https://cdn.example.net on /pages/team (report)`), which is all an app without AppSignal gets.
45
+
46
+ It keeps the reports about the app's own pages only, and drops what browser extensions load or inject, which makes most reports and says nothing about the app. It keeps the page's path, not its query, which may carry a token, and the blocked resource's origin, not its URL. Browsers send no CSRF token nor cookie: the endpoint needs neither, and answers `204`.
47
+
48
+ Anyone can send reports, so a flood mustn't bury the real violations:
49
+
50
+ - a report must be plausible: a directive CSP has, and a keyword (`inline`, `eval`, `data`…) or a host name blocked;
51
+ - AppSignal gets each violation once an hour at most, and 20 different ones an hour at most: the others are only logged;
52
+ - 60 reports a minute per IP address at most, and 600 for all of them together.
53
+
54
+ These counters are kept in `Rails.cache`: with a cache per process or per server (`:memory_store`, `:file_store`), each counts on its own, so the limits multiply. Use a shared store, such as Solid Cache or Redis, when the app runs more than one.
55
+
56
+ To send the reports elsewhere, e.g. to a service such as [Report URI](https://report-uri.com), set `CSP_REPORT_URI`. Mind that reports hold visitors' page URLs and IP addresses: under the GDPR, such a service processes personal data for the app.
57
+
58
+ ### Adding a source
59
+
60
+ Each third party the front office uses needs its hosts, in the directives it uses them for:
61
+
62
+ ```ruby
63
+ # Analytics
64
+ policy.script_src :self, "https://plausible.io"
65
+ policy.connect_src :self, "https://plausible.io"
66
+
67
+ # Embedded videos
68
+ policy.frame_src "https://www.youtube-nocookie.com"
69
+
70
+ # Google Fonts (commented out in the generated policy)
71
+ policy.style_src :self, :unsafe_inline, "https://fonts.googleapis.com"
72
+ policy.font_src :self, :data, "https://fonts.gstatic.com"
73
+ ```
74
+
75
+ A directive written twice replaces the first: keep its sources in one call.
76
+
77
+ Fonts are better served by the app, as Trek does for the admin's with a [Fontsource](https://fontsource.org) package: the policy needs no extra host, and visitors' IP addresses don't go to Google, which the GDPR would require a reason for.
78
+
79
+ ### A policy for one part of the app
80
+
81
+ A controller can change the policy for its own pages, e.g. to allow videos on the front office's pages only, and its subclasses inherit the change:
82
+
83
+ ```ruby
84
+ class PagesController < ApplicationController
85
+ content_security_policy do |policy|
86
+ policy.frame_src "https://www.youtube-nocookie.com"
87
+ end
88
+ end
89
+ ```
90
+
91
+ The same way, `Admin::BaseController` can tighten the admin's policy, and `content_security_policy_report_only` can keep one part reporting while the rest is enforced.
92
+
93
+ ## Permissions-Policy
94
+
95
+ Turns off the browser features the app doesn't use: accelerometer, camera, screen capture, geolocation, gyroscope, HID, magnetometer, microphone, MIDI, payment, serial, USB. Passkeys' WebAuthn stays on. To allow one for the app's own pages, e.g. a map locating the visitor, replace `geolocation=()` with `geolocation=(self)`.
96
+
97
+ Rails' own `config.permissions_policy` still sends the former `Feature-Policy` header, which browsers are dropping: the initializer sets `Permissions-Policy` itself.
98
+
99
+ ## HSTS
100
+
101
+ `force_ssl` makes browsers use HTTPS only, for 2 years, on subdomains too (`includeSubDomains`). To be preloaded in browsers, so that even the first visit uses HTTPS, add the `preload` flag in `config/environments/production.rb`, then submit the domain on [hstspreload.org](https://hstspreload.org). Every subdomain must serve HTTPS first: preloading is hard to undo.
102
+
103
+ ```ruby
104
+ config.ssl_options = { hsts: { subdomains: true, preload: true } }
105
+ ```
106
+
107
+ ## Checking
108
+
109
+ Once deployed, [Mozilla's HTTP Observatory](https://developer.mozilla.org/en-US/observatory) grades the headers. The report-only policy doesn't count: enforce it to reach an A.
@@ -8,6 +8,8 @@ A comprehensive list of features and settings included in Trek.
8
8
  - [Admin user](/reference/generators/admin-user) — `rails g trek:admin:user`
9
9
  - [Scaffold](/reference/generators/scaffold) — `rails g trek:scaffold`
10
10
  - [Taxonomies](/reference/generators/taxonomies) — `rails g trek:taxonomies`
11
+ - [Audit log](/reference/generators/audit-log) — `rails g trek:install:audit_log`
12
+ - [Security headers](/reference/generators/security-headers) — `rails g trek:install:security_headers`
11
13
 
12
14
  ## Models
13
15
 
@@ -36,6 +38,7 @@ Trek's behaviors, packaged as concerns you can mix into any model:
36
38
  - [Formatters](/reference/formatters) — typography & ProseMirror-to-HTML pipeline
37
39
  - [Policies](/reference/policies) — ActionPolicy integration
38
40
  - [Uploaders](/reference/uploaders) — Shrine-based attachments
41
+ - [Validators](/reference/validators) — disposable email addresses, email format, phone numbers…, for any model
39
42
 
40
43
  ## Front-end
41
44
 
@@ -6,7 +6,7 @@ The taxonomy models, installed by the [taxonomies generator](/reference/generato
6
6
 
7
7
  | Column | Type | Notes |
8
8
  | --- | --- | --- |
9
- | `key` | string | required, unique within its category |
9
+ | `key` | string | required, unique within its category, or among the tags without one |
10
10
  | `name` | string | required display name |
11
11
  | `category_id` | bigint | optional [TagCategory](#tagcategory) |
12
12
  | `position` | integer | ordering within the category |
@@ -15,6 +15,8 @@ The taxonomy models, installed by the [taxonomies generator](/reference/generato
15
15
  - `belongs_to :category`, `has_many :taggings`, and `taggables`, the tagged records of any model
16
16
  - Scopes: `ordered`, `in_category(key)`, `in_categories(keys)`
17
17
 
18
+ Two categories may share a key: a Colors category and a Fruits category can both have an `orange` tag. The model validates the rule, and the database holds it too, with a unique index on `category_id` and `key`, plus one on `key` for the tags without a category (in a unique index, NULLs never collide).
19
+
18
20
  ## TagCategory
19
21
 
20
22
  | Column | Type | Notes |
@@ -24,7 +26,7 @@ The taxonomy models, installed by the [taxonomies generator](/reference/generato
24
26
  | `position` | integer | ordering |
25
27
  | `tags_count` | integer | counter cache |
26
28
 
27
- - `has_many :tags` (ordered)
29
+ - `has_many :tags` (ordered). A category can't be deleted while it has tags: left without a category, their keys could collide with others'. Move or delete them first; the admin says so.
28
30
  - Scopes: `ordered`, `with_tags`
29
31
 
30
32
  ## Tagging
@@ -12,8 +12,81 @@ The host application's `User` model, into which Trek injects authentication (`ha
12
12
  | `locale` | string | admin UI language |
13
13
  | `password_digest` | string | `has_secure_password` (bcrypt), required |
14
14
  | `last_activity_at` | datetime | updated after each signed-in admin request |
15
+ | `password_changed_at` | datetime | when the password was last set, for its expiry |
16
+ | `password_renewal_required_at` | datetime | when an admin asked everyone for a new password (`password_renewal_required?`) |
17
+ | `failed_sign_in_count` | integer | failed sign-ins in a row, for the [lockout](#failed-sign-ins) |
18
+ | `sign_in_locked_until` | datetime | until when signing in is locked (`sign_in_locked?`) |
19
+ | `webauthn_id` | string | the random id the user's [passkeys](#passkeys) were created for |
15
20
 
16
- When the app has no `User` model yet, Trek creates the `users` table with its `email` column. Signed-in browsers are `UserSession` records (`user_agent`, `ip_address`) in a `user_sessions` table, found through a signed `session_id` cookie. Password reset (15 minutes) and invitation (1 week) links carry tokens from `generates_token_for`, so no column stores them.
21
+ When the app has no `User` model yet, Trek creates the `users` table with its `email` column. Signed-in browsers are `User::Session` records (`user.sessions`; `user_agent`, `ip_address`) in a `user_sessions` table, found through a signed `session_id` cookie. Password reset (15 minutes) and invitation (1 week) links carry tokens from `generates_token_for`, so no column stores them.
22
+
23
+ ## Sign-in and password resets
24
+
25
+ Neither tells whether an email has an account, as the [ANSSI recommends](https://messervices.cyber.gouv.fr/guides/recommandations-relatives-lauthentification-multifacteur-et-aux-mots-de-passe) (R14):
26
+
27
+ - **Sign-in:** an unknown email, a wrong password, a user who isn't privileged and a [locked](#failed-sign-ins) one get the same message and status. `authenticate_by` hashes the password typed even when no user matches, so the response takes as long.
28
+ - **Password reset request:** every email gets the same answer (« a link to choose a new password has been sent to you (unless no account matches this email) »). The request always queues `Admin::PasswordResetJob`, which looks the user up and emails privileged ones only, so the response takes as long either way.
29
+
30
+ ## Failed sign-ins
31
+
32
+ Password guessing is slowed down at three levels, as the [ANSSI recommends](https://messervices.cyber.gouv.fr/guides/recommandations-relatives-lauthentification-multifacteur-et-aux-mots-de-passe) (R10):
33
+
34
+ | Limit | Default | Counted in |
35
+ | --- | --- | --- |
36
+ | Sign-ins from an IP address (`rate_limit` in `Admin::UserSessionsController`) | 5 within 3 minutes | `Rails.cache` |
37
+ | Failed sign-ins from an IP address, whatever the account (`FAILED_SIGN_INS_PER_IP`, `FAILED_SIGN_INS_WINDOW`) | 20 within an hour | `Rails.cache` |
38
+ | Failed sign-ins in a row on an account, whatever the IP address (`Trek::Users::SignInLockout`) | 5, then locked | the `users` table |
39
+
40
+ Past an IP address's limits, the sign-in form answers « Too many attempts » (429) to any sign-in from it.
41
+
42
+ An account's lock lasts `sign_in_lockout_duration` (1 minute), doubled on each further failure up to `sign_in_lockout_max` (1 hour); never for good, so knowing someone's email isn't enough to keep them out. While locked, signing in fails even with the right password, with the same answer as a wrong one, and the attempts don't count. A successful sign-in or a new password (e.g. through a reset) unlocks it. When it first locks, the user gets an email (`AdminMailer#sign_in_locked`) offering a password reset, and warning that someone may be guessing their password.
43
+
44
+ Set them in the model, e.g. `self.sign_in_lockout_after = 10`, or `self.sign_in_lockout_email = false` to send no email.
45
+
46
+ Each sign-in, failed or throttled, is a row of the [audit log](/reference/generators/audit-log) when it is installed, on the account tried.
47
+
48
+ ::: warning Rails.cache
49
+ The IP address limits count in `Rails.cache`. With a cache per process or per server (`:memory_store`, or `:file_store`, Rails' fallback when no store is set), each one counts on its own, so the limits multiply by the number of processes and servers. Use a shared cache store, such as Solid Cache (the default of Rails 8 apps) or Redis, when the app runs more than one. Under `:null_store`, set in a new app's `test.rb`, they never kick in.
50
+ :::
51
+
52
+ ## Sessions
53
+
54
+ Each signed-in browser is a `User::Session`, which `Trek::Users::SessionLifetime` ends, as the [ANSSI recommends](https://messervices.cyber.gouv.fr/guides/recommandations-relatives-lauthentification-multifacteur-et-aux-mots-de-passe) (R12):
55
+
56
+ | Setting | Default | |
57
+ | --- | --- | --- |
58
+ | `idle_timeout` | 48 hours | without a request |
59
+ | `lifetime` | 30 days | since signing in, whatever the activity; also the cookie's expiry |
60
+ | `activity_interval` | 5 minutes | the least time between two writes of the session's `last_activity_at` and the user's `last_activity_at`, so browsing doesn't write on every page |
61
+
62
+ Set them in `app/models/user/session.rb`, e.g. `self.idle_timeout = 2.hours`. An expired session is dropped on its next request, and the user signs in again. `Trek::PurgeExpiredSessionsJob` deletes the expired ones: schedule it daily, e.g. with GoodJob's cron (`class: "Trek::PurgeExpiredSessionsJob"`).
63
+
64
+ Users see their **active sessions** in a tab of their own profile: browser and system, IP address, last activity and sign-in date. They can sign out any other session, or all of them at once (**Sign out everywhere else**). A password reset signs the user out everywhere.
65
+
66
+ ## Sign-ins
67
+
68
+ `Trek::Users::SignIns` helps users spot a sign-in that wasn't theirs:
69
+
70
+ - the **Active sessions** tab of their profile shows their **previous sign-in**: its date, browser and IP address. It's kept on the user (`last_sign_in_at`, `last_sign_in_ip`, `last_sign_in_user_agent`, with their `current_sign_in_*` counterparts), as sessions are deleted when they end;
71
+ - a sign-in from a **browser that never signed in as the user** emails them (`AdminMailer#new_sign_in`): date, browser, IP address, with links to change their password and to their active sessions. A browser is recognized by a signed cookie, kept for years, listing the users who signed in from it. Their very first sign-in sends nothing.
72
+
73
+ Set `self.new_sign_in_email = false` in the `User` model to turn the email off.
74
+
75
+ ## Passkeys
76
+
77
+ `Trek::Users::Passkeys` lets users sign in with a passkey instead of their password: their fingerprint, face or device PIN unlocks a key their device or password manager keeps, and Trek keeps its public half. A passkey can't be phished or reused on another site, and the device verifies the user, so it counts as two factors on its own.
78
+
79
+ - **Adding:** the **Passkeys** tab of their profile adds one (**Add a passkey**): their device creates it, then Trek saves it. The list names each passkey after the password manager keeping it (iCloud Keychain, Google Password Manager, 1Password, a YubiKey…), or else after the browser it was added from, with when it was added and last used; they can delete it there.
80
+ - **Signing in:** the sign-in form offers **Sign in with a passkey**, and the browser also suggests the site's passkeys in the email field's autofill. No email to type: the passkey says whose it is. It works even while [failed passwords](#failed-sign-ins) lock signing in, and lifts that lock, as a passkey can't be guessed.
81
+ - **Password:** it keeps working. Asking for a passkey after the password, as a second factor, will come with TOTP.
82
+
83
+ Passkeys are `User::Passkey` records (`user.passkeys`, `user_passkeys` table): the credential's id and public key, its signature counter, transports, AAGUID (the id of the authenticator's model, which names its password manager through `Trek::PasskeyProviders`), and `last_used_at`. The user's `webauthn_id`, random, is the id their passkeys were created for. Trek verifies them with the [webauthn](https://github.com/cedarcode/webauthn-ruby) gem, without checking the authenticator's make (no attestation).
84
+
85
+ A passkey belongs to a domain: Trek uses the request's (`request.host`, with `request.base_url` as the allowed origin), so passkeys work in each environment, with their own. A passkey added on `www.example.com` doesn't work on `example.com`, and behind a proxy, Rails must see the original scheme and host (see [Proxies](/proxies#passkeys)). To share passkeys between subdomains, override `passkey_relying_party` in `Admin::PasskeysController` and `Admin::PasskeySessionsController`, with `id: "example.com"` and every allowed origin.
86
+
87
+ The password managers' names come from the community list [passkey-authenticator-aaguids](https://github.com/passkeydeveloper/passkey-authenticator-aaguids), names only, embedded in Trek and refreshed with `bin/update-passkey-providers` before a release. An authenticator may send no AAGUID, or one the list doesn't know: the passkey then shows no password manager.
88
+
89
+ Browsers without WebAuthn's JSON methods (Safari before 18.4) don't show the passkey buttons.
17
90
 
18
91
  ## Roles
19
92
 
@@ -53,7 +126,54 @@ User.create(email: "new@example.com", role: :editor, send_invite: true)
53
126
  `Trek::Users::Validations` enforces:
54
127
 
55
128
  - `email` — stored stripped and in lower case (lookups too, so sign-in and password resets ignore case), present, unique whatever its case, and well-formed by [`email_validator`](https://github.com/K-and-R/email_validator)'s strict mode (no consecutive or leading/trailing dots, a domain with a TLD)
56
- - `password` — when one is assigned: at least 8 characters, and not spaces only. A blank password is ignored by `has_secure_password` (the current one is kept); the password reset form refuses it
129
+ - `password` — when one is assigned: follows the [password policy](#password-policy), and is not spaces only. A blank password is ignored by `has_secure_password` (the current one is kept); the password reset form refuses it, and not one of the user's last 5 passwords (see [Previous passwords](#previous-passwords))
130
+
131
+ ### Disposable email addresses
132
+
133
+ Admins create the accounts, so `User` accepts any well-formed address. When users can sign up themselves, refuse throwaway addresses with [`NotDisposableValidator`](/reference/validators#disposable-email-addresses), which any other model can use too:
134
+
135
+ ```ruby
136
+ validates :email, not_disposable: true
137
+ ```
138
+
139
+ ## Password policy
140
+
141
+ `Trek::Users::PasswordPolicy`, which `Trek::Users::Validations` includes, follows the [ANSSI's recommendations on passwords](https://messervices.cyber.gouv.fr/guides/recommandations-relatives-lauthentification-multifacteur-et-aux-mots-de-passe) (v2.0, 2021, co-signed by the CNIL). An admin is privileged access, so Trek targets their **"medium to high"** sensitivity level (about 85 bits of entropy):
142
+
143
+ | Rule | ANSSI | Default | Setting |
144
+ | --- | --- | --- | --- |
145
+ | Minimum length | R21 | 12 characters | `password_min_length` |
146
+ | A digit and a symbol, except for a passphrase | R23 | below 20 characters | `passphrase_length` |
147
+ | Not a common password, whatever its case, accents and punctuation | R27 | those longer than 8 characters among the 10,000 most common passwords and the 5,000 most common French ones ([SecLists](https://github.com/danielmiessler/SecLists)) | — |
148
+ | No sequence: 5 characters along a keyboard row (AZERTY, QWERTY), the alphabet or the digits, or the same character 5 times | R27 | — | — |
149
+ | Not the user's name or email | R27 | parts of 4 characters or more | — |
150
+ | Not in a known data breach ([Have I Been Pwned](https://haveibeenpwned.com/API/v3#PwnedPasswords)) | R27 | off | `password_check_pwned` |
151
+ | Maximum length | R22 | 72 bytes | — |
152
+
153
+ Set them in the model, e.g. `self.password_min_length = 15` for the "high" level. The maximum is bcrypt's, which ignores anything beyond: the ANSSI asks for a few hundred characters, which hashing with Argon2 will allow. The Have I Been Pwned check only sends the first 5 characters of the password's SHA-1, but stays off by default, as the ANSSI warns against sending password data to an outside service; when the service can't be reached, the password is accepted.
154
+
155
+ The new password form states the rules and rates the password as it is typed (`Trek::Form::PasswordStrengthComponent`); the model has the last word.
156
+
157
+ ### Expiry and renewal
158
+
159
+ `Trek::Users::PasswordRenewal` sends a user to the new password form, signed out, when:
160
+
161
+ - their password has expired: never by default, as the ANSSI advises for ordinary accounts (R24). For admin accounts signing in with a password only, it suggests 1 to 3 years (R25): `self.password_expires_after = 1.year`;
162
+ - an admin asked everyone for a new password, from the users list (**Ask everyone for a new password**), after a suspected compromise (R26). In code: `User.require_password_renewal!`.
163
+
164
+ ## Previous passwords
165
+
166
+ `Trek::Users::PasswordHistory` refuses a password among the user's last 5, the current one included, as the [ANSSI recommends](https://messervices.cyber.gouv.fr/guides/recommandations-relatives-lauthentification-multifacteur-et-aux-mots-de-passe) (R27). The previous digests are kept as `User::PreviousPassword` records (`user_previous_passwords` table): only as many as needed, deleted with the user.
167
+
168
+ Set the number in the model, after `include Trek::Users::PasswordHistory`; 0 turns the check off:
169
+
170
+ ```ruby
171
+ class User < ApplicationRecord
172
+ has_secure_password
173
+ include Trek::Users::PasswordHistory
174
+ self.password_history_size = 10
175
+ end
176
+ ```
57
177
 
58
178
  ## Scopes
59
179
 
@@ -63,5 +183,5 @@ User.create(email: "new@example.com", role: :editor, send_invite: true)
63
183
 
64
184
  Two non-persisted `ActiveModel::Model` companions handle the auth flows:
65
185
 
66
- - **`Trek::UserSession`** — `email` + `password`, used by the login form
186
+ - **`Trek::UserSession`** — `email` + `password` typed in a sign-in form. Deprecated, removed in Trek 3: the generated sign-in controller passes the params to `User.authenticate_by` instead
67
187
  - **`Trek::UserPasswordReset`** — `email`, looks up the `user` to send a reset link to
@@ -0,0 +1,94 @@
1
+ # Validators
2
+
3
+ Validators available to any Active Record or Active Model class of the app (`User`, a sign-up, a contact or newsletter form…): Trek's own, and those of the gems Trek depends on, which it loads.
4
+
5
+ | Validator | Key | From |
6
+ | --- | --- | --- |
7
+ | [Disposable email addresses](#disposable-email-addresses) | `not_disposable` | Trek |
8
+ | [Email format](#email-format) | `email` | [email_validator](https://github.com/K-and-R/email_validator) |
9
+ | [Email checks](#email-checks) | `"valid_email_2/email"` | [valid_email2](https://github.com/micke/valid_email2) |
10
+ | [Phone numbers](#phone-numbers) | `phone` | [Phonelib](https://github.com/daddyz/phonelib) |
11
+ | [Uniqueness of a translated attribute](#uniqueness-of-a-translated-attribute) | `uniqueness` | [Mobility](https://github.com/shioyama/mobility) |
12
+
13
+ ## Disposable email addresses
14
+
15
+ `NotDisposableValidator` refuses an email address from a throwaway email provider (mailinator.com, yopmail.com…), or a subdomain of one, whatever its case:
16
+
17
+ ```ruby
18
+ class Contact < ApplicationRecord
19
+ validates :email, presence: true, email: { mode: :strict }, not_disposable: true
20
+ end
21
+ ```
22
+
23
+ It is opt-in: Trek doesn't add it to `User`, whose accounts admins create. A blank address is left to the presence validation.
24
+
25
+ The error is `:disposable`, « is a disposable email address, use a permanent one » (« est une adresse e-mail jetable, utilisez-en une permanente »). Change it in the app's locales (`errors.messages.disposable`), or per model:
26
+
27
+ ```ruby
28
+ validates :email, not_disposable: { message: "must be your work address" }
29
+ ```
30
+
31
+ The domains come from the [valid_email2](https://github.com/micke/valid_email2) gem, whose maintainers keep the list up to date: updating the gem updates the list, with no data to refresh in the app. Only the list is checked, without looking up the domain's mail servers. To let a domain through, list it in `config/allow_listed_email_domains.yml`:
32
+
33
+ ```yaml
34
+ - legit-provider.example
35
+ ```
36
+
37
+ The list (about 180,000 domains) is loaded on the first check, as a single string of 2.6 MB searched by bisection, rather than valid_email2's own Set of about 35 MB: about 5 µs per address.
38
+
39
+ ## Email format
40
+
41
+ `EmailValidator` checks an address's shape, without any network lookup:
42
+
43
+ ```ruby
44
+ validates :email, email: { mode: :strict }
45
+ ```
46
+
47
+ | Option | Default | |
48
+ | --- | --- | --- |
49
+ | `mode` | `:loose` | `:loose` only asks for something on each side of an `@`; `:strict` refuses consecutive or leading/trailing dots and asks for a domain with a TLD; `:rfc` follows RFC 5322 |
50
+ | `require_fqdn` | — (true in `:strict` mode) | a domain with a TLD |
51
+ | `domain` | — | only this domain, e.g. `domain: "example.com"` |
52
+ | `allow_nil` | `false` | |
53
+
54
+ `User` uses `:strict` mode.
55
+
56
+ ## Email checks
57
+
58
+ valid_email2's validator checks an address's shape too, and optionally more: its domain's mail servers (`mx: true`, `strict_mx: true`), a deny list (`deny_list: true`, from `config/deny_listed_email_domains.yml`), or refusing `+tag` addresses (`disallow_subaddressing: true`). See [its options](https://github.com/micke/valid_email2#usage).
59
+
60
+ ```ruby
61
+ validates :email, "valid_email_2/email": { mx: true }
62
+ ```
63
+
64
+ - `mx` and `strict_mx` look the domain up in the DNS on every validation, which can take up to `dns_timeout` (5 seconds) and fails offline, in specs too.
65
+ - For disposable addresses, use [`not_disposable`](#disposable-email-addresses) rather than its `disposable` options: they load its Set of 180,000 domains, which grows each process by about 35 MB.
66
+
67
+ ## Phone numbers
68
+
69
+ `PhoneValidator` checks a phone number with Phonelib, against `Phonelib.default_country` when the number has no country code:
70
+
71
+ ```ruby
72
+ validates :phone, phone: { possible: true, allow_blank: true }
73
+ ```
74
+
75
+ | Option | |
76
+ | --- | --- |
77
+ | `possible` | `true` to only ask whether the digits could form a number for the country; otherwise they must fall in a range actually assigned (see [Strictness](/reference/concerns/phonable#strictness)) |
78
+ | `types` | e.g. `:mobile`, `[:mobile, :fixed]` |
79
+ | `countries` | e.g. `[:fr, :be]` |
80
+ | `allow_blank` | a blank value fails otherwise |
81
+
82
+ [`Trek::Phonable`](/reference/concerns/phonable) adds it for you, with the number's normalization and formatting.
83
+
84
+ ## Uniqueness of a translated attribute
85
+
86
+ On a model with [translated attributes](/reference/concerns/translatable), Mobility replaces `uniqueness`, so it works on them too, in the current locale: a title taken in English is still free in French.
87
+
88
+ ```ruby
89
+ class Fragment < ApplicationRecord
90
+ include Trek::Translatable
91
+ translate_attributes :title, :content
92
+ validates :title, uniqueness: true
93
+ end
94
+ ```