trek 2.3.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.node-version +1 -1
- data/.yarn/releases/yarn-4.18.1.cjs +1000 -0
- data/.yarnrc.yml +3 -5
- data/AGENTS.md +7 -4
- data/CHANGELOG.md +48 -0
- data/Gemfile.lock +35 -1
- data/Makefile +12 -0
- data/app/assets/icons/trek/passkey.svg +5 -0
- data/app/assets/stylesheets/trek/base.css +6 -0
- data/app/components/trek/entries/item_component/item_component.css +6 -0
- data/app/components/trek/form/image_field_component/image_field_component.html.slim +9 -0
- data/app/components/trek/form/password_strength_component/password_strength_component.css +56 -0
- data/app/components/trek/form/password_strength_component/password_strength_component.html.slim +8 -0
- data/app/components/trek/form/password_strength_component/password_strength_component.js +74 -0
- data/app/components/trek/form/password_strength_component.rb +29 -0
- data/app/components/trek/gate_component/gate_component.css +7 -0
- data/app/components/trek/icon/sprite_component.rb +3 -2
- data/app/components/trek/section_component/section_component.css +22 -0
- data/app/components/trek/section_component/section_component.html.slim +5 -0
- data/app/components/trek/section_component/section_component.js +1 -0
- data/app/components/trek/section_component.rb +14 -0
- data/app/controllers/concerns/trek/auditable.rb +103 -0
- data/app/controllers/concerns/trek/filters.rb +2 -0
- data/app/controllers/concerns/trek/passkeys.rb +37 -0
- data/app/controllers/trek/csp_reports_controller.rb +62 -0
- data/app/controllers/trek/resource_controller.rb +7 -1
- data/app/helpers/trek/audit_logs_helper.rb +21 -0
- data/app/helpers/trek/user_agents_helper.rb +36 -0
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/passkey_controller.js +118 -0
- data/app/javascript/trek.js +4 -0
- data/app/jobs/trek/audit_log/purge_job.rb +18 -0
- data/app/jobs/trek/purge_expired_sessions_job.rb +14 -0
- data/app/jobs/trek/resolve_session_hostname_job.rb +17 -0
- data/app/models/concerns/trek/audit_log.rb +30 -0
- data/app/models/concerns/trek/users/passkeys.rb +75 -0
- data/app/models/concerns/trek/users/password_history.rb +51 -0
- data/app/models/concerns/trek/users/password_policy.rb +85 -0
- data/app/models/concerns/trek/users/password_renewal.rb +51 -0
- data/app/models/concerns/trek/users/session_lifetime.rb +39 -0
- data/app/models/concerns/trek/users/sign_in_lockout.rb +67 -0
- data/app/models/concerns/trek/users/sign_ins.rb +31 -0
- data/app/models/concerns/trek/users/validations.rb +3 -2
- data/app/models/trek/feature.rb +6 -0
- data/app/models/trek/pwned_passwords.rb +40 -0
- data/app/models/trek/user_session.rb +10 -0
- data/app/policies/trek/audit_log_policy.rb +34 -0
- data/app/policies/trek/user_policy.rb +5 -0
- data/app/validators/not_disposable_validator.rb +18 -0
- data/config/brakeman.ignore +22 -0
- data/config/i18n-tasks.yml +7 -0
- data/config/locales/trek.en.yml +144 -39
- data/config/locales/trek.fr.yml +145 -40
- data/docs/.vitepress/config.mjs +8 -3
- data/docs/deploy.md +2 -0
- data/docs/proxies.md +53 -0
- data/docs/reference/components.md +1 -0
- data/docs/reference/environment-variables.md +2 -0
- data/docs/reference/generators/admin-user.md +1 -1
- data/docs/reference/generators/audit-log.md +113 -0
- data/docs/reference/generators/install.md +12 -3
- data/docs/reference/generators/security-headers.md +109 -0
- data/docs/reference/index.md +3 -0
- data/docs/reference/models/tag.md +4 -2
- data/docs/reference/models/user.md +123 -3
- data/docs/reference/validators.md +94 -0
- data/docs/security.md +90 -0
- data/lefthook.yml +8 -0
- data/lib/generators/trek/install/admin/user_password_resets_generator.rb +5 -0
- data/lib/generators/trek/install/admin/user_sessions_generator.rb +5 -0
- data/lib/generators/trek/install/admin/users_generator.rb +23 -0
- data/lib/generators/trek/install/audit_log_generator.rb +56 -0
- data/lib/generators/trek/install/authentication_generator.rb +77 -3
- data/lib/generators/trek/install/esbuild_generator.rb +1 -2
- data/lib/generators/trek/install/mailer_generator.rb +18 -0
- data/lib/generators/trek/install/model_specs_generator.rb +6 -4
- data/lib/generators/trek/install/postcss_generator.rb +1 -4
- data/lib/generators/trek/install/security_headers_generator.rb +49 -0
- data/lib/generators/trek/install/yarn_generator.rb +1 -1
- data/lib/generators/trek/install_generator.rb +62 -54
- data/lib/generators/trek/taxonomies_generator.rb +5 -0
- data/lib/generators/trek/templates/.gitlab-ci.yml +18 -2
- data/lib/generators/trek/templates/controllers/admin/active_sessions_controller.rb +21 -0
- data/lib/generators/trek/templates/controllers/admin/audit_logs_controller.rb +30 -0
- data/lib/generators/trek/templates/controllers/admin/passkey_sessions_controller.rb +41 -0
- data/lib/generators/trek/templates/controllers/admin/passkeys_controller.rb +37 -0
- data/lib/generators/trek/templates/controllers/admin/user_password_resets_controller.rb +4 -4
- data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +53 -4
- data/lib/generators/trek/templates/controllers/admin/users_controller.rb +20 -0
- data/lib/generators/trek/templates/controllers/concerns/trek/authentication.rb +58 -7
- data/lib/generators/trek/templates/helpers/application_helper.rb +0 -4
- data/lib/generators/trek/templates/initializers/audit_log.rb +11 -0
- data/lib/generators/trek/templates/initializers/content_security_policy.rb +49 -0
- data/lib/generators/trek/templates/initializers/permissions_policy.rb +19 -0
- data/lib/generators/trek/templates/jobs/admin/password_reset_job.rb +9 -0
- data/lib/generators/trek/templates/layouts/admin/auth.html.slim +0 -5
- data/lib/generators/trek/templates/layouts/admin.html.slim +0 -3
- data/lib/generators/trek/templates/mailers/admin/passwords_mailer.rb +1 -1
- data/lib/generators/trek/templates/mailers/admin_mailer.rb +28 -1
- data/lib/generators/trek/templates/migrations/audit_logs.rb.erb +27 -0
- data/lib/generators/trek/templates/migrations/tags.rb.erb +6 -1
- data/lib/generators/trek/templates/migrations/user_passkeys.rb.erb +24 -0
- data/lib/generators/trek/templates/migrations/user_password_renewal.rb.erb +8 -0
- data/lib/generators/trek/templates/migrations/user_previous_passwords.rb.erb +10 -0
- data/lib/generators/trek/templates/migrations/user_session_tracking.rb.erb +11 -0
- data/lib/generators/trek/templates/migrations/user_sign_in_lockout.rb.erb +7 -0
- data/lib/generators/trek/templates/migrations/user_sign_ins.rb.erb +13 -0
- data/lib/generators/trek/templates/models/audit_log.rb +3 -0
- data/lib/generators/trek/templates/models/tag_category.rb +2 -1
- data/lib/generators/trek/templates/models/user/passkey.rb +17 -0
- data/lib/generators/trek/templates/models/user/previous_password.rb +4 -0
- data/lib/generators/trek/templates/models/user/session.rb +8 -0
- data/lib/generators/trek/templates/policies/audit_log.rb +4 -0
- data/lib/generators/trek/templates/spec/mailers/admin/passwords_mailer_spec.rb +16 -0
- data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +50 -0
- data/lib/generators/trek/templates/spec/models/audit_log_spec.rb +43 -0
- data/lib/generators/trek/templates/spec/models/tag_spec.rb +52 -0
- data/lib/generators/trek/templates/spec/models/user/session_spec.rb +68 -0
- data/lib/generators/trek/templates/spec/models/user_spec.rb +142 -4
- data/lib/generators/trek/templates/spec/requests/admin/active_sessions_spec.rb +119 -0
- data/lib/generators/trek/templates/spec/requests/admin/audit_logs_spec.rb +149 -0
- data/lib/generators/trek/templates/spec/requests/admin/passkey_sessions_spec.rb +103 -0
- data/lib/generators/trek/templates/spec/requests/admin/passkeys_spec.rb +104 -0
- data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +83 -0
- data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +95 -0
- data/lib/generators/trek/templates/spec/requests/admin/sign_in_notices_spec.rb +93 -0
- data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +43 -6
- data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +25 -2
- data/lib/generators/trek/templates/views/admin/audit_logs/index.html.slim +35 -0
- data/lib/generators/trek/templates/views/admin/audit_logs/show.html.slim +50 -0
- data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +16 -6
- data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +2 -1
- data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +27 -1
- data/lib/generators/trek/templates/views/admin/users/_passkeys.html.slim +67 -0
- data/lib/generators/trek/templates/views/admin/users/_properties.html.slim +21 -0
- data/lib/generators/trek/templates/views/admin/users/_sessions.html.slim +70 -0
- data/lib/generators/trek/templates/views/admin/users/index.html.slim +13 -0
- data/lib/generators/trek/templates/views/admin/users/show.html.slim +10 -21
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +22 -0
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +16 -0
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +22 -0
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +16 -0
- data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.html.slim +17 -0
- data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.text.erb +10 -0
- data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.html.slim +18 -0
- data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.text.erb +10 -0
- data/lib/trek/admin_ip_allowlist.rb +52 -0
- data/lib/trek/csp_reports.rb +99 -0
- data/lib/trek/data/common_passwords.txt +857 -0
- data/lib/trek/data/passkey_providers.json +404 -0
- data/lib/trek/disposable_email_domains.rb +53 -0
- data/lib/trek/engine.rb +12 -0
- data/lib/trek/passkey_providers.rb +19 -0
- data/lib/trek/reverse_dns.rb +27 -0
- data/lib/trek/version.rb +1 -1
- data/lib/trek.rb +13 -0
- data/package.json +3 -5
- data/renovate.json +11 -1
- data/trek.gemspec +3 -0
- data/yarn.lock +1757 -2019
- metadata +132 -4
- data/.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs +0 -541
- data/.yarn/releases/yarn-3.8.0.cjs +0 -875
- data/lib/generators/trek/templates/models/user_session.rb +0 -3
|
@@ -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
|
|
51
|
-
user.password = "
|
|
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
|
|
56
|
+
it "requires a password of at least 12 characters when changing it" do
|
|
57
57
|
user = users.create
|
|
58
|
-
user.password = "
|
|
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
|