trek 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +55 -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/concerns/trek/scopes.rb +9 -2
- 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/controllers.md +1 -1
- 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 +55 -6
- data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +31 -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 +17 -7
- data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +3 -2
- data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +28 -2
- 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,49 @@
|
|
|
1
|
+
# The Content Security Policy: what pages may load and run, so a script injected into a page (an XSS)
|
|
2
|
+
# can't run, nor send data elsewhere. See
|
|
3
|
+
# https://guides.rubyonrails.org/security.html#content-security-policy-header
|
|
4
|
+
#
|
|
5
|
+
# It only reports violations for now (`content_security_policy_report_only`): check the browser's
|
|
6
|
+
# console, and the violations reported to AppSignal (the "csp" namespace) or the log, on every page,
|
|
7
|
+
# then set it to false to enforce it.
|
|
8
|
+
#
|
|
9
|
+
# Add the hosts the front office uses, e.g. for analytics `policy.script_src :self, "https://plausible.io"`
|
|
10
|
+
# and `policy.connect_src :self, "https://plausible.io"`, or for videos `policy.frame_src "https://www.youtube-nocookie.com"`.
|
|
11
|
+
Rails.application.configure do
|
|
12
|
+
# Where uploaded files are served from: the store's public URL, and the bucket's endpoint for files
|
|
13
|
+
# just uploaded (config/initializers/shrine.rb). Origins only, as CSP sources.
|
|
14
|
+
upload_hosts = ENV.values_at("S3_BASE_URL", "S3_ENDPOINT").compact_blank.map do |url|
|
|
15
|
+
URI(url).tap { |uri| uri.path = "" }.to_s
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
config.content_security_policy do |policy|
|
|
19
|
+
policy.default_src :self
|
|
20
|
+
policy.base_uri :self
|
|
21
|
+
policy.form_action :self
|
|
22
|
+
# Framed by its own pages only (Lookbook's previews), against clickjacking
|
|
23
|
+
policy.frame_ancestors :self
|
|
24
|
+
policy.object_src :none
|
|
25
|
+
# No inline script: the app's own files, and the scripts given the request's nonce
|
|
26
|
+
policy.script_src :self
|
|
27
|
+
# Inline `style` attributes stay allowed: Trek's components and Backpack's set some (colors,
|
|
28
|
+
# custom properties)
|
|
29
|
+
policy.style_src :self, :unsafe_inline
|
|
30
|
+
policy.font_src :self, :data
|
|
31
|
+
# For fonts from Google Fonts (the admin's Inter is served by the app):
|
|
32
|
+
# policy.style_src :self, :unsafe_inline, "https://fonts.googleapis.com"
|
|
33
|
+
# policy.font_src :self, :data, "https://fonts.gstatic.com"
|
|
34
|
+
policy.img_src :self, :data, :blob, *upload_hosts
|
|
35
|
+
policy.media_src :self, :blob, *upload_hosts
|
|
36
|
+
policy.connect_src :self
|
|
37
|
+
# Trek's endpoint logs the violations, and reports them to AppSignal when the app uses it
|
|
38
|
+
policy.report_uri ENV.fetch("CSP_REPORT_URI", "/csp-reports")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A nonce for the scripts written in a page (`javascript_tag nonce: true`), per session rather than
|
|
42
|
+
# per request: Turbo renders the next pages under the first one's policy, but with their nonce. A
|
|
43
|
+
# visitor without a session yet gets a random one, as an empty nonce is invalid: prefer script files
|
|
44
|
+
# to inline scripts on pages Turbo visits without a session.
|
|
45
|
+
config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s.presence || SecureRandom.base64(16) }
|
|
46
|
+
config.content_security_policy_nonce_directives = %w[script-src]
|
|
47
|
+
|
|
48
|
+
config.content_security_policy_report_only = true
|
|
49
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Turns off the browser features the app doesn't use (the Permissions-Policy header), so a script
|
|
2
|
+
# injected into a page can't use them either. Allow one the front office needs, e.g. `geolocation=(self)`
|
|
3
|
+
# for a map.
|
|
4
|
+
#
|
|
5
|
+
# Not Rails' `config.permissions_policy`, which still sends the former Feature-Policy header.
|
|
6
|
+
Rails.application.config.action_dispatch.default_headers["Permissions-Policy"] = %w[
|
|
7
|
+
accelerometer=()
|
|
8
|
+
camera=()
|
|
9
|
+
display-capture=()
|
|
10
|
+
geolocation=()
|
|
11
|
+
gyroscope=()
|
|
12
|
+
hid=()
|
|
13
|
+
magnetometer=()
|
|
14
|
+
microphone=()
|
|
15
|
+
midi=()
|
|
16
|
+
payment=()
|
|
17
|
+
serial=()
|
|
18
|
+
usb=()
|
|
19
|
+
].join(", ")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Emails a password reset link to a privileged user, and nothing for any other email. The reset
|
|
2
|
+
# request form queues it whatever the email, so the response takes as long whether an account
|
|
3
|
+
# exists or not.
|
|
4
|
+
class Admin::PasswordResetJob < ApplicationJob
|
|
5
|
+
def perform(email)
|
|
6
|
+
user = User.privileged.find_by(email:)
|
|
7
|
+
Admin::PasswordsMailer.reset(user).deliver_now if user
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -12,11 +12,6 @@ html(lang=I18n.locale class=class_for_theme)
|
|
|
12
12
|
link(rel="manifest" href="/site.webmanifest")
|
|
13
13
|
= csrf_meta_tags
|
|
14
14
|
= csp_meta_tag
|
|
15
|
-
link(rel="preconnect" href="https://fonts.googleapis.com")
|
|
16
|
-
link(rel="preconnect" href="https://fonts.gstatic.com" crossorigin)
|
|
17
|
-
/ slim-lint:disable LineLength
|
|
18
|
-
link(href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet")
|
|
19
|
-
/ slim-lint:enable LineLength
|
|
20
15
|
= stylesheet_link_tag "admin",
|
|
21
16
|
media: "all",
|
|
22
17
|
'data-turbo-track': "reload"
|
|
@@ -12,9 +12,6 @@ html(lang=I18n.locale class=class_for_theme)
|
|
|
12
12
|
link(rel="manifest" href="/site.webmanifest")
|
|
13
13
|
= csrf_meta_tags
|
|
14
14
|
= csp_meta_tag
|
|
15
|
-
link(rel="preconnect" href="https://fonts.googleapis.com")
|
|
16
|
-
link(rel="preconnect" href="https://fonts.gstatic.com" crossorigin)
|
|
17
|
-
= admin_webfonts_link_tag
|
|
18
15
|
= stylesheet_link_tag "admin",
|
|
19
16
|
media: "all",
|
|
20
17
|
"data-turbo-track": "reload"
|
|
@@ -4,9 +4,9 @@ class Admin::PasswordsMailer < ApplicationMailer
|
|
|
4
4
|
def reset(user)
|
|
5
5
|
@user = user
|
|
6
6
|
@url = edit_admin_user_password_reset_url(@user.generate_token_for(:password_reset))
|
|
7
|
-
@expiration_delay = distance_of_time_in_words(0, User.token_definitions[:password_reset].expires_in)
|
|
8
7
|
|
|
9
8
|
I18n.with_locale(@user.locale || I18n.default_locale) do
|
|
9
|
+
@expiration_delay = distance_of_time_in_words(0, User.token_definitions[:password_reset].expires_in)
|
|
10
10
|
mail to: @user.email
|
|
11
11
|
end
|
|
12
12
|
end
|
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
class AdminMailer < ApplicationMailer
|
|
2
2
|
include ActionView::Helpers::DateHelper
|
|
3
3
|
|
|
4
|
+
helper Trek::UserAgentsHelper
|
|
5
|
+
|
|
4
6
|
def invite(user)
|
|
5
7
|
@user = user
|
|
6
8
|
@url = edit_admin_user_password_reset_url(@user.generate_token_for(:invite))
|
|
7
|
-
|
|
9
|
+
|
|
10
|
+
I18n.with_locale(@user.locale || I18n.default_locale) do
|
|
11
|
+
@expiration_delay = distance_of_time_in_words(0, User.token_definitions[:invite].expires_in)
|
|
12
|
+
mail to: @user.email
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A sign-in from a browser that never signed in as the user, so they can react if it wasn't them
|
|
17
|
+
def new_sign_in(user, at:, ip:, user_agent:)
|
|
18
|
+
@user = user
|
|
19
|
+
@at = at
|
|
20
|
+
@ip = ip
|
|
21
|
+
@user_agent = user_agent
|
|
22
|
+
@sessions_url = admin_user_url(user, anchor: "sessions")
|
|
23
|
+
@reset_url = new_admin_user_password_reset_url
|
|
24
|
+
|
|
25
|
+
I18n.with_locale(@user.locale || I18n.default_locale) do
|
|
26
|
+
mail to: @user.email
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Signing in is locked after failed sign-ins in a row (Trek::Users::SignInLockout): the user waits,
|
|
31
|
+
# or resets their password if they forgot it, or learns someone is guessing it.
|
|
32
|
+
def sign_in_locked(user)
|
|
33
|
+
@user = user
|
|
34
|
+
@reset_url = new_admin_user_password_reset_url
|
|
8
35
|
|
|
9
36
|
I18n.with_locale(@user.locale || I18n.default_locale) do
|
|
10
37
|
mail to: @user.email
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class TrekAuditLogs < <%= migration_class_name %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :audit_logs do |t|
|
|
4
|
+
# Who made the request: nil when nobody is signed in. index: false, see the index below.
|
|
5
|
+
t.references :actor, polymorphic: true, index: false
|
|
6
|
+
# The record the request worked on, when there is one.
|
|
7
|
+
t.references :auditable, polymorphic: true
|
|
8
|
+
|
|
9
|
+
t.string :action, null: false
|
|
10
|
+
t.string :http_method, null: false
|
|
11
|
+
t.string :path, null: false
|
|
12
|
+
t.integer :http_status
|
|
13
|
+
t.jsonb :params, null: false, default: {}
|
|
14
|
+
t.string :ip_address
|
|
15
|
+
t.string :user_agent
|
|
16
|
+
t.string :request_id
|
|
17
|
+
t.string :error_class
|
|
18
|
+
|
|
19
|
+
# Rows are never updated: no updated_at.
|
|
20
|
+
t.datetime :created_at, null: false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
add_index :audit_logs, [:actor_type, :actor_id, :created_at]
|
|
24
|
+
add_index :audit_logs, :created_at
|
|
25
|
+
add_index :audit_logs, :request_id
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
class TrekTags < <%= migration_class_name %>
|
|
2
2
|
def change
|
|
3
3
|
create_table :tags do |t|
|
|
4
|
-
t.string :key
|
|
4
|
+
t.string :key
|
|
5
5
|
t.string :name
|
|
6
6
|
t.integer :position, null: false
|
|
7
7
|
t.references :category, foreign_key: { to_table: :tag_categories }
|
|
8
8
|
t.integer :taggings_count, null: false, default: 0
|
|
9
9
|
|
|
10
10
|
t.timestamps
|
|
11
|
+
|
|
12
|
+
# A key is unique within its category, as Tag validates it
|
|
13
|
+
t.index %i[category_id key], unique: true
|
|
14
|
+
# and among the tags without one, which the index above lets through: NULLs never collide
|
|
15
|
+
t.index :key, unique: true, where: "category_id IS NULL", name: "index_tags_on_key_without_category"
|
|
11
16
|
end
|
|
12
17
|
end
|
|
13
18
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class TrekUserPasskeys < <%= migration_class_name %>
|
|
2
|
+
def change
|
|
3
|
+
# The random id a user's passkeys are created for (Trek::Users::Passkeys), set on the first one
|
|
4
|
+
add_column :users, :webauthn_id, :string
|
|
5
|
+
add_index :users, :webauthn_id, unique: true
|
|
6
|
+
|
|
7
|
+
create_table :user_passkeys do |t|
|
|
8
|
+
t.belongs_to :user, null: false, foreign_key: true
|
|
9
|
+
# The browser it was added from, shown when Trek doesn't know its provider (aaguid)
|
|
10
|
+
t.string :name, null: false
|
|
11
|
+
# The credential's id and public key, as the browser sent them
|
|
12
|
+
t.string :external_id, null: false, index: { unique: true }
|
|
13
|
+
t.text :public_key, null: false
|
|
14
|
+
t.bigint :sign_count, null: false, default: 0
|
|
15
|
+
# How the browser reaches the authenticator ("internal", "hybrid", "usb"…)
|
|
16
|
+
t.string :transports, array: true, null: false, default: []
|
|
17
|
+
# The authenticator's model, e.g. a password manager (Trek::PasskeyProviders), when it says
|
|
18
|
+
t.string :aaguid
|
|
19
|
+
t.datetime :last_used_at
|
|
20
|
+
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class TrekUserPasswordRenewal < <%= migration_class_name %>
|
|
2
|
+
def change
|
|
3
|
+
# When the password was last set, for its expiry (Trek::Users::PasswordRenewal)
|
|
4
|
+
add_column :users, :password_changed_at, :datetime
|
|
5
|
+
# When an admin asked everyone for a new password, after a suspected compromise
|
|
6
|
+
add_column :users, :password_renewal_required_at, :datetime
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class TrekUserPreviousPasswords < <%= migration_class_name %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :user_previous_passwords do |t|
|
|
4
|
+
t.belongs_to :user, null: false, foreign_key: true
|
|
5
|
+
t.string :password_digest, null: false
|
|
6
|
+
|
|
7
|
+
t.datetime :created_at, null: false
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class TrekUserSessionTracking < <%= migration_class_name %>
|
|
2
|
+
def change
|
|
3
|
+
# The session's last request, for its idle timeout (Trek::Users::SessionLifetime)
|
|
4
|
+
add_column :user_sessions, :last_activity_at, :datetime
|
|
5
|
+
up_only { execute "UPDATE user_sessions SET last_activity_at = updated_at" }
|
|
6
|
+
change_column_null :user_sessions, :last_activity_at, false
|
|
7
|
+
|
|
8
|
+
# The host name of the IP address it signed in from (Trek::ReverseDns)
|
|
9
|
+
add_column :user_sessions, :ip_hostname, :string
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class TrekUserSignInLockout < <%= migration_class_name %>
|
|
2
|
+
def change
|
|
3
|
+
# Failed sign-ins in a row, and until when they lock signing in (Trek::Users::SignInLockout)
|
|
4
|
+
add_column :users, :failed_sign_in_count, :integer, default: 0, null: false
|
|
5
|
+
add_column :users, :sign_in_locked_until, :datetime
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class TrekUserSignIns < <%= migration_class_name %>
|
|
2
|
+
def change
|
|
3
|
+
# The current and the previous sign-ins (Trek::Users::SignIns), kept when their sessions end
|
|
4
|
+
change_table :users, bulk: true do |t|
|
|
5
|
+
t.datetime :current_sign_in_at
|
|
6
|
+
t.string :current_sign_in_ip
|
|
7
|
+
t.string :current_sign_in_user_agent
|
|
8
|
+
t.datetime :last_sign_in_at
|
|
9
|
+
t.string :last_sign_in_ip
|
|
10
|
+
t.string :last_sign_in_user_agent
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
class TagCategory < ApplicationRecord
|
|
2
|
+
# Not deleted while it has tags: left without a category, their keys could collide with others'
|
|
2
3
|
has_many :tags,
|
|
3
4
|
-> { ordered },
|
|
4
|
-
dependent: :
|
|
5
|
+
dependent: :restrict_with_error,
|
|
5
6
|
foreign_key: :category_id,
|
|
6
7
|
inverse_of: :category
|
|
7
8
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# A passkey the user signs in with instead of their password (Trek::Users::Passkeys): its private
|
|
2
|
+
# key stays on their device or in their password manager, Trek keeps the public one.
|
|
3
|
+
class User::Passkey < ApplicationRecord
|
|
4
|
+
belongs_to :user
|
|
5
|
+
|
|
6
|
+
validates :name, presence: true, length: { maximum: 100 }
|
|
7
|
+
|
|
8
|
+
# Its provider, or else the browser it was added from (`name`)
|
|
9
|
+
def to_s
|
|
10
|
+
provider || name
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The password manager, platform or security key keeping it, when Trek knows it
|
|
14
|
+
def provider
|
|
15
|
+
Trek::PasskeyProviders[aaguid]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# A browser signed in as the user, found through a signed session_id cookie.
|
|
2
|
+
class User::Session < ApplicationRecord
|
|
3
|
+
# Expires after 48 hours without a request, and 30 days after signing in; set idle_timeout and
|
|
4
|
+
# lifetime to change it.
|
|
5
|
+
include Trek::Users::SessionLifetime
|
|
6
|
+
|
|
7
|
+
belongs_to :user
|
|
8
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe Admin::PasswordsMailer do
|
|
4
|
+
include ActionView::Helpers::DateHelper
|
|
5
|
+
|
|
6
|
+
# Not the app's default locale, so the email can't be right by chance
|
|
7
|
+
let(:locale) { (I18n.available_locales - [I18n.default_locale]).first }
|
|
8
|
+
|
|
9
|
+
it "formats the reset link's expiry in the recipient's locale" do
|
|
10
|
+
skip "the app has a single locale" unless locale
|
|
11
|
+
user = users.create(role: :editor, locale: locale)
|
|
12
|
+
expiry = I18n.with_locale(locale) { distance_of_time_in_words(0, User.token_definitions[:password_reset].expires_in) }
|
|
13
|
+
|
|
14
|
+
expect(described_class.reset(user).text_part.body.to_s).to include(expiry)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe AdminMailer do
|
|
4
|
+
include ActionView::Helpers::DateHelper
|
|
5
|
+
|
|
6
|
+
# Not the app's default locale, so the email can't be right by chance
|
|
7
|
+
let(:locale) { (I18n.available_locales - [I18n.default_locale]).first }
|
|
8
|
+
|
|
9
|
+
it "formats the invite link's expiry in the recipient's locale" do
|
|
10
|
+
skip "the app has a single locale" unless locale
|
|
11
|
+
user = users.create(role: :editor, locale: locale)
|
|
12
|
+
expiry = I18n.with_locale(locale) { distance_of_time_in_words(0, User.token_definitions[:invite].expires_in) }
|
|
13
|
+
|
|
14
|
+
expect(described_class.invite(user).text_part.body.to_s).to include(expiry)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe "the new sign-in email" do
|
|
18
|
+
let(:user) { users.create(role: :editor) }
|
|
19
|
+
let(:mail) do
|
|
20
|
+
described_class.new_sign_in(
|
|
21
|
+
user,
|
|
22
|
+
at: Time.zone.parse("2026-10-13 08:30"),
|
|
23
|
+
ip: "203.0.113.7",
|
|
24
|
+
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0"
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "tells the user which browser and address signed in" do
|
|
29
|
+
expect(mail.text_part.body.to_s).to include("Firefox on Windows").and include("203.0.113.7")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "links to the active sessions and to a password reset" do
|
|
33
|
+
expect(mail.html_part.body.to_s).to include("#sessions").and include("user_password_resets/new")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "tells about a sign-in lock in the recipient's locale" do
|
|
38
|
+
skip "the app has a single locale" unless locale
|
|
39
|
+
user = users.create(role: :editor, locale: locale)
|
|
40
|
+
subject = I18n.with_locale(locale) { I18n.t("admin_mailer.sign_in_locked.subject") }
|
|
41
|
+
|
|
42
|
+
expect(described_class.sign_in_locked(user).subject).to eq(subject)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "links a sign-in lock to a password reset" do
|
|
46
|
+
user = users.create(role: :editor)
|
|
47
|
+
|
|
48
|
+
expect(described_class.sign_in_locked(user).text_part.body.to_s).to include("/admin/user_password_resets/new")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe AuditLog, type: :model do
|
|
4
|
+
it "lists the failures: an error status or a raised error" do
|
|
5
|
+
server_error = create_log(http_status: 500)
|
|
6
|
+
raised = create_log(error_class: "ActiveRecord::RecordNotFound")
|
|
7
|
+
create_log(http_status: 302)
|
|
8
|
+
|
|
9
|
+
expect(described_class.failures).to contain_exactly(server_error, raised)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "orders the most recent rows first" do
|
|
13
|
+
old = create_log(created_at: 2.days.ago)
|
|
14
|
+
recent = create_log(created_at: 1.day.ago)
|
|
15
|
+
|
|
16
|
+
expect(described_class.ordered).to eq([recent, old])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "can't be changed once written" do
|
|
20
|
+
log = create_log
|
|
21
|
+
|
|
22
|
+
expect { log.update!(http_status: 200) }.to raise_error(ActiveRecord::ReadOnlyRecord)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "reads as its date, action and status" do
|
|
26
|
+
log = create_log(created_at: Time.zone.parse("2026-10-12 10:00"), http_status: 302)
|
|
27
|
+
|
|
28
|
+
expect(log.to_s).to eq("#{log.created_at.iso8601} – admin/pages#update – 302")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "purges the rows older than the retention window" do
|
|
32
|
+
create_log(created_at: (Trek::AuditLog::PurgeJob.retention + 1.day).ago)
|
|
33
|
+
recent = create_log(created_at: (Trek::AuditLog::PurgeJob.retention - 1.day).ago)
|
|
34
|
+
|
|
35
|
+
Trek::AuditLog::PurgeJob.perform_now
|
|
36
|
+
|
|
37
|
+
expect(described_class.all).to contain_exactly(recent)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def create_log(**attributes)
|
|
41
|
+
described_class.create!(action: "admin/pages#update", http_method: "PATCH", path: "/admin/pages/1", **attributes)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe Tag do
|
|
4
|
+
let(:colors) { TagCategory.create!(name: "Colors", key: "colors") }
|
|
5
|
+
let(:fruits) { TagCategory.create!(name: "Fruits", key: "fruits") }
|
|
6
|
+
|
|
7
|
+
describe "key" do
|
|
8
|
+
it "may be the same in two categories" do
|
|
9
|
+
described_class.create!(name: "Orange", key: "orange", category: colors)
|
|
10
|
+
|
|
11
|
+
expect(described_class.create(name: "Orange", key: "orange", category: fruits)).to be_persisted
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "is unique within a category" do
|
|
15
|
+
described_class.create!(name: "Orange", key: "orange", category: colors)
|
|
16
|
+
|
|
17
|
+
expect(described_class.new(name: "Orange", key: "orange", category: colors).tap(&:validate).errors.details[:key]).to include(error: :taken, value: "orange")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "is unique among the tags without a category" do
|
|
21
|
+
described_class.create!(name: "Orange", key: "orange")
|
|
22
|
+
|
|
23
|
+
expect(described_class.new(name: "Orange", key: "orange").tap(&:validate).errors.details[:key]).to include(error: :taken, value: "orange")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The database holds the rule too, against two saves at once
|
|
27
|
+
it "is unique within a category in the database" do
|
|
28
|
+
described_class.create!(name: "Orange", key: "orange", category: colors)
|
|
29
|
+
|
|
30
|
+
expect { described_class.new(name: "Orange", key: "orange", category: colors).save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "is unique among the tags without a category in the database" do
|
|
34
|
+
described_class.create!(name: "Orange", key: "orange")
|
|
35
|
+
|
|
36
|
+
expect { described_class.new(name: "Orange", key: "orange").save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe "category" do
|
|
41
|
+
# Its tags would be left without a category, where their keys could collide
|
|
42
|
+
it "can't be deleted while it has tags" do
|
|
43
|
+
described_class.create!(name: "Orange", key: "orange", category: colors)
|
|
44
|
+
|
|
45
|
+
expect(colors.destroy).to be(false)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "can be deleted once empty" do
|
|
49
|
+
expect(colors.destroy).to be_destroyed
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe User::Session do
|
|
4
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
5
|
+
|
|
6
|
+
let(:user) { users.create }
|
|
7
|
+
let(:session) { user.sessions.create! }
|
|
8
|
+
|
|
9
|
+
def come_back_after(duration)
|
|
10
|
+
travel duration
|
|
11
|
+
session.record_activity
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "is active right after signing in" do
|
|
15
|
+
expect(described_class.active).to include(session)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "expires after idle_timeout without a request" do
|
|
19
|
+
session
|
|
20
|
+
travel described_class.idle_timeout + 1.minute
|
|
21
|
+
|
|
22
|
+
expect(session.reload).to be_expired
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "stays active while requests come in, until its lifetime" do
|
|
26
|
+
session
|
|
27
|
+
5.times { come_back_after(described_class.idle_timeout - 1.hour) }
|
|
28
|
+
|
|
29
|
+
expect(session.reload).not_to be_expired
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "expires after its lifetime, even when in use" do
|
|
33
|
+
session
|
|
34
|
+
travel_to (described_class.lifetime + 1.day).from_now do
|
|
35
|
+
session.record_activity
|
|
36
|
+
|
|
37
|
+
expect(session).to be_expired
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "records a request without touching updated_at" do
|
|
42
|
+
session
|
|
43
|
+
travel 1.hour
|
|
44
|
+
|
|
45
|
+
expect { session.record_activity }.not_to(change { session.reload.updated_at })
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "records a request at most once every activity_interval" do
|
|
49
|
+
session
|
|
50
|
+
travel 1.minute
|
|
51
|
+
|
|
52
|
+
expect { session.record_activity }.not_to(change { session.reload.last_activity_at })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "purges the expired sessions" do
|
|
56
|
+
old = user.sessions.create!(created_at: 1.year.ago, last_activity_at: 1.year.ago)
|
|
57
|
+
|
|
58
|
+
Trek::PurgeExpiredSessionsJob.perform_now
|
|
59
|
+
|
|
60
|
+
expect(described_class.where(id: [old.id, session.id]).pluck(:id)).to eq([session.id])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "lists the expired sessions, for the purge" do
|
|
64
|
+
old = user.sessions.create!(created_at: 1.year.ago, last_activity_at: 1.year.ago)
|
|
65
|
+
|
|
66
|
+
expect(described_class.expired).to contain_exactly(old)
|
|
67
|
+
end
|
|
68
|
+
end
|