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
@@ -12,25 +12,25 @@ module Trek
12
12
  source_root File.expand_path("templates", __dir__)
13
13
 
14
14
  def install_dotfiles_and_tooling
15
- generate "trek:install:ruby"
16
- generate "trek:install:package_json"
17
- generate "trek:install:yarn"
18
-
19
- generate "trek:install:brakeman"
20
- generate "trek:install:bundler_audit"
21
- generate "trek:install:editorconfig"
22
- generate "trek:install:esbuild"
23
- generate "trek:install:eslint"
24
- generate "trek:install:lefthook"
25
- generate "trek:install:postcss"
26
- generate "trek:install:prettier"
27
- generate "trek:install:procfiles"
28
- generate "trek:install:prosopite"
29
- generate "trek:install:rubocop"
30
- generate "trek:install:slim_lint"
31
- generate "trek:install:stylelint"
32
- generate "trek:install:kaminari"
33
- generate "trek:install:lookbook"
15
+ generate_inline "trek:install:ruby"
16
+ generate_inline "trek:install:package_json"
17
+ generate_inline "trek:install:yarn"
18
+
19
+ generate_inline "trek:install:brakeman"
20
+ generate_inline "trek:install:bundler_audit"
21
+ generate_inline "trek:install:editorconfig"
22
+ generate_inline "trek:install:esbuild"
23
+ generate_inline "trek:install:eslint"
24
+ generate_inline "trek:install:lefthook"
25
+ generate_inline "trek:install:postcss"
26
+ generate_inline "trek:install:prettier"
27
+ generate_inline "trek:install:procfiles"
28
+ generate_inline "trek:install:prosopite"
29
+ generate_inline "trek:install:rubocop"
30
+ generate_inline "trek:install:slim_lint"
31
+ generate_inline "trek:install:stylelint"
32
+ generate_inline "trek:install:kaminari"
33
+ generate_inline "trek:install:lookbook"
34
34
 
35
35
  run "yarn install", abort_on_failure: true
36
36
  end
@@ -60,100 +60,101 @@ module Trek
60
60
  end
61
61
 
62
62
  def setup_config
63
- generate "trek:install:config"
63
+ generate_inline "trek:install:config"
64
+ generate_inline "trek:install:security_headers"
64
65
  end
65
66
 
66
67
  def install_specs
67
- generate "trek:install:specs"
68
+ generate_inline "trek:install:specs"
68
69
  end
69
70
 
70
71
  def install_strip_attributes
71
- generate "trek:install:strip_attributes"
72
+ generate_inline "trek:install:strip_attributes"
72
73
  end
73
74
 
74
75
  def install_emails
75
- generate "trek:install:postmark"
76
- generate "trek:install:mailer"
76
+ generate_inline "trek:install:postmark"
77
+ generate_inline "trek:install:mailer"
77
78
  end
78
79
 
79
80
  def install_monitoring
80
- generate "trek:install:appsignal"
81
+ generate_inline "trek:install:appsignal"
81
82
  end
82
83
 
83
84
  def install_user
84
- generate "trek:install:user"
85
+ generate_inline "trek:install:user"
85
86
  end
86
87
 
87
88
  def install_authentication
88
- generate "trek:install:authentication"
89
+ generate_inline "trek:install:authentication"
89
90
  end
90
91
 
91
92
  def install_authorization
92
- generate "trek:install:authorization"
93
+ generate_inline "trek:install:authorization"
93
94
  end
94
95
 
95
96
  def install_healthcheck
96
- generate "trek:install:healthcheck"
97
+ generate_inline "trek:install:healthcheck"
97
98
  end
98
99
 
99
100
  def install_translations
100
- generate "trek:install:translations"
101
+ generate_inline "trek:install:translations"
101
102
  end
102
103
 
103
104
  def add_locales_generator
104
- generate "trek:install:locales"
105
+ generate_inline "trek:install:locales"
105
106
  end
106
107
 
107
108
  def install_attachments
108
- generate "trek:install:attachments"
109
- generate "trek:install:uploaders"
109
+ generate_inline "trek:install:attachments"
110
+ generate_inline "trek:install:uploaders"
110
111
  end
111
112
 
112
113
  def install_models
