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.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/.node-version +1 -1
  3. data/.yarn/releases/yarn-4.18.1.cjs +1000 -0
  4. data/.yarnrc.yml +3 -5
  5. data/AGENTS.md +7 -4
  6. data/CHANGELOG.md +55 -0
  7. data/Gemfile.lock +35 -1
  8. data/Makefile +12 -0
  9. data/app/assets/icons/trek/passkey.svg +5 -0
  10. data/app/assets/stylesheets/trek/base.css +6 -0
  11. data/app/components/trek/entries/item_component/item_component.css +6 -0
  12. data/app/components/trek/form/image_field_component/image_field_component.html.slim +9 -0
  13. data/app/components/trek/form/password_strength_component/password_strength_component.css +56 -0
  14. data/app/components/trek/form/password_strength_component/password_strength_component.html.slim +8 -0
  15. data/app/components/trek/form/password_strength_component/password_strength_component.js +74 -0
  16. data/app/components/trek/form/password_strength_component.rb +29 -0
  17. data/app/components/trek/gate_component/gate_component.css +7 -0
  18. data/app/components/trek/icon/sprite_component.rb +3 -2
  19. data/app/components/trek/section_component/section_component.css +22 -0
  20. data/app/components/trek/section_component/section_component.html.slim +5 -0
  21. data/app/components/trek/section_component/section_component.js +1 -0
  22. data/app/components/trek/section_component.rb +14 -0
  23. data/app/controllers/concerns/trek/auditable.rb +103 -0
  24. data/app/controllers/concerns/trek/filters.rb +2 -0
  25. data/app/controllers/concerns/trek/passkeys.rb +37 -0
  26. data/app/controllers/concerns/trek/scopes.rb +9 -2
  27. data/app/controllers/trek/csp_reports_controller.rb +62 -0
  28. data/app/controllers/trek/resource_controller.rb +7 -1
  29. data/app/helpers/trek/audit_logs_helper.rb +21 -0
  30. data/app/helpers/trek/user_agents_helper.rb +36 -0
  31. data/app/javascript/controllers/index.js +3 -0
  32. data/app/javascript/controllers/passkey_controller.js +118 -0
  33. data/app/javascript/trek.js +4 -0
  34. data/app/jobs/trek/audit_log/purge_job.rb +18 -0
  35. data/app/jobs/trek/purge_expired_sessions_job.rb +14 -0
  36. data/app/jobs/trek/resolve_session_hostname_job.rb +17 -0
  37. data/app/models/concerns/trek/audit_log.rb +30 -0
  38. data/app/models/concerns/trek/users/passkeys.rb +75 -0
  39. data/app/models/concerns/trek/users/password_history.rb +51 -0
  40. data/app/models/concerns/trek/users/password_policy.rb +85 -0
  41. data/app/models/concerns/trek/users/password_renewal.rb +51 -0
  42. data/app/models/concerns/trek/users/session_lifetime.rb +39 -0
  43. data/app/models/concerns/trek/users/sign_in_lockout.rb +67 -0
  44. data/app/models/concerns/trek/users/sign_ins.rb +31 -0
  45. data/app/models/concerns/trek/users/validations.rb +3 -2
  46. data/app/models/trek/feature.rb +6 -0
  47. data/app/models/trek/pwned_passwords.rb +40 -0
  48. data/app/models/trek/user_session.rb +10 -0
  49. data/app/policies/trek/audit_log_policy.rb +34 -0
  50. data/app/policies/trek/user_policy.rb +5 -0
  51. data/app/validators/not_disposable_validator.rb +18 -0
  52. data/config/brakeman.ignore +22 -0
  53. data/config/i18n-tasks.yml +7 -0
  54. data/config/locales/trek.en.yml +144 -39
  55. data/config/locales/trek.fr.yml +145 -40
  56. data/docs/.vitepress/config.mjs +8 -3
  57. data/docs/deploy.md +2 -0
  58. data/docs/proxies.md +53 -0
  59. data/docs/reference/components.md +1 -0
  60. data/docs/reference/controllers.md +1 -1
  61. data/docs/reference/environment-variables.md +2 -0
  62. data/docs/reference/generators/admin-user.md +1 -1
  63. data/docs/reference/generators/audit-log.md +113 -0
  64. data/docs/reference/generators/install.md +12 -3
  65. data/docs/reference/generators/security-headers.md +109 -0
  66. data/docs/reference/index.md +3 -0
  67. data/docs/reference/models/tag.md +4 -2
  68. data/docs/reference/models/user.md +123 -3
  69. data/docs/reference/validators.md +94 -0
  70. data/docs/security.md +90 -0
  71. data/lefthook.yml +8 -0
  72. data/lib/generators/trek/install/admin/user_password_resets_generator.rb +5 -0
  73. data/lib/generators/trek/install/admin/user_sessions_generator.rb +5 -0
  74. data/lib/generators/trek/install/admin/users_generator.rb +23 -0
  75. data/lib/generators/trek/install/audit_log_generator.rb +56 -0
  76. data/lib/generators/trek/install/authentication_generator.rb +77 -3
  77. data/lib/generators/trek/install/esbuild_generator.rb +1 -2
  78. data/lib/generators/trek/install/mailer_generator.rb +18 -0
  79. data/lib/generators/trek/install/model_specs_generator.rb +6 -4
  80. data/lib/generators/trek/install/postcss_generator.rb +1 -4
  81. data/lib/generators/trek/install/security_headers_generator.rb +49 -0
  82. data/lib/generators/trek/install/yarn_generator.rb +1 -1
  83. data/lib/generators/trek/install_generator.rb +62 -54
  84. data/lib/generators/trek/taxonomies_generator.rb +5 -0
  85. data/lib/generators/trek/templates/.gitlab-ci.yml +18 -2
  86. data/lib/generators/trek/templates/controllers/admin/active_sessions_controller.rb +21 -0
  87. data/lib/generators/trek/templates/controllers/admin/audit_logs_controller.rb +30 -0
  88. data/lib/generators/trek/templates/controllers/admin/passkey_sessions_controller.rb +41 -0
  89. data/lib/generators/trek/templates/controllers/admin/passkeys_controller.rb +37 -0
  90. data/lib/generators/trek/templates/controllers/admin/user_password_resets_controller.rb +4 -4
  91. data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +53 -4
  92. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +20 -0
  93. data/lib/generators/trek/templates/controllers/concerns/trek/authentication.rb +58 -7
  94. data/lib/generators/trek/templates/helpers/application_helper.rb +0 -4
  95. data/lib/generators/trek/templates/initializers/audit_log.rb +11 -0
  96. data/lib/generators/trek/templates/initializers/content_security_policy.rb +49 -0
  97. data/lib/generators/trek/templates/initializers/permissions_policy.rb +19 -0
  98. data/lib/generators/trek/templates/jobs/admin/password_reset_job.rb +9 -0
  99. data/lib/generators/trek/templates/layouts/admin/auth.html.slim +0 -5
  100. data/lib/generators/trek/templates/layouts/admin.html.slim +0 -3
  101. data/lib/generators/trek/templates/mailers/admin/passwords_mailer.rb +1 -1
  102. data/lib/generators/trek/templates/mailers/admin_mailer.rb +28 -1
  103. data/lib/generators/trek/templates/migrations/audit_logs.rb.erb +27 -0
  104. data/lib/generators/trek/templates/migrations/tags.rb.erb +6 -1
  105. data/lib/generators/trek/templates/migrations/user_passkeys.rb.erb +24 -0
  106. data/lib/generators/trek/templates/migrations/user_password_renewal.rb.erb +8 -0
  107. data/lib/generators/trek/templates/migrations/user_previous_passwords.rb.erb +10 -0
  108. data/lib/generators/trek/templates/migrations/user_session_tracking.rb.erb +11 -0
  109. data/lib/generators/trek/templates/migrations/user_sign_in_lockout.rb.erb +7 -0
  110. data/lib/generators/trek/templates/migrations/user_sign_ins.rb.erb +13 -0
  111. data/lib/generators/trek/templates/models/audit_log.rb +3 -0
  112. data/lib/generators/trek/templates/models/tag_category.rb +2 -1
  113. data/lib/generators/trek/templates/models/user/passkey.rb +17 -0
  114. data/lib/generators/trek/templates/models/user/previous_password.rb +4 -0
  115. data/lib/generators/trek/templates/models/user/session.rb +8 -0
  116. data/lib/generators/trek/templates/policies/audit_log.rb +4 -0
  117. data/lib/generators/trek/templates/spec/mailers/admin/passwords_mailer_spec.rb +16 -0
  118. data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +50 -0
  119. data/lib/generators/trek/templates/spec/models/audit_log_spec.rb +43 -0
  120. data/lib/generators/trek/templates/spec/models/tag_spec.rb +52 -0
  121. data/lib/generators/trek/templates/spec/models/user/session_spec.rb +68 -0
  122. data/lib/generators/trek/templates/spec/models/user_spec.rb +142 -4
  123. data/lib/generators/trek/templates/spec/requests/admin/active_sessions_spec.rb +119 -0
  124. data/lib/generators/trek/templates/spec/requests/admin/audit_logs_spec.rb +149 -0
  125. data/lib/generators/trek/templates/spec/requests/admin/passkey_sessions_spec.rb +103 -0
  126. data/lib/generators/trek/templates/spec/requests/admin/passkeys_spec.rb +104 -0
  127. data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +83 -0
  128. data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +95 -0
  129. data/lib/generators/trek/templates/spec/requests/admin/sign_in_notices_spec.rb +93 -0
  130. data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +55 -6
  131. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +31 -2
  132. data/lib/generators/trek/templates/views/admin/audit_logs/index.html.slim +35 -0
  133. data/lib/generators/trek/templates/views/admin/audit_logs/show.html.slim +50 -0
  134. data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +17 -7
  135. data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +3 -2
  136. data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +28 -2
  137. data/lib/generators/trek/templates/views/admin/users/_passkeys.html.slim +67 -0
  138. data/lib/generators/trek/templates/views/admin/users/_properties.html.slim +21 -0
  139. data/lib/generators/trek/templates/views/admin/users/_sessions.html.slim +70 -0
  140. data/lib/generators/trek/templates/views/admin/users/index.html.slim +13 -0
  141. data/lib/generators/trek/templates/views/admin/users/show.html.slim +10 -21
  142. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +22 -0
  143. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +16 -0
  144. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +22 -0
  145. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +16 -0
  146. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.html.slim +17 -0
  147. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.text.erb +10 -0
  148. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.html.slim +18 -0
  149. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.text.erb +10 -0
  150. data/lib/trek/admin_ip_allowlist.rb +52 -0
  151. data/lib/trek/csp_reports.rb +99 -0
  152. data/lib/trek/data/common_passwords.txt +857 -0
  153. data/lib/trek/data/passkey_providers.json +404 -0
  154. data/lib/trek/disposable_email_domains.rb +53 -0
  155. data/lib/trek/engine.rb +12 -0
  156. data/lib/trek/passkey_providers.rb +19 -0
  157. data/lib/trek/reverse_dns.rb +27 -0
  158. data/lib/trek/version.rb +1 -1
  159. data/lib/trek.rb +13 -0
  160. data/package.json +3 -5
  161. data/renovate.json +11 -1
  162. data/trek.gemspec +3 -0
  163. data/yarn.lock +1757 -2019
  164. metadata +132 -4
  165. data/.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs +0 -541
  166. data/.yarn/releases/yarn-3.8.0.cjs +0 -875
  167. 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
 
@@ -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
@@ -30,6 +30,11 @@ module Trek
30
30
  user.admin?
31
31
  end
32
32
 
33
+ # Every user's password, after a suspected compromise
34
+ def require_password_renewal?
35
+ user.admin?
36
+ end
37
+
33
38
  def permitted_attributes
34
39
  if user.admin?
35
40
  %i[email name role locale theme].tap do |attrs|
@@ -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
@@ -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"
@@ -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),