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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "Admin password renewal", type: :request do
|
|
4
|
+
let(:admin) { users.create(role: :admin) }
|
|
5
|
+
let(:editor) { users.create(role: :editor) }
|
|
6
|
+
|
|
7
|
+
def sign_in(user, password: users.password)
|
|
8
|
+
post admin_user_session_path, params: { email: user.email, password: }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it "lets an admin ask every user to renew their password" do
|
|
12
|
+
editor
|
|
13
|
+
sign_in(admin)
|
|
14
|
+
|
|
15
|
+
post require_password_renewal_admin_users_path
|
|
16
|
+
|
|
17
|
+
expect(editor.reload.password_renewal_required).to be(true)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "doesn't let an editor ask for it" do
|
|
21
|
+
admin
|
|
22
|
+
sign_in(editor)
|
|
23
|
+
|
|
24
|
+
post require_password_renewal_admin_users_path
|
|
25
|
+
|
|
26
|
+
expect(admin.reload.password_renewal_required).to be(false)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
context "when the password must be renewed" do
|
|
30
|
+
before do
|
|
31
|
+
editor
|
|
32
|
+
User.require_password_renewal!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "sends the user to the new password form after signing in" do
|
|
36
|
+
sign_in(editor)
|
|
37
|
+
follow_redirect!
|
|
38
|
+
|
|
39
|
+
expect(response).to redirect_to(%r{/admin/user_password_resets/[^/]+/edit\z})
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "keeps the user signed out until then" do
|
|
43
|
+
sign_in(editor)
|
|
44
|
+
follow_redirect!
|
|
45
|
+
get admin_root_path
|
|
46
|
+
|
|
47
|
+
expect(response).to redirect_to(new_admin_user_session_path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "lets the user back in with the new password" do
|
|
51
|
+
sign_in(editor)
|
|
52
|
+
follow_redirect!
|
|
53
|
+
patch response.location.delete_suffix("/edit"), params: { password: "a renewed passphrase here" }
|
|
54
|
+
sign_in(editor, password: "a renewed passphrase here")
|
|
55
|
+
|
|
56
|
+
expect(response).to redirect_to(admin_root_url)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
context "when the password has expired" do
|
|
61
|
+
around do |example|
|
|
62
|
+
User.password_expires_after = 1.year
|
|
63
|
+
example.run
|
|
64
|
+
ensure
|
|
65
|
+
User.password_expires_after = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "sends the user to the new password form after signing in" do
|
|
69
|
+
editor.update!(password_changed_at: 2.years.ago)
|
|
70
|
+
sign_in(editor)
|
|
71
|
+
follow_redirect!
|
|
72
|
+
|
|
73
|
+
expect(response).to redirect_to(%r{/admin/user_password_resets/[^/]+/edit\z})
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "lets a recent password through" do
|
|
77
|
+
sign_in(editor)
|
|
78
|
+
follow_redirect!
|
|
79
|
+
|
|
80
|
+
expect(response).to have_http_status(:ok)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "Admin sign-in lockout", type: :request do
|
|
4
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
5
|
+
|
|
6
|
+
let(:editor) { users.create(role: :editor) }
|
|
7
|
+
|
|
8
|
+
def sign_in(user = editor, password: users.password)
|
|
9
|
+
post admin_user_session_path, params: { email: user.email, password: }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def fail_sign_ins(count, user = editor)
|
|
13
|
+
count.times { sign_in(user, password: "not the password") }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def lock_then_extend
|
|
17
|
+
fail_sign_ins(User.sign_in_lockout_after)
|
|
18
|
+
travel(User.sign_in_lockout_duration + 1.second) { fail_sign_ins(1) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe "per account" do
|
|
22
|
+
it "refuses the right password once failures lock signing in" do
|
|
23
|
+
fail_sign_ins(User.sign_in_lockout_after)
|
|
24
|
+
|
|
25
|
+
sign_in
|
|
26
|
+
|
|
27
|
+
expect(response).to have_http_status(:unprocessable_entity)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "answers a locked account as a wrong password, so it can't be told apart" do
|
|
31
|
+
fail_sign_ins(User.sign_in_lockout_after)
|
|
32
|
+
sign_in(password: "not the password")
|
|
33
|
+
wrong_password = [response.status, flash[:alert]]
|
|
34
|
+
|
|
35
|
+
sign_in
|
|
36
|
+
|
|
37
|
+
expect([response.status, flash[:alert]]).to eq(wrong_password)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "lets the user in once the lock is over" do
|
|
41
|
+
fail_sign_ins(User.sign_in_lockout_after)
|
|
42
|
+
|
|
43
|
+
travel(User.sign_in_lockout_duration + 1.second) { sign_in }
|
|
44
|
+
|
|
45
|
+
expect(response).to redirect_to(admin_root_url)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "lets the user in before failures lock signing in" do
|
|
49
|
+
fail_sign_ins(User.sign_in_lockout_after - 1)
|
|
50
|
+
|
|
51
|
+
sign_in
|
|
52
|
+
|
|
53
|
+
expect(response).to redirect_to(admin_root_url)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "starts the count over after a successful sign-in" do
|
|
57
|
+
fail_sign_ins(User.sign_in_lockout_after - 1)
|
|
58
|
+
sign_in
|
|
59
|
+
delete admin_user_session_path
|
|
60
|
+
|
|
61
|
+
fail_sign_ins(1)
|
|
62
|
+
|
|
63
|
+
expect(editor.reload.sign_in_locked?).to be(false)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "emails the user once when it locks, not on the failures that extend it" do
|
|
67
|
+
editor
|
|
68
|
+
|
|
69
|
+
expect { perform_enqueued_jobs { lock_then_extend } }.to change(ActionMailer::Base.deliveries, :size).by(1)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe "per IP address, whatever the account" do
|
|
74
|
+
# The app's test cache is a null store, under which nothing is ever counted
|
|
75
|
+
before { allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) }
|
|
76
|
+
|
|
77
|
+
let(:limit) { Admin::UserSessionsController::FAILED_SIGN_INS_PER_IP }
|
|
78
|
+
|
|
79
|
+
it "refuses any sign-in after too many failures" do
|
|
80
|
+
limit.times { |i| sign_in(users.create(email: "user#{i}@example.com", role: :editor), password: "wrong") }
|
|
81
|
+
|
|
82
|
+
sign_in
|
|
83
|
+
|
|
84
|
+
expect(response).to have_http_status(:too_many_requests)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "lets sign-ins from the address in again after the window" do
|
|
88
|
+
limit.times { |i| sign_in(users.create(email: "user#{i}@example.com", role: :editor), password: "wrong") }
|
|
89
|
+
|
|
90
|
+
travel(Admin::UserSessionsController::FAILED_SIGN_INS_WINDOW + 1.second) { sign_in }
|
|
91
|
+
|
|
92
|
+
expect(response).to redirect_to(admin_root_url)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "Admin sign-in notices", type: :request do
|
|
4
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
5
|
+
|
|
6
|
+
let(:editor) { users.create(role: :editor) }
|
|
7
|
+
let(:firefox) { "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0" }
|
|
8
|
+
|
|
9
|
+
def sign_in(user, user_agent: firefox)
|
|
10
|
+
post admin_user_session_path, params: { email: user.email, password: users.password }, headers: { "User-Agent" => user_agent }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def sign_out
|
|
14
|
+
delete admin_user_session_path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe "the previous sign-in" do
|
|
18
|
+
it "keeps the sign-in notice short" do
|
|
19
|
+
sign_in(editor)
|
|
20
|
+
sign_out
|
|
21
|
+
|
|
22
|
+
sign_in(editor)
|
|
23
|
+
|
|
24
|
+
expect(flash[:notice]).to eq(I18n.t("admin.user_sessions.create.success"))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "speaks the user's language, not the app's default one" do
|
|
28
|
+
other_locale = (I18n.available_locales - [I18n.default_locale]).first
|
|
29
|
+
skip "the app has a single locale" unless other_locale
|
|
30
|
+
user = users.create(role: :editor, locale: other_locale)
|
|
31
|
+
|
|
32
|
+
sign_in(user)
|
|
33
|
+
|
|
34
|
+
expect(flash[:notice]).to eq(I18n.t("admin.user_sessions.create.success", locale: other_locale))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "records the current sign-in as the previous one for the next" do
|
|
38
|
+
sign_in(editor)
|
|
39
|
+
travel 1.day
|
|
40
|
+
sign_in(editor)
|
|
41
|
+
|
|
42
|
+
expect(editor.reload.last_sign_in_at).to be < editor.current_sign_in_at
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "shows the previous sign-in's date, browser and address on the sessions tab" do
|
|
46
|
+
sign_in(editor)
|
|
47
|
+
sign_out
|
|
48
|
+
sign_in(editor)
|
|
49
|
+
|
|
50
|
+
get admin_user_path(editor)
|
|
51
|
+
|
|
52
|
+
expect(response.body).to include("Firefox on Windows").and include("127.0.0.1")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "offers to change the password when the previous sign-in wasn't the user's" do
|
|
57
|
+
sign_in(editor)
|
|
58
|
+
sign_out
|
|
59
|
+
sign_in(editor)
|
|
60
|
+
|
|
61
|
+
get admin_user_path(editor)
|
|
62
|
+
|
|
63
|
+
expect(response.body).to include(new_admin_user_password_reset_path(email: editor.email))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe "the email on a sign-in from a new browser" do
|
|
67
|
+
it "isn't sent on the very first sign-in" do
|
|
68
|
+
expect { sign_in(editor) }.not_to have_enqueued_mail(AdminMailer, :new_sign_in)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "isn't sent from a browser already used to sign in" do
|
|
72
|
+
sign_in(editor)
|
|
73
|
+
sign_out
|
|
74
|
+
|
|
75
|
+
expect { sign_in(editor) }.not_to have_enqueued_mail(AdminMailer, :new_sign_in)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "is sent from a browser never used to sign in" do
|
|
79
|
+
sign_in(editor)
|
|
80
|
+
cookies.delete("trek_known_users")
|
|
81
|
+
|
|
82
|
+
expect { sign_in(editor) }.to have_enqueued_mail(AdminMailer, :new_sign_in)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "isn't sent when the app turns it off" do
|
|
86
|
+
allow(User).to receive(:new_sign_in_email).and_return(false)
|
|
87
|
+
sign_in(editor)
|
|
88
|
+
cookies.delete("trek_known_users")
|
|
89
|
+
|
|
90
|
+
expect { sign_in(editor) }.not_to have_enqueued_mail(AdminMailer, :new_sign_in)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -9,6 +9,12 @@ RSpec.describe "Admin password resets", type: :request do
|
|
|
9
9
|
expect(response).to have_http_status(:ok)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
it "fills the reset request form with the email of the link" do
|
|
13
|
+
get new_admin_user_password_reset_path(email: "ada@example.com")
|
|
14
|
+
|
|
15
|
+
expect(response.body).to include('value="ada@example.com"')
|
|
16
|
+
end
|
|
17
|
+
|
|
12
18
|
it "keeps the reset request actions in the card, out of the sticky action bar" do
|
|
13
19
|
get new_admin_user_password_reset_path
|
|
14
20
|
|
|
@@ -16,19 +22,35 @@ RSpec.describe "Admin password resets", type: :request do
|
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
it "emails a reset link to a privileged user" do
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
perform_enqueued_jobs { post admin_user_password_resets_path, params: { email: editor.email } }
|
|
26
|
+
|
|
27
|
+
expect(ActionMailer::Base.deliveries.last.to).to eq([editor.email])
|
|
21
28
|
end
|
|
22
29
|
|
|
23
30
|
it "sends nothing for an unknown email" do
|
|
24
|
-
expect { post admin_user_password_resets_path, params: { email: "nobody@example.com" } }
|
|
25
|
-
.not_to
|
|
31
|
+
expect { perform_enqueued_jobs { post admin_user_password_resets_path, params: { email: "nobody@example.com" } } }
|
|
32
|
+
.not_to change(ActionMailer::Base.deliveries, :count)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "sends nothing to a user who is not privileged" do
|
|
36
|
+
reader = users.create(role: :reader)
|
|
37
|
+
|
|
38
|
+
expect { perform_enqueued_jobs { post admin_user_password_resets_path, params: { email: reader.email } } }
|
|
39
|
+
.not_to change(ActionMailer::Base.deliveries, :count)
|
|
26
40
|
end
|
|
27
41
|
|
|
28
42
|
it "answers an unknown email as a known one, so it can't be used to find accounts" do
|
|
43
|
+
post admin_user_password_resets_path, params: { email: editor.email }
|
|
44
|
+
known = [response.location, flash[:notice]]
|
|
45
|
+
|
|
29
46
|
post admin_user_password_resets_path, params: { email: "nobody@example.com" }
|
|
30
47
|
|
|
31
|
-
expect(response).to
|
|
48
|
+
expect([response.location, flash[:notice]]).to eq(known)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "queues the same job for an unknown email, so the response time doesn't tell it apart" do
|
|
52
|
+
expect { post admin_user_password_resets_path, params: { email: "nobody@example.com" } }
|
|
53
|
+
.to have_enqueued_job(Admin::PasswordResetJob).with("nobody@example.com")
|
|
32
54
|
end
|
|
33
55
|
|
|
34
56
|
it "renders the new password form from a reset link" do
|
|
@@ -37,6 +59,14 @@ RSpec.describe "Admin password resets", type: :request do
|
|
|
37
59
|
expect(response).to have_http_status(:ok)
|
|
38
60
|
end
|
|
39
61
|
|
|
62
|
+
it "states the password rules on the new password form" do
|
|
63
|
+
get edit_admin_user_password_reset_path(editor.password_reset_token)
|
|
64
|
+
|
|
65
|
+
rules = I18n.t("admin.user_password_resets.edit.rules", min: User.password_min_length, passphrase: User.passphrase_length)
|
|
66
|
+
|
|
67
|
+
expect(response.body).to include(CGI.escapeHTML(rules))
|
|
68
|
+
end
|
|
69
|
+
|
|
40
70
|
it "keeps the new password actions in the card, out of the sticky action bar" do
|
|
41
71
|
get edit_admin_user_password_reset_path(editor.password_reset_token)
|
|
42
72
|
|
|
@@ -65,7 +95,7 @@ RSpec.describe "Admin password resets", type: :request do
|
|
|
65
95
|
post admin_user_session_path, params: { email: editor.email, password: users.password }
|
|
66
96
|
patch admin_user_password_reset_path(editor.password_reset_token), params: { password: "a brand new password" }
|
|
67
97
|
|
|
68
|
-
expect(editor.
|
|
98
|
+
expect(editor.sessions).to be_empty
|
|
69
99
|
end
|
|
70
100
|
|
|
71
101
|
it "sends an empty password back to the form rather than pretending to change it" do
|
|
@@ -87,4 +117,11 @@ RSpec.describe "Admin password resets", type: :request do
|
|
|
87
117
|
|
|
88
118
|
expect(editor.reload.authenticate(users.password)).to be_truthy
|
|
89
119
|
end
|
|
120
|
+
|
|
121
|
+
it "sends a recent password back to the form, saying why" do
|
|
122
|
+
token = editor.password_reset_token
|
|
123
|
+
patch admin_user_password_reset_path(token), params: { password: users.password }
|
|
124
|
+
|
|
125
|
+
expect(flash[:alert]).to include(editor.errors.generate_message(:password, :recently_used, count: User.password_history_size))
|
|
126
|
+
end
|
|
90
127
|
end
|
|
@@ -31,14 +31,37 @@ RSpec.describe "Admin sign-in", type: :request do
|
|
|
31
31
|
it "refuses a wrong password" do
|
|
32
32
|
post admin_user_session_path, params: { email: editor.email, password: "not the password" }
|
|
33
33
|
|
|
34
|
-
expect(response).to
|
|
34
|
+
expect(response).to have_http_status(:unprocessable_entity)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "keeps the email typed after a wrong password" do
|
|
38
|
+
post admin_user_session_path, params: { email: editor.email, password: "not the password" }
|
|
39
|
+
|
|
40
|
+
expect(response.body).to include(%(value="#{editor.email}"))
|
|
35
41
|
end
|
|
36
42
|
|
|
37
43
|
it "refuses a user who is not privileged" do
|
|
38
44
|
reader = users.create(role: :reader)
|
|
39
45
|
post admin_user_session_path, params: { email: reader.email, password: users.password }
|
|
40
46
|
|
|
41
|
-
expect(response).to
|
|
47
|
+
expect(response).to have_http_status(:unprocessable_entity)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "answers an unknown email as a wrong password, so it can't be used to find accounts" do
|
|
51
|
+
post admin_user_session_path, params: { email: editor.email, password: "not the password" }
|
|
52
|
+
wrong_password = [response.status, flash[:alert]]
|
|
53
|
+
|
|
54
|
+
post admin_user_session_path, params: { email: "nobody@example.com", password: "not the password" }
|
|
55
|
+
|
|
56
|
+
expect([response.status, flash[:alert]]).to eq(wrong_password)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "still hashes the password typed with an unknown email, so it takes as long as a wrong password" do
|
|
60
|
+
allow(BCrypt::Password).to receive(:create).and_call_original
|
|
61
|
+
|
|
62
|
+
post admin_user_session_path, params: { email: "nobody@example.com", password: "not the password" }
|
|
63
|
+
|
|
64
|
+
expect(BCrypt::Password).to have_received(:create)
|
|
42
65
|
end
|
|
43
66
|
|
|
44
67
|
it "sends a signed-out visitor to the sign-in form" do
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
= render Trek::HeadingComponent.new( \
|
|
2
|
+
title: model_name.human(count: 2),
|
|
3
|
+
intro: t(".intro"),
|
|
4
|
+
) do |c|
|
|
5
|
+
ruby:
|
|
6
|
+
if scope_params&.dig(:failures).present?
|
|
7
|
+
c.with_button(
|
|
8
|
+
href: [:admin, :audit_logs, filters: filter_params],
|
|
9
|
+
text: t("admin.actions.all"),
|
|
10
|
+
)
|
|
11
|
+
else
|
|
12
|
+
c.with_button(
|
|
13
|
+
href: [
|
|
14
|
+
:admin, :audit_logs,
|
|
15
|
+
filters: filter_params, scopes: { failures: 1 }
|
|
16
|
+
],
|
|
17
|
+
text: t(".failures", count: scope_counts[:failures]),
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
= render Trek::EntriesComponent.new do |entries|
|
|
22
|
+
ruby:
|
|
23
|
+
@objects.each do |log|
|
|
24
|
+
entries.with_item(item: log, action: :show) do |entry|
|
|
25
|
+
entry.with_title(href: [:admin, log]) { log.action }
|
|
26
|
+
entry.with_detail { l(log.created_at, format: :long) }
|
|
27
|
+
entry.with_detail { log.actor.to_s } if log.actor
|
|
28
|
+
entry.with_detail { "#{log.http_method} #{log.http_status}".strip }
|
|
29
|
+
entry.with_detail { log.auditable.to_s } if log.auditable
|
|
30
|
+
entry.with_detail { log.error_class } if log.error_class
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
= render Trek::PaginationComponent.new do
|
|
35
|
+
= paginate @objects
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
= render Trek::HeadingComponent.new( \
|
|
2
|
+
title: @object.to_s,
|
|
3
|
+
intro: t(".intro"),
|
|
4
|
+
) do |c|
|
|
5
|
+
- c.with_button(href: [:admin, :audit_logs], text: t("admin.actions.cancel"))
|
|
6
|
+
|
|
7
|
+
= render Trek::PropertiesComponent.new(model:) do |p|
|
|
8
|
+
= p.with_item :created_at
|
|
9
|
+
= l(@object.created_at, format: :long)
|
|
10
|
+
|
|
11
|
+
- if @object.actor
|
|
12
|
+
= p.with_item :actor
|
|
13
|
+
= audit_log_record_link(@object.actor)
|
|
14
|
+
|
|
15
|
+
= p.with_item :action
|
|
16
|
+
= @object.action
|
|
17
|
+
|
|
18
|
+
= p.with_item :http_method
|
|
19
|
+
code = @object.http_method
|
|
20
|
+
|
|
21
|
+
= p.with_item :path
|
|
22
|
+
code = @object.path
|
|
23
|
+
|
|
24
|
+
- if @object.http_status
|
|
25
|
+
= p.with_item :http_status
|
|
26
|
+
= @object.http_status
|
|
27
|
+
|
|
28
|
+
- if @object.error_class
|
|
29
|
+
= p.with_item :error_class
|
|
30
|
+
code = @object.error_class
|
|
31
|
+
|
|
32
|
+
- if @object.auditable
|
|
33
|
+
= p.with_item :auditable
|
|
34
|
+
= audit_log_record_link(@object.auditable)
|
|
35
|
+
|
|
36
|
+
- if @object.ip_address
|
|
37
|
+
= p.with_item :ip_address
|
|
38
|
+
code = @object.ip_address
|
|
39
|
+
|
|
40
|
+
- if @object.user_agent
|
|
41
|
+
= p.with_item :user_agent
|
|
42
|
+
= @object.user_agent
|
|
43
|
+
|
|
44
|
+
- if @object.request_id
|
|
45
|
+
= p.with_item :request_id
|
|
46
|
+
code = @object.request_id
|
|
47
|
+
|
|
48
|
+
- if @object.params.present?
|
|
49
|
+
= p.with_item :params
|
|
50
|
+
pre = JSON.pretty_generate(@object.params)
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
= render Trek::GateComponent.new
|
|
2
2
|
= form_with url: admin_user_password_reset_path(@token), method: :put do |f|
|
|
3
|
-
|
|
4
|
-
=
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
ruby:
|
|
4
|
+
rules = t(
|
|
5
|
+
".rules",
|
|
6
|
+
min: User.password_min_length,
|
|
7
|
+
passphrase: User.passphrase_length,
|
|
8
|
+
)
|
|
9
|
+
= f.group :password, model_name: User.model_name, hint: rules
|
|
10
|
+
= render Trek::Form::PasswordStrengthComponent.new( \
|
|
11
|
+
min_length: User.password_min_length,
|
|
12
|
+
passphrase_length: User.passphrase_length,
|
|
13
|
+
)
|
|
14
|
+
= f.password_field :password,
|
|
15
|
+
required: true,
|
|
16
|
+
autocomplete: "new-password",
|
|
17
|
+
maxlength: 72,
|
|
18
|
+
minlength: User.password_min_length
|
|
9
19
|
|
|
10
20
|
= render Trek::Form::ActionsComponent.new(aspect: :seamless) do |c|
|
|
11
21
|
ruby:
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
= render Trek::GateComponent.new
|
|
2
2
|
= form_with url: admin_user_session_path do |f|
|
|
3
3
|
= f.group :email, model_name: User.model_name
|
|
4
|
+
/ "webauthn" offers the site's passkeys in the field's autofill
|
|
4
5
|
= f.email_field :email,
|
|
5
6
|
required: true,
|
|
6
7
|
autofocus: @email.nil?,
|
|
7
|
-
autocomplete: "username",
|
|
8
|
+
autocomplete: "username webauthn",
|
|
8
9
|
value: @email
|
|
9
10
|
|
|
10
11
|
= f.group :password, model_name: User.model_name
|
|
@@ -27,3 +28,28 @@
|
|
|
27
28
|
href: %i[new admin user_password_reset],
|
|
28
29
|
text: t(".forgot_password"),
|
|
29
30
|
)
|
|
31
|
+
|
|
32
|
+
/ Shown by the passkey controller, in browsers that support passkeys
|
|
33
|
+
ruby:
|
|
34
|
+
passkey_form = {
|
|
35
|
+
url: admin_passkey_session_path,
|
|
36
|
+
hidden: true,
|
|
37
|
+
data: {
|
|
38
|
+
controller: "passkey",
|
|
39
|
+
passkey_options_url_value: options_admin_passkey_session_path,
|
|
40
|
+
passkey_conditional_value: true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
= form_with(**passkey_form) do
|
|
44
|
+
= hidden_field_tag :credential, nil, data: { passkey_target: "credential" }
|
|
45
|
+
div(data-passkey-target="error" hidden)
|
|
46
|
+
= render Trek::Form::ErrorsComponent.new(errors: [t(".passkey_failure")])
|
|
47
|
+
|
|
48
|
+
= render Trek::Form::ActionsComponent.new(aspect: :seamless) do |c|
|
|
49
|
+
ruby:
|
|
50
|
+
c.with_button(
|
|
51
|
+
text: t(".passkey"),
|
|
52
|
+
data: { action: "passkey#get" },
|
|
53
|
+
) do |b|
|
|
54
|
+
b.with_icon("trek/passkey")
|
|
55
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/ Shown by the passkey controller, in browsers that support passkeys: in the
|
|
2
|
+
/ section while it's empty, below the list, in an actions bar, once it isn't.
|
|
3
|
+
/ Submitting it creates the passkey on the device, then posts it.
|
|
4
|
+
ruby:
|
|
5
|
+
passkey_form = {
|
|
6
|
+
url: admin_passkeys_path,
|
|
7
|
+
hidden: true,
|
|
8
|
+
data: {
|
|
9
|
+
controller: "passkey",
|
|
10
|
+
passkey_options_url_value: options_admin_passkeys_path,
|
|
11
|
+
action: "submit->passkey#submit"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
aspect = passkeys.any? ? :default : :seamless
|
|
15
|
+
- add_passkey = capture do
|
|
16
|
+
= form_with(**passkey_form) do
|
|
17
|
+
= hidden_field_tag :credential, nil, data: { passkey_target: "credential" }
|
|
18
|
+
div(data-passkey-target="error" hidden)
|
|
19
|
+
= render Trek::Form::ErrorsComponent.new(errors: [t(".failure")])
|
|
20
|
+
div(data-passkey-target="alreadyAdded" hidden)
|
|
21
|
+
= render Trek::Form::ErrorsComponent.new(errors: [t(".already_added")])
|
|
22
|
+
|
|
23
|
+
= render Trek::Form::ActionsComponent.new(aspect:) do |c|
|
|
24
|
+
ruby:
|
|
25
|
+
c.with_button(type: "submit", text: t(".add")) do |b|
|
|
26
|
+
b.with_icon("trek/passkey")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
= render Trek::SectionComponent.new(title: t(".title"), hint: t(".intro"))
|
|
30
|
+
- if passkeys.any?
|
|
31
|
+
= render Trek::EntriesComponent.new do |entries|
|
|
32
|
+
ruby:
|
|
33
|
+
passkeys.each do |passkey|
|
|
34
|
+
entries.with_item(item: passkey) do |entry|
|
|
35
|
+
entry.with_title(href: nil) { passkey.to_s }
|
|
36
|
+
entry.with_detail do
|
|
37
|
+
t(".added", date: l(passkey.created_at, format: :long))
|
|
38
|
+
end
|
|
39
|
+
entry.with_detail do
|
|
40
|
+
if passkey.last_used_at
|
|
41
|
+
time = time_ago_in_words(passkey.last_used_at)
|
|
42
|
+
t(".last_used", time:)
|
|
43
|
+
else
|
|
44
|
+
t(".never_used")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
entry.with_status do
|
|
48
|
+
render Trek::ButtonComponent.new(
|
|
49
|
+
href: admin_passkey_path(passkey),
|
|
50
|
+
text: t(".delete"),
|
|
51
|
+
size: :narrow,
|
|
52
|
+
color: :tomato,
|
|
53
|
+
data: {
|
|
54
|
+
turbo_method: :delete,
|
|
55
|
+
turbo_confirm: t(".delete_confirm"),
|
|
56
|
+
turbo_confirm_button: t(".delete"),
|
|
57
|
+
turbo_confirm_color: "tomato"
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
- else
|
|
64
|
+
= add_passkey
|
|
65
|
+
|
|
66
|
+
- if passkeys.any?
|
|
67
|
+
= add_passkey
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
= render Trek::PropertiesComponent.new(model:) do |p|
|
|
2
|
+
- if @object.name?
|
|
3
|
+
= p.with_item :name
|
|
4
|
+
= @object.name
|
|
5
|
+
|
|
6
|
+
= p.with_item :role
|
|
7
|
+
= t("user.roles.#{@object.role}")
|
|
8
|
+
|
|
9
|
+
= p.with_item :email
|
|
10
|
+
= mail_to @object.email
|
|
11
|
+
|
|
12
|
+
= p.with_item :created_at
|
|
13
|
+
= l(@object.created_at, default: "")
|
|
14
|
+
|
|
15
|
+
- if policy_permitted_attribute?(:locale) && @object.locale?
|
|
16
|
+
= p.with_item :locale
|
|
17
|
+
= t("user.locale.#{@object.locale}")
|
|
18
|
+
|
|
19
|
+
- if policy_permitted_attribute?(:theme) && @object.theme?
|
|
20
|
+
= p.with_item :theme
|
|
21
|
+
= t("user.theme.#{@object.theme}")
|