113
- generate "trek:install:page"
114
- generate "trek:install:current"
115
- generate "trek:install:fragment"
116
- generate "trek:install:menu_node"
117
- generate "trek:install:external_link"
118
- generate "trek:install:model_specs"
114
+ generate_inline "trek:install:page"
115
+ generate_inline "trek:install:current"
116
+ generate_inline "trek:install:fragment"
117
+ generate_inline "trek:install:menu_node"
118
+ generate_inline "trek:install:external_link"
119
+ generate_inline "trek:install:model_specs"
119
120
  end
120
121
 
121
122
  def install_ci
122
- generate "trek:install:ci"
123
+ generate_inline "trek:install:ci"
123
124
  end
124
125
 
125
126
  def install_makefile
126
- generate "trek:install:makefile"
127
+ generate_inline "trek:install:makefile"
127
128
  end
128
129
 
129
130
  def install_readme
130
- generate "trek:install:readme"
131
+ generate_inline "trek:install:readme"
131
132
  end
132
133
 
133
134
  def install_agents
134
- generate "trek:install:agents"
135
+ generate_inline "trek:install:agents"
135
136
  end
136
137
 
137
138
  def install_admin
138
- generate "trek:install:admin"
139
- generate "trek:install:admin:routes"
140
- generate "trek:install:admin:fragments"
141
- generate "trek:install:admin:pages"
142
- generate "trek:install:admin:user_password_resets"
143
- generate "trek:install:admin:user_sessions"
144
- generate "trek:install:admin:users"
139
+ generate_inline "trek:install:admin"
140
+ generate_inline "trek:install:admin:routes"
141
+ generate_inline "trek:install:admin:fragments"
142
+ generate_inline "trek:install:admin:pages"
143
+ generate_inline "trek:install:admin:user_password_resets"
144
+ generate_inline "trek:install:admin:user_sessions"
145
+ generate_inline "trek:install:admin:users"
145
146
  end
146
147
 
147
148
  def install_assets
148
- generate "trek:install:assets"
149
+ generate_inline "trek:install:assets"
149
150
  end
150
151
 
151
152
  def install_component_index
152
- generate "trek:install:component"
153
+ generate_inline "trek:install:component"
153
154
  end
154
155
 
155
156
  def install_application_files
156
- generate "trek:install:application"
157
+ generate_inline "trek:install:application"
157
158
  end
158
159
 
159
160
  def run_migrations
@@ -173,7 +174,7 @@ module Trek
173
174
  say ""
174
175
  say "Create an admin user with:"
175
176
  say ""
176
- say " rails g trek:admin:user --email admin@example.com --password azerty123"
177
+ say " rails g trek:admin:user --email admin@example.com --password ************"
177
178
  say ""
178
179
  say "Then start the server:"
179
180
  say ""
@@ -183,6 +184,13 @@ module Trek
183
184
 
184
185
  private
185
186
 
187
+ # Runs a generator in this process rather than in a new `rails generate`, which boots the app
188
+ # again: for Trek's 50-odd generators, that took about two minutes. Other gems' generators keep
189
+ # their own process (`generate`), as they may need a gem this run just added to the Gemfile.
190
+ def generate_inline(name)
191
+ generate name, inline: true
192
+ end
193
+
186
194
  def ascii_art
187
195
  <<~ASCII
188
196
  ██████▌ ██████▌
@@ -44,6 +44,11 @@ module Trek
44
44
  "app/policies/admin/tag_category_policy.rb"
45
45
  end
46
46
 
47
+ def copy_model_specs
48
+ template "spec/models/tag_spec.rb",
49
+ "spec/models/tag_spec.rb"
50
+ end
51
+
47
52
  def copy_policy_specs
48
53
  template "spec/policies/admin/tag_policy_spec.rb",
49
54
  "spec/policies/admin/tag_policy_spec.rb"
@@ -158,6 +158,22 @@ bundler-audit:
158
158
  script:
159
159
  - bundle exec bundle-audit check --update
160
160
 
