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,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
module Users
|
|
5
|
+
# Password rules from the ANSSI's guide on passwords (v2.0, 2021, co-signed by the CNIL), at the
|
|
6
|
+
# "medium to high" level a back office calls for. Checked when a password is assigned.
|
|
7
|
+
#
|
|
8
|
+
# - At least `password_min_length` characters (R21), 12 by default.
|
|
9
|
+
# - Below `passphrase_length` characters (20 by default), a digit and a symbol (R23); a longer
|
|
10
|
+
# passphrase needs neither.
|
|
11
|
+
# - Not a common password, nor a keyboard, alphabet or number sequence, nor the user's name or
|
|
12
|
+
# email (R27).
|
|
13
|
+
# - With `password_check_pwned = true`, not in a known data breach (Have I Been Pwned). Off by
|
|
14
|
+
# default: the ANSSI warns against sending password data to an outside service, even hashed.
|
|
15
|
+
#
|
|
16
|
+
# The maximum is bcrypt's 72 bytes, which has_secure_password enforces.
|
|
17
|
+
module PasswordPolicy
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
COMMON_PASSWORDS_PATH = Trek::Engine.root.join("lib/trek/data/common_passwords.txt")
|
|
21
|
+
|
|
22
|
+
# Keyboard rows (AZERTY and QWERTY), the alphabet and the digits: 5 characters in a row of one
|
|
23
|
+
# of them, either way, make a sequence.
|
|
24
|
+
SEQUENCES = %w[azertyuiop qsdfghjklm wxcvbn qwertyuiop asdfghjkl zxcvbnm abcdefghijklmnopqrstuvwxyz 01234567890].freeze
|
|
25
|
+
SEQUENCE_LENGTH = 5
|
|
26
|
+
|
|
27
|
+
# Names and email parts shorter than this are too likely to show up by chance.
|
|
28
|
+
PERSONAL_MIN_LENGTH = 4
|
|
29
|
+
|
|
30
|
+
included do
|
|
31
|
+
class_attribute :password_min_length, default: 12
|
|
32
|
+
class_attribute :passphrase_length, default: 20
|
|
33
|
+
class_attribute :password_check_pwned, default: false
|
|
34
|
+
|
|
35
|
+
validates :password, length: { minimum: ->(user) { user.password_min_length } }, allow_nil: true
|
|
36
|
+
validate :password_follows_policy, if: -> { password.present? }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class_methods do
|
|
40
|
+
def common_passwords
|
|
41
|
+
@common_passwords ||= File.foreach(COMMON_PASSWORDS_PATH, chomp: true).reject { |line| line.start_with?("#") }.to_set
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Lower case, without accents, letters and digits only: how passwords are compared to the lists.
|
|
46
|
+
def self.normalize(string)
|
|
47
|
+
string.unicode_normalize(:nfkd).gsub(/\p{Mn}/, "").downcase.gsub(/[^a-z0-9]/, "")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.sequences
|
|
51
|
+
@sequences ||= (SEQUENCES + SEQUENCES.map(&:reverse)).flat_map do |row|
|
|
52
|
+
row.chars.each_cons(SEQUENCE_LENGTH).map(&:join)
|
|
53
|
+
end.to_set
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def password_follows_policy
|
|
59
|
+
normalized = PasswordPolicy.normalize(password)
|
|
60
|
+
|
|
61
|
+
errors.add(:password, :digit_and_symbol, count: passphrase_length) unless passphrase_or_complex?
|
|
62
|
+
errors.add(:password, :common) if self.class.common_passwords.include?(normalized)
|
|
63
|
+
errors.add(:password, :sequence) if sequence?(normalized)
|
|
64
|
+
errors.add(:password, :personal) if personal?(normalized)
|
|
65
|
+
errors.add(:password, :pwned) if password_check_pwned && Trek::PwnedPasswords.include?(password)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def passphrase_or_complex?
|
|
69
|
+
password.length >= passphrase_length || (password.match?(/\d/) && password.match?(/[^[:alnum:][:space:]]/))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sequence?(normalized)
|
|
73
|
+
normalized.match?(/(.)\1{#{SEQUENCE_LENGTH - 1}}/) ||
|
|
74
|
+
normalized.chars.each_cons(SEQUENCE_LENGTH).any? { |chars| PasswordPolicy.sequences.include?(chars.join) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def personal?(normalized)
|
|
78
|
+
parts = [try(:name), email.to_s.split("@").first].compact.flat_map { |value| value.split(/[^[:alnum:]]+/) }
|
|
79
|
+
parts.map { |part| PasswordPolicy.normalize(part) }
|
|
80
|
+
.select { |part| part.length >= PERSONAL_MIN_LENGTH }
|
|
81
|
+
.any? { |part| normalized.include?(part) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
module Users
|
|
5
|
+
# When a user must choose a new password before going on (ANSSI guide on passwords, R24 to R26):
|
|
6
|
+
#
|
|
7
|
+
# - after `password_expires_after` (nil by default: passwords don't expire, R24). The ANSSI
|
|
8
|
+
# suggests 1 to 3 years for privileged accounts without a second factor (R25), e.g.
|
|
9
|
+
# `self.password_expires_after = 1.year`;
|
|
10
|
+
# - after `User.require_password_renewal!`, for everyone at once when a compromise is suspected
|
|
11
|
+
# (R26); the admin's users list offers it.
|
|
12
|
+
#
|
|
13
|
+
# Needs the `password_changed_at` and `password_renewal_required_at` columns.
|
|
14
|
+
module PasswordRenewal
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
included do
|
|
18
|
+
class_attribute :password_expires_after, default: nil
|
|
19
|
+
|
|
20
|
+
# password_renewal_required, password_renewal_required? and its setter, from the
|
|
21
|
+
# password_renewal_required_at time
|
|
22
|
+
time_for_a_boolean :password_renewal_required
|
|
23
|
+
|
|
24
|
+
before_save :renew_password, if: :password_digest_changed?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class_methods do
|
|
28
|
+
def require_password_renewal!
|
|
29
|
+
update_all(password_renewal_required_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def password_renewal_needed?
|
|
34
|
+
password_renewal_required? || password_expired?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def password_expired?
|
|
38
|
+
return false unless password_expires_after
|
|
39
|
+
|
|
40
|
+
(password_changed_at || created_at) < password_expires_after.ago
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def renew_password
|
|
46
|
+
self.password_changed_at = Time.current
|
|
47
|
+
self.password_renewal_required = false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
module Users
|
|
5
|
+
# How long a signed-in browser (User::Session) stays signed in (ANSSI guide on passwords, R12):
|
|
6
|
+
#
|
|
7
|
+
# - `idle_timeout` without a request, 48 hours by default;
|
|
8
|
+
# - `lifetime` since signing in, whatever the activity, 30 days by default.
|
|
9
|
+
#
|
|
10
|
+
# The last request is the session's `last_activity_at`, written at most every
|
|
11
|
+
# `activity_interval` (5 minutes by default), so browsing doesn't write on every page.
|
|
12
|
+
module SessionLifetime
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
class_attribute :idle_timeout, default: 48.hours
|
|
17
|
+
class_attribute :lifetime, default: 30.days
|
|
18
|
+
class_attribute :activity_interval, default: 5.minutes
|
|
19
|
+
|
|
20
|
+
attribute :last_activity_at, default: -> { Time.current }
|
|
21
|
+
|
|
22
|
+
scope :active, -> { where(last_activity_at: idle_timeout.ago..).where(created_at: lifetime.ago..) }
|
|
23
|
+
scope :expired, -> { where(last_activity_at: ...idle_timeout.ago).or(where(created_at: ...lifetime.ago)) }
|
|
24
|
+
scope :recent_first, -> { order(last_activity_at: :desc) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def expired?
|
|
28
|
+
last_activity_at < idle_timeout.ago || created_at < lifetime.ago
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Records a request: at most once every activity_interval, without touching updated_at.
|
|
32
|
+
def record_activity
|
|
33
|
+
return if last_activity_at > activity_interval.ago
|
|
34
|
+
|
|
35
|
+
update_column(:last_activity_at, Time.current) # rubocop:disable Rails/SkipsModelValidations -- a timestamp only
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
module Users
|
|
5
|
+
# Locks signing in for a while after failed sign-ins in a row, whatever the IP address they come
|
|
6
|
+
# from, against password guessing spread over many addresses (ANSSI guide on passwords, R10):
|
|
7
|
+
# after `sign_in_lockout_after` failures (5), for `sign_in_lockout_duration` (1 minute), doubled
|
|
8
|
+
# on each further failure up to `sign_in_lockout_max` (1 hour). Never for good, so knowing an
|
|
9
|
+
# email isn't enough to keep its user out.
|
|
10
|
+
#
|
|
11
|
+
# While locked, signing in fails even with the right password, and the attempts don't count. A
|
|
12
|
+
# successful sign-in or a new password (e.g. through a reset) unlocks it. The user is emailed
|
|
13
|
+
# when it first locks (`sign_in_lockout_email`).
|
|
14
|
+
#
|
|
15
|
+
# Needs the `failed_sign_in_count` and `sign_in_locked_until` columns.
|
|
16
|
+
module SignInLockout
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
class_attribute :sign_in_lockout_after, default: 5
|
|
21
|
+
class_attribute :sign_in_lockout_duration, default: 1.minute
|
|
22
|
+
class_attribute :sign_in_lockout_max, default: 1.hour
|
|
23
|
+
class_attribute :sign_in_lockout_email, default: true
|
|
24
|
+
|
|
25
|
+
before_save :unlock_sign_in, if: :password_digest_changed?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sign_in_locked?
|
|
29
|
+
sign_in_locked_until.present? && sign_in_locked_until.future?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Counts a wrong password, unless signing in is already locked
|
|
33
|
+
def record_failed_sign_in!
|
|
34
|
+
return if sign_in_locked?
|
|
35
|
+
|
|
36
|
+
# In the database, then read back with the failures counted in parallel
|
|
37
|
+
increment!(:failed_sign_in_count) # rubocop:disable Rails/SkipsModelValidations
|
|
38
|
+
reload
|
|
39
|
+
return if failed_sign_in_count < sign_in_lockout_after
|
|
40
|
+
|
|
41
|
+
update_column(:sign_in_locked_until, sign_in_lockout_delay.from_now) # rubocop:disable Rails/SkipsModelValidations
|
|
42
|
+
# Once per series of failures, not on each failure that extends it
|
|
43
|
+
first_lockout = failed_sign_in_count == sign_in_lockout_after
|
|
44
|
+
AdminMailer.sign_in_locked(self).deliver_later if sign_in_lockout_email && first_lockout
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record_successful_sign_in!
|
|
48
|
+
return unless failed_sign_in_count.positive? || sign_in_locked_until
|
|
49
|
+
|
|
50
|
+
update_columns(failed_sign_in_count: 0, sign_in_locked_until: nil) # rubocop:disable Rails/SkipsModelValidations
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def sign_in_lockout_delay
|
|
56
|
+
# Capped before raising 2 to it, as the count may grow for as long as the guessing goes on
|
|
57
|
+
doublings = [failed_sign_in_count - sign_in_lockout_after, 20].min
|
|
58
|
+
[sign_in_lockout_duration * (2**doublings), sign_in_lockout_max].min
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def unlock_sign_in
|
|
62
|
+
self.failed_sign_in_count = 0
|
|
63
|
+
self.sign_in_locked_until = nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
module Users
|
|
5
|
+
# The user's current and previous sign-ins (date, IP address, browser), shown after signing in
|
|
6
|
+
# and on their profile to help them spot one that wasn't theirs. Kept on the user, as sessions
|
|
7
|
+
# are deleted when they end. Needs the `{current,last}_sign_in_{at,ip,user_agent}` columns.
|
|
8
|
+
#
|
|
9
|
+
# `new_sign_in_email` (true by default) emails the user when they sign in from a browser that
|
|
10
|
+
# never signed in as them.
|
|
11
|
+
module SignIns
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
class_attribute :new_sign_in_email, default: true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The current sign-in becomes the previous one.
|
|
19
|
+
def record_sign_in(ip:, user_agent:)
|
|
20
|
+
update_columns( # rubocop:disable Rails/SkipsModelValidations -- tracking only
|
|
21
|
+
last_sign_in_at: current_sign_in_at,
|
|
22
|
+
last_sign_in_ip: current_sign_in_ip,
|
|
23
|
+
last_sign_in_user_agent: current_sign_in_user_agent,
|
|
24
|
+
current_sign_in_at: Time.current,
|
|
25
|
+
current_sign_in_ip: ip,
|
|
26
|
+
current_sign_in_user_agent: user_agent
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -3,6 +3,9 @@ module Trek
|
|
|
3
3
|
module Validations
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
|
+
# The password rules (length, complexity, common passwords…)
|
|
7
|
+
include Trek::Users::PasswordPolicy
|
|
8
|
+
|
|
6
9
|
included do
|
|
7
10
|
# Also applies to lookups (`find_by(email:)`, `authenticate_by`), so signing in and asking
|
|
8
11
|
# for a password reset work whatever the case typed.
|
|
@@ -12,8 +15,6 @@ module Trek
|
|
|
12
15
|
presence: true,
|
|
13
16
|
uniqueness: { case_sensitive: false },
|
|
14
17
|
email: { mode: :strict }
|
|
15
|
-
# Only when a password was assigned: `password` is nil otherwise.
|
|
16
|
-
validates :password, length: { minimum: 8 }, allow_nil: true
|
|
17
18
|
validate :password_not_only_spaces
|
|
18
19
|
end
|
|
19
20
|
|
data/app/models/trek/feature.rb
CHANGED
|
@@ -3,5 +3,11 @@ module Trek
|
|
|
3
3
|
def self.reorder?
|
|
4
4
|
ENV.fetch("TREK_FEATURE_REORDER", "false") == "true"
|
|
5
5
|
end
|
|
6
|
+
|
|
7
|
+
# On by default; FEATURE_AUDIT_LOG=false is a kill switch, e.g. if writing the rows slows down
|
|
8
|
+
# a busy app.
|
|
9
|
+
def self.audit_log?
|
|
10
|
+
ENV.fetch("FEATURE_AUDIT_LOG", "true") != "false"
|
|
11
|
+
end
|
|
6
12
|
end
|
|
7
13
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
module Trek
|
|
7
|
+
# Checks a password against Have I Been Pwned's breached passwords, with its k-anonymity API: only
|
|
8
|
+
# the first 5 characters of the password's SHA-1 leave the app, padded so the response size tells
|
|
9
|
+
# nothing either. See https://haveibeenpwned.com/API/v3#PwnedPasswords
|
|
10
|
+
#
|
|
11
|
+
# Fails open: when the service can't be reached, the password is accepted and the error reported,
|
|
12
|
+
# so an outage never blocks a password change.
|
|
13
|
+
module PwnedPasswords
|
|
14
|
+
RANGE_URL = "https://api.pwnedpasswords.com/range/"
|
|
15
|
+
TIMEOUT = 3
|
|
16
|
+
|
|
17
|
+
def self.include?(password)
|
|
18
|
+
hash = Digest::SHA1.hexdigest(password).upcase
|
|
19
|
+
prefix, suffix = hash[0, 5], hash[5..]
|
|
20
|
+
|
|
21
|
+
range(prefix).each_line.any? do |line|
|
|
22
|
+
line_suffix, count = line.strip.split(":")
|
|
23
|
+
line_suffix == suffix && count.to_i.positive?
|
|
24
|
+
end
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Rails.error.report(e, handled: true, context: { service: "pwnedpasswords" })
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.range(prefix)
|
|
31
|
+
uri = URI("#{RANGE_URL}#{prefix}")
|
|
32
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
33
|
+
request = Net::HTTP::Get.new(uri, "Add-Padding" => "true", "User-Agent" => "Trek")
|
|
34
|
+
response = http.request(request)
|
|
35
|
+
response.value # raises unless 2xx
|
|
36
|
+
response.body
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
module Trek
|
|
2
|
+
# The email and password typed in a sign-in form. Deprecated: Trek's generated sign-in controller
|
|
3
|
+
# passes the params to `User.authenticate_by` instead. Removed in Trek 3.
|
|
2
4
|
class UserSession
|
|
3
5
|
extend ActiveModel::Translation
|
|
4
6
|
include ActiveModel::Model
|
|
5
7
|
|
|
6
8
|
attr_accessor :email, :password
|
|
9
|
+
|
|
10
|
+
def initialize(...)
|
|
11
|
+
Trek.deprecator.warn(<<~MSG.squish)
|
|
12
|
+
Trek::UserSession is deprecated and will be removed in Trek 3. Pass the sign-in params to
|
|
13
|
+
`User.authenticate_by(email:, password:)`, as Trek's generated Admin::UserSessionsController does.
|
|
14
|
+
MSG
|
|
15
|
+
super
|
|
16
|
+
end
|
|
7
17
|
end
|
|
8
18
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Trek
|
|
2
|
+
# Audit logs are read by admins only, and never created, edited or deleted from the admin.
|
|
3
|
+
class AuditLogPolicy < Trek::ApplicationPolicy
|
|
4
|
+
def index?
|
|
5
|
+
user.admin?
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def show?
|
|
9
|
+
user.admin?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create?
|
|
13
|
+
false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def manage?
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def destroy?
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def permitted_attributes
|
|
25
|
+
[]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
relation_scope do |relation|
|
|
29
|
+
next relation if user.admin?
|
|
30
|
+
|
|
31
|
+
relation.none
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Refuses an email address from a disposable (throwaway) email provider, or a subdomain of one:
|
|
4
|
+
#
|
|
5
|
+
# validates :email, not_disposable: true
|
|
6
|
+
#
|
|
7
|
+
# The domains come from the valid_email2 gem, whose maintainers keep the list up to date: updating
|
|
8
|
+
# the gem updates it (see Trek::DisposableEmailDomains). Only the list is checked, without looking up
|
|
9
|
+
# the domain's mail servers. An app can let a domain through in config/allow_listed_email_domains.yml
|
|
10
|
+
# (valid_email2's file).
|
|
11
|
+
# A blank value is left to the presence validation.
|
|
12
|
+
class NotDisposableValidator < ActiveModel::EachValidator
|
|
13
|
+
def validate_each(record, attribute, value)
|
|
14
|
+
return if value.blank? || !Trek::DisposableEmailDomains.include?(value.to_s.split("@").last)
|
|
15
|
+
|
|
16
|
+
record.errors.add(attribute, :disposable, **options.except(:allow_nil, :allow_blank, :if, :unless, :on))
|
|
17
|
+
end
|
|
18
|
+
end
|
data/config/brakeman.ignore
CHANGED
|
@@ -21,6 +21,28 @@
|
|
|
21
21
|
352
|
|
22
22
|
],
|
|
23
23
|
"note": ""
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"warning_type": "Cross-Site Request Forgery",
|
|
27
|
+
"warning_code": 7,
|
|
28
|
+
"fingerprint": "ad2a1ba1d260a5401928b050fe86d5f219d91a240b06abe0e0da3f346d744f3e",
|
|
29
|
+
"check_name": "ForgerySetting",
|
|
30
|
+
"message": "`protect_from_forgery` should be called in `Trek::CspReportsController`",
|
|
31
|
+
"file": "app/controllers/trek/csp_reports_controller.rb",
|
|
32
|
+
"line": 11,
|
|
33
|
+
"link": "https://brakemanscanner.org/docs/warning_types/cross-site_request_forgery/",
|
|
34
|
+
"code": null,
|
|
35
|
+
"render_path": null,
|
|
36
|
+
"location": {
|
|
37
|
+
"type": "controller",
|
|
38
|
+
"controller": "Trek::CspReportsController"
|
|
39
|
+
},
|
|
40
|
+
"user_input": null,
|
|
41
|
+
"confidence": "High",
|
|
42
|
+
"cwe_id": [
|
|
43
|
+
352
|
|
44
|
+
],
|
|
45
|
+
"note": "Browsers send CSP reports without a CSRF token; the endpoint only logs and reports them, with no session nor user."
|
|
24
46
|
}
|
|
25
47
|
],
|
|
26
48
|
"brakeman_version": "7.0.0"
|
data/config/i18n-tasks.yml
CHANGED
|
@@ -13,6 +13,13 @@ locales:
|
|
|
13
13
|
data:
|
|
14
14
|
read:
|
|
15
15
|
- config/locales/trek.%{locale}.yml
|
|
16
|
+
# `i18n-tasks normalize` sorts the keys, then rewrites the files with Ruby's YAML writer, which
|
|
17
|
+
# splits values longer than 80 characters over several lines. Prettier, run next, fixes the
|
|
18
|
+
# writer's quotes but keeps those split lines, so don't split any (-1). `make i18n-normalize`
|
|
19
|
+
# runs both.
|
|
20
|
+
yaml:
|
|
21
|
+
write:
|
|
22
|
+
line_width: -1
|
|
16
23
|
|
|
17
24
|
# The gendered resource-label variants (f/m/m_alt) only exist in locales whose
|
|
18
25
|
# models declare a gender; English always resolves to `n` (see i18n_model_gender),
|