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
@@ -47,15 +47,15 @@ RSpec.describe User do
47
47
  expect(user).to be_invalid
48
48
  end
49
49
 
50
- it "requires a password of at least 8 characters on creation" do
51
- user.password = "short"
50
+ it "requires a password of at least 12 characters on creation" do
51
+ user.password = "Sh0rt-but-1"
52
52
 
53
53
  expect(user).to be_invalid
54
54
  end
55
55
 
56
- it "requires a password of at least 8 characters when changing it" do
56
+ it "requires a password of at least 12 characters when changing it" do
57
57
  user = users.create
58
- user.password = "short"
58
+ user.password = "Sh0rt-but-1"
59
59
 
60
60
  expect(user).to be_invalid
61
61
  end
@@ -68,6 +68,144 @@ RSpec.describe User do
68
68
  end
69
69
  end
70
70
 
71
+ describe "previous passwords" do
72
+ let(:user) { users.create }
73
+
74
+ it "refuses the current password" do
75
+ user.password = users.password
76
+
77
+ expect(user).to be_invalid
78
+ end
79
+
80
+ it "refuses one of the last passwords" do
81
+ %w[second-password-2! third-password-3! fourth-password-4! fifth-password-5!].each { |password| user.update!(password:) }
82
+ user.password = users.password
83
+
84
+ expect(user).to be_invalid
85
+ end
86
+
87
+ it "accepts a password older than the last ones" do
88
+ %w[second-password-2! third-password-3! fourth-password-4! fifth-password-5! sixth-password-6!].each { |password| user.update!(password:) }
89
+ user.password = users.password
90
+
91
+ expect(user).to be_valid
92
+ end
93
+
94
+ it "keeps no more digests than needed" do
95
+ %w[second-password-2! third-password-3! fourth-password-4! fifth-password-5! sixth-password-6!].each { |password| user.update!(password:) }
96
+
97
+ expect(user.previous_passwords.count).to eq(described_class.password_history_size - 1)
98
+ end
99
+
100
+ it "deletes the digests with the user" do
101
+ user.update!(password: "second-password-2!")
102
+
103
+ expect { user.destroy }.to change(User::PreviousPassword, :count).by(-1)
104
+ end
105
+ end
106
+
107
+ describe "sign-in lockout" do
108
+ include ActiveSupport::Testing::TimeHelpers
109
+
110
+ let(:user) { users.create }
111
+
112
+ def fail_sign_ins(count)
113
+ count.times { user.record_failed_sign_in! }
114
+ end
115
+
116
+ # Each failure once the previous lock is over, as while locked they don't count
117
+ def fail_sign_ins_after_locks(count)
118
+ count.times do
119
+ travel_to(user.sign_in_locked_until + 1.second) if user.sign_in_locked?
120
+ user.record_failed_sign_in!
121
+ end
122
+ end
123
+
124
+ it "locks signing in after the failures allowed" do
125
+ fail_sign_ins(described_class.sign_in_lockout_after)
126
+
127
+ expect(user.sign_in_locked_until).to be_within(1.second).of(described_class.sign_in_lockout_duration.from_now)
128
+ end
129
+
130
+ it "doesn't lock before" do
131
+ fail_sign_ins(described_class.sign_in_lockout_after - 1)
132
+
133
+ expect(user.sign_in_locked?).to be(false)
134
+ end
135
+
136
+ it "doubles the lock on each further failure" do
137
+ fail_sign_ins_after_locks(described_class.sign_in_lockout_after + 2)
138
+
139
+ expect(user.sign_in_locked_until).to be_within(1.second).of((described_class.sign_in_lockout_duration * 4).from_now)
140
+ end
141
+
142
+ it "never locks for longer than the maximum" do
143
+ fail_sign_ins_after_locks(described_class.sign_in_lockout_after + 30)
144
+
145
+ expect(user.sign_in_locked_until).to be_within(1.second).of(described_class.sign_in_lockout_max.from_now)
146
+ end
147
+
148
+ it "doesn't count failures while locked" do
149
+ fail_sign_ins(described_class.sign_in_lockout_after + 3)
150
+
151
+ expect(user.failed_sign_in_count).to eq(described_class.sign_in_lockout_after)
152
+ end
153
+
154
+ it "unlocks with a new password" do
155
+ fail_sign_ins(described_class.sign_in_lockout_after)
156
+
157
+ user.update!(password: "second-password-2!")
158
+
159
+ expect([user.sign_in_locked?, user.failed_sign_in_count]).to eq([false, 0])
160
+ end
161
+
162
+ it "starts the count over after a successful sign-in" do
163
+ fail_sign_ins(described_class.sign_in_lockout_after - 1)
164
+
165
+ user.record_successful_sign_in!
166
+
167
+ expect(user.reload.failed_sign_in_count).to eq(0)
168
+ end
169
+ end
170
+
171
+ describe "password policy" do
172
+ def password_errors(password, **attributes)
173
+ users.new(password:, **attributes).tap(&:validate).errors.details[:password].pluck(:error)
174
+ end
175
+
176
+ it "accepts a short password with a digit and a symbol" do
177
+ expect(password_errors("Brume-de-l1n")).to be_empty
178
+ end
179
+
180
+ it "requires a digit and a symbol in a password shorter than a passphrase" do
181
+ expect(password_errors("brumedelinxyz")).to include(:digit_and_symbol)
182
+ end
183
+
184
+ it "accepts a long passphrase made of letters only" do
185
+ expect(password_errors("velo orange sous la pluie")).to be_empty
186
+ end
187
+
188
+ it "refuses a common password, whatever its case and punctuation" do
189
+ expect(password_errors("Password123!")).to include(:common)
190
+ end
191
+
192
+ it "refuses a password built on a keyboard or number sequence" do
193
+ expect(password_errors("Azerty-2026!x")).to include(:sequence)
194
+ end
195
+
196
+ it "refuses a password repeating a character" do
197
+ expect(password_errors("Gaaaaa-2026!x")).to include(:sequence)
198
+ end
199
+
200
+ it "refuses a password containing the user's email" do
201
+ expect(password_errors("Lovelace-1815!", email: "ada.lovelace@example.com")).to include(:personal)
202
+ end
203
+
204
+ it "refuses a password containing the user's name" do
205
+ expect(password_errors("Byron-1815!xyz", name: "Ada Byron")).to include(:personal)
206
+ end
207
+ end
208
+
71
209
  describe "email" do