161
+ # Software Bill of Materials (CycloneDX): the gems and npm packages in the lockfiles, with their licenses.
162
+ # Uncomment the job when a client asks for one.
163
+ # GitLab keeps each branch's latest one by default, past `expire_in`: hand over the one of the production branch.
164
+ # cdxgen runs with an empty environment (env -i), so neither it nor its dependencies see the CI's tokens.
165
+ # sbom:
166
+ # <<: *security_definition
167
+ # script:
168
+ # - env -i PATH="$PATH" HOME="$HOME" FETCH_LICENSE=true npx --yes @cyclonedx/cdxgen@12.8.6 --type ruby --type js --no-recurse --output sbom.cdx.json .
169
+ # artifacts:
170
+ # name: "sbom-${CI_COMMIT_REF_SLUG}"
171
+ # expire_in: 1 month
172
+ # paths:
173
+ # - sbom.cdx.json
174
+ # reports:
175
+ # cyclonedx: sbom.cdx.json
176
+
161
177
  #
162
178
  # Deploy
163
179
  #
@@ -173,7 +189,7 @@ bundler-audit:
173
189
  # - main
174
190
  # environment:
175
191
  # name: qa
176
- # url: https://qa.temoanadiving.com
192
+ # url: https://qa.example.com
177
193
  # script:
178
194
  # - curl $HATCHBOX_WEBHOOK_QA -X POST
179
195
 
@@ -183,6 +199,6 @@ bundler-audit:
183
199
  # - production
184
200
  # environment:
185
201
  # name: preprod
186
- # url: https://temoanadiving.com
202
+ # url: https://example.com
187
203
  # script:
188
204
  # - curl $HATCHBOX_WEBHOOK_PROD -X POST
@@ -0,0 +1,21 @@
1
+ module Admin
2
+ # Signs out browsers signed in as the current user, listed on their profile's sessions tab. Only
3
+ # theirs: every query goes through Current.user.sessions.
4
+ class ActiveSessionsController < Admin::BaseController
5
+ def destroy
6
+ Current.user.sessions.find(params[:id]).destroy
7
+ redirect_to sessions_tab, notice: t(".success")
8
+ end
9
+
10
+ def destroy_others
11
+ Current.user.sessions.where.not(id: Current.session.id).destroy_all
12
+ redirect_to sessions_tab, notice: t(".success")
13
+ end
14
+
15
+ private
16
+
17
+ def sessions_tab
18
+ admin_user_path(Current.user, anchor: "sessions")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ module Admin
2
+ class AuditLogsController < Trek::ResourceController
3
+ include Trek::Filters
4
+ include Trek::Pagination
5
+ include Trek::Scopes
6
+
7
+ helper Trek::AuditLogsHelper
8
+
9
+ def index
10
+ super
11
+ @objects = @objects.includes(:actor, :auditable)
12
+ apply_filters!
13
+ apply_scopes!
14
+ end
15
+
16
+ private
17
+
18
+ def model
19
+ AuditLog
20
+ end
21
+
22
+ def available_filters
23
+ %i[action actor_type actor_id auditable_type auditable_id]
24
+ end
25
+
26
+ def available_scopes
27
+ %i[failures]
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,41 @@
1
+ module Admin
2
+ # Signing in with a passkey instead of a password, from the sign-in form
3
+ class PasskeySessionsController < ApplicationController
4
+ include Trek::Authentication
5
+ include Trek::Passkeys
6
+
7
+ allow_unauthenticated_access
8
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_admin_user_session_path, alert: t("notices.rate_limited") }
9
+
10
+ # What the browser needs to offer the site's passkeys, fetched when the sign-in form opens
11
+ def options
12
+ render_passkey_options User.passkey_request_options(passkey_relying_party)
13
+ end
14
+
15
+ def create
16
+ @user = User.privileged.authenticate_by_passkey(passkey_relying_party, params[:credential], challenge: take_passkey_challenge)
17
+
18
+ if @user
19
+ # A passkey proves it's them: failed passwords no longer lock them out
20
+ @user.record_successful_sign_in!
21
+ start_new_session_for @user
22
+ record_sign_in_of @user
23
+ redirect_to after_authentication_url, notice: sign_in_notice(@user)
24
+ else
25
+ redirect_to new_admin_user_session_path, alert: t(".failure")
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # In the user's language: this request isn't signed in yet, so it's still in the app's default one.
32
+ def sign_in_notice(user)
33
+ I18n.with_locale(user.locale || I18n.default_locale) { t("admin.user_sessions.create.success") }
34
+ end
35
+
36
+ # For the audit log (Trek::Auditable): the account signed in to
37
+ def audit_target
38
+ @user
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,37 @@
1
+ module Admin
2
+ # Passkeys the current user adds and deletes, on their profile's passkeys tab. Only theirs: every
3
+ # query goes through Current.user.passkeys.
4
+ class PasskeysController < Admin::BaseController
5
+ include Trek::Passkeys
6
+
7
+ helper Trek::UserAgentsHelper
8
+
9
+ # What the browser needs to create a passkey, fetched when the user adds one
10
+ def options
11
+ render_passkey_options Current.user.passkey_creation_options(passkey_relying_party)
12
+ end
13
+
14
+ def create
15
+ # Shown when Trek doesn't know the passkey's provider
16
+ browser = helpers.user_agent_summary(request.user_agent)
17
+ passkey = Current.user.add_passkey(passkey_relying_party, params[:credential], challenge: take_passkey_challenge, name: browser)
18
+
19
+ if passkey
20
+ redirect_to passkeys_tab, notice: t(".success")
21
+ else
22
+ redirect_to passkeys_tab, alert: t(".failure")
23
+ end
24
+ end
25
+
26
+ def destroy
27
+ Current.user.passkeys.find(params[:id]).destroy
28
+ redirect_to passkeys_tab, notice: t(".success"), status: :see_other
29
+ end
30
+
31
+ private
32
+
33
+ def passkeys_tab
34
+ admin_user_path(Current.user, anchor: "passkeys")
35
+ end
36
+ end
37
+ end
@@ -18,9 +18,9 @@ module Admin
18
18
  end
19
19
 
20
20
  def create
21
- if user = User.privileged.find_by(email: params[:email])
22
- Admin::PasswordsMailer.reset(user).deliver_later
23
- end
21
+ # Queued whatever the email, and answered the same: the response doesn't tell whether an
22
+ # account exists.
23
+ Admin::PasswordResetJob.perform_later(params[:email].to_s)
24
24
 
25
25
  redirect_to new_admin_user_session_path, notice: t(".success")
26
26
  end
@@ -37,7 +37,7 @@ module Admin
37
37
  end
38
38
 
39
39
  @user.update!(params.permit(:password))
40
- @user.user_sessions.destroy_all
40
+ @user.sessions.destroy_all
41
41
  redirect_to new_admin_user_session_path, notice: t(".success")
42
42
  rescue ActiveRecord::RecordInvalid
43
43
  redirect_to edit_admin_user_password_reset_path(@token), alert: @user.errors.full_messages.join(", ")
@@ -2,8 +2,13 @@ module Admin
2
2
  class UserSessionsController < ApplicationController
3
3
  include Trek::Authentication
4
4
 
5
+ # Failed sign-ins from one IP address, whatever the account, over a longer window than
6
+ # rate_limit's. Counted in Rails.cache, as rate_limit is.
7
+ FAILED_SIGN_INS_PER_IP = 20
8
+ FAILED_SIGN_INS_WINDOW = 1.hour
9
+
5
10
  allow_unauthenticated_access only: %i[new create]
6
- rate_limit to: 5, within: 3.minutes, only: :create, with: -> { redirect_to new_admin_user_session_path, alert: t("notices.rate_limited") }
11
+ rate_limit to: 5, within: 3.minutes, only: :create, with: -> { render_rate_limited }
7
12
 
8
13
  helper Trek::I18nHelper
9
14
  helper Trek::ThemeHelper
@@ -19,11 +24,22 @@ module Admin
19
24
  end
20
25
 
21
26
  def create
22
- if user = User.privileged.authenticate_by(session_params)
27
+ return render_rate_limited if too_many_failed_sign_ins_from_ip?
28
+
29
+ # Hashes the password typed even when no user matches, so the response takes as long
30
+ user = User.privileged.authenticate_by(session_params)
31
+ @user = user || User.privileged.find_by(email: params[:email].to_s)
32
+
33
+ # A locked user is refused even with the right password, with the same answer as a wrong one
34
+ if user && !user.sign_in_locked?
35
+ user.record_successful_sign_in!
23
36
  start_new_session_for user