72
210
  it "is stored in lower case, without surrounding spaces" do
73
211
  expect(users.create(email: " Ada@Example.com ").email).to eq("ada@example.com")
@@ -0,0 +1,119 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "Admin active sessions", type: :request do
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ let(:editor) { users.create(role: :editor) }
7
+ let(:safari) { "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15" }
8
+ let(:firefox) { "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0" }
9
+
10
+ def sign_in(user)
11
+ post admin_user_session_path, params: { email: user.email, password: users.password }, headers: { "User-Agent" => safari }
12
+ end
13
+
14
+ def come_back_after(duration)
15
+ travel duration
16
+ get admin_root_path
17
+ end
18
+
19
+ it "looks the host name of the address up after signing in, not during it" do
20
+ expect { sign_in(editor) }.to have_enqueued_job(Trek::ResolveSessionHostnameJob)
21
+ end
22
+
23
+ it "records the host name of the address signing in" do
24
+ allow(Trek::ReverseDns).to receive(:hostname).and_return("host.example.net")
25
+
26
+ perform_enqueued_jobs { sign_in(editor) }
27
+
28
+ expect(editor.sessions.last.ip_hostname).to eq("host.example.net")
29
+ end
30
+
31
+ describe "expiry" do
32
+ it "signs the user out after idle_timeout without a request" do
33
+ sign_in(editor)
34
+ travel User::Session.idle_timeout + 1.minute
35
+
36
+ get admin_root_path
37
+
38
+ expect(response).to redirect_to(new_admin_user_session_path)
39
+ end
40
+
41
+ it "keeps the user signed in while requests come in" do
42
+ sign_in(editor)
43
+ come_back_after(User::Session.idle_timeout - 1.hour)
44
+
45
+ come_back_after(User::Session.idle_timeout - 1.hour)
46
+
47
+ expect(response).to have_http_status(:ok)
48
+ end
49
+
50
+ it "records the activity at most once every activity_interval" do
51
+ sign_in(editor)
52
+ get admin_root_path
53
+ travel 1.minute
54
+
55
+ expect { get admin_root_path }.not_to(change { editor.reload.last_activity_at })
56
+ end
57
+ end
58
+
59
+ describe "the sessions tab of the user's profile" do
60
+ let!(:other_session) { editor.sessions.create!(user_agent: firefox, ip_address: "203.0.113.7") }
61
+
62
+ before { sign_in(editor) }
63
+
64
+ it "lists the user's sessions with their browser and IP address" do
65
+ get admin_user_path(editor)
66
+
67
+ expect(response.body).to include("Safari on macOS").and include("Firefox on Windows").and include("203.0.113.7")
68
+ end
69
+
70
+ it "shows an IP address's host name on hover" do
71
+ other_session.update!(ip_hostname: "host.example.net")
72
+
73
+ get admin_user_path(editor)
74
+
75
+ expect(response.body).to include('<abbr title="host.example.net">203.0.113.7</abbr>')
76
+ end
77
+
78
+ it "leaves out other users' sessions" do
79
+ users.create(role: :editor).sessions.create!(user_agent: firefox, ip_address: "198.51.100.9")
80
+
81
+ get admin_user_path(editor)
82
+
83
+ expect(response.body).not_to include("198.51.100.9")
84
+ end
85
+
86
+ it "signs another session out" do
87
+ expect { delete admin_active_session_path(other_session) }.to change(User::Session, :count).by(-1)
88
+ end
89
+
90
+ it "goes back to the sessions tab" do
91
+ delete admin_active_session_path(other_session)
92
+
93
+ expect(response).to redirect_to(admin_user_path(editor, anchor: "sessions"))
94
+ end
95
+
96
+ it "doesn't sign out another user's session" do
97
+ stranger = users.create(role: :editor).sessions.create!
98
+
99
+ delete admin_active_session_path(stranger)
100
+
101
+ expect(User::Session.exists?(stranger.id)).to be(true)
102
+ end
103
+
104
+ it "signs out every other session, and keeps this one" do
105
+ delete others_admin_active_sessions_path
106
+ get admin_root_path
107
+
108
+ expect([editor.sessions.count, response.status]).to eq([1, 200])
109
+ end
110
+ end
111
+
112
+ it "shows no sessions tab on another user's profile" do
113
+ sign_in(users.create(role: :admin))
114
+
115
+ get admin_user_path(editor)
116
+
117
+ expect(response.body).not_to include('href="#sessions"')
118
+ end
119
+ end
@@ -0,0 +1,149 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "Admin audit log", type: :request do
4
+ let(:admin) { users.create(role: :admin) }
5
+
6
+ before { seed "cases/page_tree" }
7
+
8
+ def sign_in(user)
9
+ post admin_user_session_path, params: { email: user.email, password: users.password }
10
+ end
11
+
12
+ describe "recording" do
13
+ before { sign_in(admin) }
14
+
15
+ it "records a change with its author, request, target and status" do
16
+ patch admin_page_path(pages.team), params: { page: { title: "The team" } }
17
+
18
+ expect(AuditLog.recent.first).to have_attributes(
19
+ actor: admin, action: "admin/pages#update", http_method: "PATCH", path: admin_page_path(pages.team),
20
+ auditable: pages.team, http_status: 302, error_class: nil
21
+ )
22
+ end
23
+
24
+ it "records the parameters by name, without the values the form sent" do
25
+ patch admin_page_path(pages.team), params: { page: { title: "The team" } }
26
+
27
+ expect(AuditLog.recent.first.params).to include("id" => pages.team.id.to_s, "page" => { "title" => "[FILTERED]" })
28
+ end
29
+
30
+ it "records the value of a role change" do
31
+ editor = users.create(role: :editor)
32
+
33
+ patch admin_user_path(editor), params: { user: { role: "admin" } }
34
+
35
+ expect(AuditLog.recent.first.params).to include("user" => { "role" => "admin" })
36
+ end
37
+
38
+ it "records a deleted record as the target" do
39
+ page = pages.team
40
+
41
+ delete admin_page_path(page)
42
+
43
+ expect(AuditLog.recent.first).to have_attributes(auditable_type: "Page", auditable_id: page.id)
44
+ end
45
+
46
+ it "records the error a failed request raised" do
47
+ patch admin_page_path(id: 0), params: { page: { title: "Missing" } }
48
+
49
+ expect(AuditLog.recent.first).to have_attributes(error_class: "ActiveRecord::RecordNotFound", http_status: nil)
50
+ end
51
+
52
+ it "records nothing for a read" do
53
+ expect { get admin_pages_path }.not_to change(AuditLog, :count)
54
+ end
55
+
56
+ it "records nothing for a HEAD request either" do
57
+ expect { head admin_pages_path }.not_to change(AuditLog, :count)
58
+ end
59
+
60
+ it "records nothing when switched off with FEATURE_AUDIT_LOG=false" do
61
+ allow(ENV).to receive(:fetch).and_call_original
62
+ allow(ENV).to receive(:fetch).with("FEATURE_AUDIT_LOG", "true").and_return("false")
63
+
64
+ expect { patch admin_page_path(pages.team), params: { page: { title: "The team" } } }
65
+ .not_to change(AuditLog, :count)
66
+ end
67
+
68
+ context "when the row can't be written" do
69
+ before do
70
+ allow(AuditLog).to receive(:create!).and_raise(ActiveRecord::StatementInvalid)
71
+ allow(Rails.error).to receive(:report)
72
+ patch admin_page_path(pages.team), params: { page: { title: "The team" } }
73
+ end
74
+
75
+ it "still completes the request" do
76
+ expect(pages.team.reload.title).to eq("The team")
77
+ end
78
+
79
+ it "reports the failure" do
80
+ expect(Rails.error).to have_received(:report).with(an_instance_of(ActiveRecord::StatementInvalid), anything)
81
+ end
82
+ end
83
+ end
84
+
85
+ describe "recording sign-ins" do
86
+ it "records a failed sign-in, without its password" do
87
+ post admin_user_session_path, params: { email: admin.email, password: "wrong" }
88
+
89
+ expect(AuditLog.recent.first).to have_attributes(
90
+ actor: nil, action: "admin/user_sessions#create", params: hash_including("password" => "[FILTERED]")
91
+ )
92
+ end
93
+
94
+ it "records a failed sign-in as a failure, on the account tried" do
95
+ post admin_user_session_path, params: { email: admin.email, password: "wrong" }
96
+
97
+ expect(AuditLog.failures.for_auditable(admin)).to exist
98
+ end
99
+
100
+ it "records a change attempted without signing in" do
101
+ patch admin_page_path(pages.team), params: { page: { title: "Hacked" } }
102
+
103
+ expect(AuditLog.recent.first).to have_attributes(actor: nil, action: "admin/pages#update", http_status: 302)
104
+ end
105
+ end
106
+
107
+ describe "listing" do
108
+ before { sign_in(admin) }
109
+
110
+ it "lists the rows" do
111
+ get admin_audit_logs_path
112
+
113
+ expect(response.body).to include("admin/user_sessions#create")
114
+ end
115
+
116
+ it "filters the rows on a record", :aggregate_failures do
117
+ AuditLog.create!(action: "admin/pages#update", http_method: "PATCH", path: "/", auditable: pages.team)
118
+ AuditLog.create!(action: "admin/pages#destroy", http_method: "DELETE", path: "/", auditable: pages.company)
119
+
120
+ get admin_audit_logs_path(filters: { auditable_type: "Page", auditable_id: pages.team.id })
121
+
122
+ expect(response.body).to include("admin/pages#update")
123
+ expect(response.body).not_to include("admin/pages#destroy")
124
+ end
125
+
126
+ it "lists the failures only", :aggregate_failures do
127
+ AuditLog.create!(action: "admin/pages#update", http_method: "PATCH", path: "/", http_status: 422)
128
+
129
+ get admin_audit_logs_path(scopes: { failures: 1 })
130
+
131
+ expect(response.body).to include("admin/pages#update")
132
+ expect(response.body).not_to include("admin/user_sessions#create")
133
+ end
134
+
135
+ it "shows a row" do
136
+ get admin_audit_log_path(AuditLog.recent.first)
137
+
138
+ expect(response.body).to include("admin/user_sessions#create")
139
+ end
140
+ end
141
+
142
+ it "is closed to an editor" do
143
+ sign_in(users.create(role: :editor))
144
+
145
+ get admin_audit_logs_path
146
+
147
+ expect(response).to redirect_to(admin_root_path)
148
+ end
149
+ end
@@ -0,0 +1,103 @@
1
+ require "rails_helper"
2
+ # Not loaded with the gem: it stands in for a browser with an authenticator
3
+ require "webauthn/fake_client"
4
+
5
+ RSpec.describe "Admin sign-in with a passkey", type: :request do
6
+ let(:editor) { users.create(role: :editor) }
7
+ # A browser with an authenticator, on the app's test host
8
+ let(:client) { WebAuthn::FakeClient.new("http://www.example.com") }
9
+
10
+ # The passkey the editor added from their profile, then signed out
11
+ before do
12
+ post admin_user_session_path, params: { email: editor.email, password: users.password }
13
+ get options_admin_passkeys_path, as: :json
14
+ credential = client.create(challenge: response.parsed_body["challenge"], user_verified: true)
15
+ post admin_passkeys_path, params: { credential: credential.to_json }
16
+ delete admin_user_session_path
17
+ end
18
+
19
+ def request_challenge
20
+ get options_admin_passkey_session_path, as: :json
21
+ response.parsed_body["challenge"]
22
+ end
23
+
24
+ # The bytes the passkey was created for, which the browser sends back encoded
25
+ def sign_in(challenge: request_challenge, user_verified: true, user_handle: WebAuthn.standard_encoder.decode(editor.reload.webauthn_id))
26
+ credential = client.get(challenge:, user_verified:, user_handle:)
27
+ post admin_passkey_session_path, params: { credential: credential.to_json }
28
+ end
29
+
30
+ it "asks for any of the site's passkeys, verifying the user" do
31
+ get options_admin_passkey_session_path, as: :json
32
+
33
+ expect(response.parsed_body).to include("allowCredentials" => [], "userVerification" => "required")
34
+ end
35
+
36
+ it "signs the user in" do
37
+ sign_in
38
+
39
+ expect(response).to redirect_to(admin_root_url)
40
+ end
41
+
42
+ # A passkey can't be guessed: failed passwords don't keep its owner out
43
+ it "lets in a user whose password sign-in is locked, and unlocks it" do
44
+ User.sign_in_lockout_after.times do
45
+ post admin_user_session_path, params: { email: editor.email, password: "not the password" }
46
+ end
47
+
48
+ sign_in
49
+
50
+ expect([response.location, editor.reload.sign_in_locked?, editor.failed_sign_in_count]).to eq([admin_root_url, false, 0])
51
+ end
52
+
53
+ it "records the passkey's use" do
54
+ sign_in
55
+
56
+ expect(editor.passkeys.sole.last_used_at).to be_present
57
+ end
58
+
59
+ it "refuses a passkey that doesn't verify the user" do
60
+ sign_in(user_verified: false)
61
+
62
+ expect(response).to redirect_to(new_admin_user_session_path)
63
+ end
64
+
65
+ it "refuses a signature of another challenge" do
66
+ request_challenge
67
+ sign_in(challenge: WebAuthn.generate_user_id)
68
+
69
+ expect(response).to redirect_to(new_admin_user_session_path)
70
+ end
71
+
72
+ it "refuses to reuse a challenge" do
73
+ challenge = request_challenge
74
+ sign_in(challenge:)
75
+ delete admin_user_session_path
76
+
77
+ sign_in(challenge:)
78
+
79
+ expect(response).to redirect_to(new_admin_user_session_path)
80
+ end
81
+
82
+ it "refuses a passkey created for another user than the one it's stored for" do
83
+ sign_in(user_handle: WebAuthn.standard_encoder.decode(WebAuthn.generate_user_id))
84
+
85
+ expect(response).to redirect_to(new_admin_user_session_path)
86
+ end
87
+
88
+ it "refuses a deleted passkey" do
89
+ editor.passkeys.destroy_all
90
+
91
+ sign_in
92
+
93
+ expect(response).to redirect_to(new_admin_user_session_path)
94
+ end
95
+
96
+ it "refuses a user who is no longer privileged" do
97
+ editor.update!(role: :reader)
98
+
99
+ sign_in
100
+
101
+ expect(response).to redirect_to(new_admin_user_session_path)
102
+ end
103
+ end
@@ -0,0 +1,104 @@
1
+ require "rails_helper"
2
+ # Not loaded with the gem: it stands in for a browser with an authenticator
3
+ require "webauthn/fake_client"
4
+
5
+ RSpec.describe "Admin passkeys", type: :request do
6
+ let(:editor) { users.create(role: :editor) }
7
+ # A browser with an authenticator, on the app's test host
8
+ let(:client) { WebAuthn::FakeClient.new("http://www.example.com") }
9
+ let(:safari) { "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15" }
10
+
11
+ before { post admin_user_session_path, params: { email: editor.email, password: users.password } }
12
+
13
+ def creation_options
14
+ get options_admin_passkeys_path, as: :json
15
+ response.parsed_body
16
+ end
17
+
18
+ def add_passkey(user_verified: true)
19
+ challenge = creation_options["challenge"]
20
+ credential = client.create(challenge:, user_verified:).to_json
21
+ post admin_passkeys_path, params: { credential: }, headers: { "User-Agent" => safari }
22
+ end
23
+
24
+ it "asks the authenticator to keep the passkey and verify the user" do
25
+ expect(creation_options["authenticatorSelection"]).to include("residentKey" => "required", "userVerification" => "required")
26
+ end
27
+
28
+ it "excludes the user's passkeys, so an authenticator holding one says so" do
29
+ add_passkey
30
+
31
+ expect(creation_options["excludeCredentials"].pluck("id")).to eq(editor.passkeys.pluck(:external_id))
32
+ end
33
+
34
+ it "adds a passkey, with its transports and the AAGUID saying what keeps it" do
35
+ add_passkey
36
+
37
+ expect(editor.passkeys.sole).to have_attributes(transports: ["internal"], aaguid: match(/\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/))
38
+ end
39
+
40
+ it "shows it under its password manager's name" do
41
+ allow(Trek::PasskeyProviders).to receive(:[]).and_return("Passwords")
42
+
43
+ add_passkey
44
+
45
+ expect(editor.passkeys.sole.to_s).to eq("Passwords")
46
+ end
47
+
48
+ it "shows it under the browser it was added from when its password manager is unknown" do
49
+ allow(Trek::PasskeyProviders).to receive(:[]).and_return(nil)
50
+
51
+ add_passkey
52
+
53
+ expect(editor.passkeys.sole.to_s).to eq(I18n.t("trek.user_agents.browser_on_system", browser: "Safari", system: "macOS"))
54
+ end
55
+
56
+ it "goes back to the passkeys tab once added" do
57
+ add_passkey
58
+
59
+ expect(response).to redirect_to(admin_user_path(editor, anchor: "passkeys"))
60
+ end
61
+
62
+ it "refuses a passkey that doesn't verify the user" do
63
+ add_passkey(user_verified: false)
64
+
65
+ expect(editor.passkeys).to be_empty
66
+ end
67
+
68
+ it "refuses a passkey created for another challenge" do
69
+ creation_options
70
+ post admin_passkeys_path, params: { credential: client.create(challenge: WebAuthn.generate_user_id).to_json }
71
+
72
+ expect(editor.passkeys).to be_empty
73
+ end
74
+
75
+ it "refuses to reuse a challenge" do
76
+ challenge = creation_options["challenge"]
77
+ post admin_passkeys_path, params: { credential: client.create(challenge:, user_verified: true).to_json }
78
+ post admin_passkeys_path, params: { credential: client.create(challenge:, user_verified: true).to_json }
79
+
80
+ expect(editor.passkeys.count).to eq(1)
81
+ end
82
+
83
+ it "deletes a passkey" do
84
+ add_passkey
85
+
86
+ expect { delete admin_passkey_path(editor.passkeys.sole) }.to change(User::Passkey, :count).by(-1)
87
+ end
88
+
89
+ it "doesn't touch another user's passkey" do
90
+ other = users.create(role: :editor).passkeys.create!(name: "Theirs", external_id: "theirs", public_key: "key")
91
+
92
+ delete admin_passkey_path(other)
93
+
94
+ expect(response).to have_http_status(:not_found)
95
+ end
96
+
97
+ it "lists the passkeys on the user's profile" do
98
+ add_passkey
99
+
100
+ get admin_user_path(editor)
101
+
102
+ expect(response.body).to include(editor.passkeys.sole.to_s)
103
+ end
104
+ end