24
- redirect_to after_authentication_url, notice: t(".success")
37
+ record_sign_in_of user
38
+ redirect_to after_authentication_url, notice: sign_in_notice(user)
25
39
  else
26
- redirect_to new_admin_user_session_path, alert: t(".failure")
40
+ @user&.record_failed_sign_in!
41
+ count_failed_sign_in_from_ip
42
+ render_sign_in_form t(".failure"), status: :unprocessable_entity
27
43
  end
28
44
  end
29
45
 
@@ -34,8 +50,41 @@ module Admin
34
50
 
35
51
  private
36
52
 
53
+ # In the user's language: this request isn't signed in yet, so it's still in the app's default one.
54
+ def sign_in_notice(user)
55
+ I18n.with_locale(user.locale || I18n.default_locale) { t(".success") }
56
+ end
57
+
37
58
  def session_params
38
59
  params.permit(:email, :password)
39
60
  end
61
+
62
+ # Rendered rather than redirected to, so the audit log records the failure's status
63
+ def render_sign_in_form(alert, status:)
64
+ @email = params[:email].presence
65
+ flash.now[:alert] = alert
66
+ render :new, status:
67
+ end
68
+
69
+ def render_rate_limited
70
+ render_sign_in_form t("notices.rate_limited"), status: :too_many_requests
71
+ end
72
+
73
+ def too_many_failed_sign_ins_from_ip?
74
+ Rails.cache.read(failed_sign_ins_from_ip_key).to_i >= FAILED_SIGN_INS_PER_IP
75
+ end
76
+
77
+ def count_failed_sign_in_from_ip
78
+ Rails.cache.increment(failed_sign_ins_from_ip_key, 1, expires_in: FAILED_SIGN_INS_WINDOW)
79
+ end
80
+
81
+ def failed_sign_ins_from_ip_key
82
+ "admin/failed_sign_ins/#{request.remote_ip}"
83
+ end
84
+
85
+ # For the audit log (Trek::Auditable): the account signed in to, or tried
86
+ def audit_target
87
+ @user
88
+ end
40
89
  end
41
90
  end
@@ -1,5 +1,17 @@
1
1
  module Admin
2
2
  class UsersController < Trek::ResourceController
3
+ helper Trek::UserAgentsHelper
4
+
5
+ # On their own profile, users also see the browsers signed in as them (the sessions tab), and
6
+ # their passkeys.
7
+ def show
8
+ super
9
+ return unless @object == Current.user
10
+
11
+ @sessions = Current.user.sessions.active.recent_first
12
+ @passkeys = Current.user.passkeys.order(:created_at)
13
+ end
14
+
3
15
  def new
4
16
  super
5
17
  @object.role = "editor"
@@ -12,6 +24,14 @@ module Admin
12
24
  @object.password = SecureRandom.alphanumeric(24) if @object.password.blank?
13
25
  end
14
26
 
27
+ # After a suspected compromise: every user, this admin included, must choose a new password at
28
+ # their next request.
29
+ def require_password_renewal
30
+ authorize! User, to: :require_password_renewal?
31
+ User.require_password_renewal!
32
+ redirect_to admin_users_path, notice: t(".success")
33
+ end
34
+
15
35
  def theme
16
36
  value = params[:theme].to_s
17
37
  Current.user.update!(theme: value) if User.themes.key?(value)
@@ -22,15 +22,32 @@ module Trek::Authentication
22
22
  end
23
23
 
24
24
  def require_authentication
25
- resume_session || request_authentication
25
+ return request_authentication unless resume_session
26
+
27
+ renew_password if Current.user.try(:password_renewal_needed?)
28
+ end
29
+
30
+ # An expired password, or one an admin asked everyone to renew, must be changed before going on:
31
+ # signs out and opens the new password form.
32
+ def renew_password
33
+ token = Current.user.password_reset_token
34
+ terminate_session
35
+ redirect_to edit_admin_user_password_reset_path(token), alert: t("notices.password_renewal_needed")
26
36
  end
27
37
 
28
38
  def resume_session
29
39
  Current.session ||= find_session_by_cookie
30
40
  end
31
41
 
42
+ # A session past its idle timeout or lifetime is dropped: the user signs in again.
32
43
  def find_session_by_cookie
33
- UserSession.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
44
+ return unless cookies.signed[:session_id]
45
+
46
+ user_session = User::Session.find_by(id: cookies.signed[:session_id])
47
+ return user_session unless user_session&.expired?
48
+
49
+ user_session.destroy
50
+ nil
34
51
  end
35
52
 
36
53
  def request_authentication
@@ -43,20 +60,54 @@ module Trek::Authentication
43
60
  end
44
61
 
45
62
  def start_new_session_for(user)
46
- user.user_sessions.create!(user_agent: request.user_agent).tap do |session|
63
+ user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
47
64
  Current.session = session
48
- cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
65
+ # Its IP address's host name, for the active sessions list, without slowing the sign-in down
66
+ Trek::ResolveSessionHostnameJob.perform_later(session)
67
+ cookies.signed[:session_id] = {
68
+ value: session.id, expires: User::Session.lifetime.from_now, httponly: true, same_site: :lax
69
+ }
49
70
  end
50
71
  end
51
72
 
73
+ # Records the sign-in as the user's current one, and emails them when this browser never signed
74
+ # in as them before (unless it's their first sign-in, or the app turned it off).
75
+ def record_sign_in_of(user)
76
+ new_browser = !signed_in_from_this_browser?(user)
77
+ remember_sign_in_from_this_browser(user)
78
+ user.record_sign_in(ip: request.remote_ip, user_agent: request.user_agent)
79
+ return unless new_browser && user.last_sign_in_at && user.new_sign_in_email
80
+
81
+ AdminMailer.new_sign_in(
82
+ user, at: user.current_sign_in_at, ip: request.remote_ip, user_agent: request.user_agent
83
+ ).deliver_later
84
+ end
85
+
86
+ # The users who signed in from this browser, in a signed cookie kept for years
87
+ def signed_in_from_this_browser?(user)
88
+ Array(cookies.signed[:trek_known_users]).include?(user.id)
89
+ end
90
+
91
+ def remember_sign_in_from_this_browser(user)
92
+ user_ids = (Array(cookies.signed[:trek_known_users]) | [user.id]).last(20)
93
+ cookies.signed.permanent[:trek_known_users] = { value: user_ids, httponly: true, same_site: :lax }
94
+ end
95
+
52
96
  def terminate_session
53
97
  Current.session.destroy
54
98
  cookies.delete(:session_id)
55
99
  end
56
100
 
101
+ # At most once every User::Session.activity_interval (5 minutes), so browsing doesn't write on
102
+ # every page.
57
103
  def track_last_activity
58
- # rubocop:disable Rails/SkipsModelValidations
59
- Current.user&.update_columns(last_activity_at: Time.current)
60
- # rubocop:enable Rails/SkipsModelValidations
104
+ # Not after signing out, which destroys the session
105
+ return unless Current.session&.persisted?
106
+
107
+ Current.session.record_activity
108
+ last_activity_at = Current.user.last_activity_at
109
+ return if last_activity_at && last_activity_at > User::Session.activity_interval.ago
110
+
111
+ Current.user.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
61
112
  end
62
113
  end
@@ -3,10 +3,6 @@ module ApplicationHelper
3
3
  # stylesheet_link_tag("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap")
4
4
  # end
5
5
 
6
- def admin_webfonts_link_tag
7
- stylesheet_link_tag("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap")
8
- end
9
-
10
6
  def admin_full_brand
11
7
  [
12
8
  t("admin.brand.title"),
@@ -0,0 +1,11 @@
1
+ # Records an AuditLog row for each mutation in the admin (see Trek::Auditable), including sign-ins
2
+ # and password resets. Include Trek::Auditable in another controller to audit it too.
3
+ Rails.application.config.to_prepare do
4
+ [
5
+ Trek::BaseController,
6
+ Admin::BaseController,
7
+ Admin::UserSessionsController,
8
+ Admin::PasskeySessionsController,
9
+ Admin::UserPasswordResetsController
10
+ ].each { |controller| controller.include(Trek::Auditable) }
11
+ end