rodauth-tools 0.3.1 → 0.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/.rubocop.yml +4 -0
- data/.rubocop_todo.yml +10 -3
- data/CHANGELOG.md +25 -0
- data/CLAUDE.md +34 -11
- data/Gemfile +5 -5
- data/Gemfile.lock +119 -117
- data/README.md +33 -1
- data/lib/rodauth/features/account_id_obfuscation.rb +317 -0
- data/lib/rodauth/features/hmac_secret_guard.rb +55 -47
- data/lib/rodauth/features/jwt_secret_guard.rb +55 -45
- data/lib/rodauth/features/table_guard.rb +195 -53
- data/lib/rodauth/secret_guard.rb +137 -0
- data/lib/rodauth/sequel_generator.rb +17 -5
- data/lib/rodauth/table_inspector.rb +1 -1
- data/lib/rodauth/tools/account_id_cipher.rb +150 -0
- data/lib/rodauth/tools/migration.rb +3 -4
- data/lib/rodauth/tools/version.rb +1 -1
- data/lib/rodauth/tools.rb +2 -0
- data/package-lock.json +943 -76
- data/package.json +1 -1
- metadata +4 -1
data/README.md
CHANGED
|
@@ -192,7 +192,38 @@ end
|
|
|
192
192
|
|
|
193
193
|
**Documentation:** [docs/features/jwt-secret-guard.md](docs/features/jwt-secret-guard.md)
|
|
194
194
|
|
|
195
|
-
### 5.
|
|
195
|
+
### 5. Account ID Obfuscation Feature
|
|
196
|
+
|
|
197
|
+
Obfuscate the numeric `account_id` that leaks into email-verification links and the
|
|
198
|
+
remember-me cookie, with no database schema change.
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
class RodauthApp < Roda
|
|
202
|
+
plugin :rodauth do
|
|
203
|
+
enable :login, :verify_account, :remember
|
|
204
|
+
enable :account_id_obfuscation
|
|
205
|
+
|
|
206
|
+
# Production: raises error if ACCOUNT_ID_SECRET (>= 32 bytes) missing
|
|
207
|
+
# Development: uses fallback secret with warning
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Links change from `/verify-account?key=2_SspVz...` to `/verify-account?key=AE946V4SD7Z7RV_SspVz...`.
|
|
213
|
+
|
|
214
|
+
**Key Features:**
|
|
215
|
+
|
|
216
|
+
- Keyed, reversible obfuscation (4-round Feistel format-preserving encryption)
|
|
217
|
+
- Covers all email-link features + the remember cookie via two scoped overrides
|
|
218
|
+
- Leaves `split_token`/`convert_token_id` untouched, so `jwt_refresh` is unaffected
|
|
219
|
+
- Backward compatible: in-flight numeric links and legacy cookies keep working
|
|
220
|
+
- Version-tagged tokens enable config-driven secret rotation
|
|
221
|
+
- Dedicated `ACCOUNT_ID_SECRET` (>= 32 bytes), independent of HMAC/JWT secrets
|
|
222
|
+
- Standalone `Rodauth::Tools::AccountIdCipher` primitive (stdlib `openssl` only)
|
|
223
|
+
|
|
224
|
+
**Documentation:** [docs/features/account-id-obfuscation.md](docs/features/account-id-obfuscation.md)
|
|
225
|
+
|
|
226
|
+
### 6. Sequel Migration Generator
|
|
196
227
|
|
|
197
228
|
Generate database migrations for Rodauth features.
|
|
198
229
|
|
|
@@ -306,6 +337,7 @@ Rodauth Features are modules that mix into `Rodauth::Auth` instances at runtime.
|
|
|
306
337
|
- **[External Identity Feature](docs/features/external-identity.md)** - Track external service identifiers
|
|
307
338
|
- **[HMAC Secret Guard Feature](docs/features/hmac-secret-guard.md)** - Validate HMAC secrets at startup
|
|
308
339
|
- **[JWT Secret Guard Feature](docs/features/jwt-secret-guard.md)** - Validate JWT secrets at startup
|
|
340
|
+
- **[Account ID Obfuscation Feature](docs/features/account-id-obfuscation.md)** - Hide numeric account ids in email links and cookies
|
|
309
341
|
- **[Sequel Migrations](docs/sequel-migrations.md)** - Integrating table_guard with Sequel migrations
|
|
310
342
|
- **[Rodauth Feature API](docs/rodauth-features-api.md)** - Complete DSL reference for feature development
|
|
311
343
|
- **[Rodauth Integration](docs/rodauth-integration.md)** - Framework integration patterns
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# lib/rodauth/features/account_id_obfuscation.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative '../tools/account_id_cipher' unless defined?(Rodauth::Tools::AccountIdCipher)
|
|
6
|
+
require_relative '../secret_guard' unless defined?(Rodauth::SecretGuard)
|
|
7
|
+
|
|
8
|
+
module Rodauth
|
|
9
|
+
# Obfuscates the numeric account id that Rodauth otherwise leaks in plaintext
|
|
10
|
+
# inside email-link tokens (e.g. +/verify-account?key=2_...+) and, optionally,
|
|
11
|
+
# the remember-me cookie. The id is replaced by a fixed-width, URL-safe,
|
|
12
|
+
# non-sequential token: a one-character non-digit VERSION tag followed by the
|
|
13
|
+
# 13-char output of {Rodauth::Tools::AccountIdCipher} (keyed format-preserving
|
|
14
|
+
# encryption). No database schema change is required; the integer primary key
|
|
15
|
+
# is used everywhere internally and only the value crossing the network is
|
|
16
|
+
# obfuscated.
|
|
17
|
+
#
|
|
18
|
+
# It wraps the two email-token chokepoints in +email_base+ (+token_param_value+
|
|
19
|
+
# to encode, +account_from_key+ to decode), which together cover ALL email-link
|
|
20
|
+
# features: verify_account, reset_password, email_auth, verify_login_change and
|
|
21
|
+
# lockout/unlock. It deliberately does NOT touch the lower-level +split_token+/
|
|
22
|
+
# +convert_token_id+, so other token consumers (e.g. jwt_refresh) are unaffected.
|
|
23
|
+
#
|
|
24
|
+
# This is deterministic pseudonymisation, NOT access control or encryption of
|
|
25
|
+
# confidential data: the same id always maps to the same token, and the token's
|
|
26
|
+
# authority is unchanged (the email HMAC still gates the request after the id is
|
|
27
|
+
# swapped back to the real Integer). Applies to integer/bigint primary keys.
|
|
28
|
+
#
|
|
29
|
+
# The VERSION tag makes obfuscated tokens deterministically distinguishable from
|
|
30
|
+
# legacy decimal ids (Rodauth ids are always digits, never a letter), so legacy
|
|
31
|
+
# links/cookies pass through untouched. It also selects the secret, enabling
|
|
32
|
+
# config-driven key rotation via +account_id_obfuscation_previous_secrets+.
|
|
33
|
+
#
|
|
34
|
+
# This feature shares its ENV-loading and production-detection plumbing with
|
|
35
|
+
# +hmac_secret_guard+/+jwt_secret_guard+ via +Rodauth::SecretGuard+ (see
|
|
36
|
+
# +production_env_check+ and +validate_secrets_on_configure?+ below, which are
|
|
37
|
+
# the same config methods those two guards expose). It keeps its own
|
|
38
|
+
# always-on 32-byte minimum, though: {Rodauth::Tools::AccountIdCipher} hard-requires
|
|
39
|
+
# that floor regardless of environment, unlike the guards' opt-in, production-only
|
|
40
|
+
# +minimum_secret_length+.
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# plugin :rodauth do
|
|
44
|
+
# enable :login, :verify_account, :remember, :account_id_obfuscation
|
|
45
|
+
# # ACCOUNT_ID_SECRET must be set (>= 32 bytes) in production
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# @example Development / custom secret source
|
|
49
|
+
# plugin :rodauth do
|
|
50
|
+
# enable :account_id_obfuscation
|
|
51
|
+
# account_id_obfuscation_secret_env_key 'MY_ID_SECRET'
|
|
52
|
+
# account_id_obfuscation_remember_cookie? false
|
|
53
|
+
# end
|
|
54
|
+
Feature.define(:account_id_obfuscation, :AccountIdObfuscation) do
|
|
55
|
+
# email_base owns the two chokepoints we wrap; depending on it guarantees it
|
|
56
|
+
# is loaded and sits lower in the ancestor chain (so our +super+ resolves).
|
|
57
|
+
depends :email_base
|
|
58
|
+
|
|
59
|
+
auth_value_method :account_id_obfuscation_secret, nil
|
|
60
|
+
auth_value_method :account_id_obfuscation_secret_env_key, 'ACCOUNT_ID_SECRET'
|
|
61
|
+
auth_value_method :account_id_obfuscation_key_version, 'A' # single non-digit char
|
|
62
|
+
auth_value_method :account_id_obfuscation_previous_secrets, {}.freeze # {ver_char => old_secret}, decode-only
|
|
63
|
+
auth_value_method :account_id_obfuscation_remember_cookie?, true
|
|
64
|
+
# Shared with hmac_secret_guard/jwt_secret_guard (same config method, same
|
|
65
|
+
# fail-safe default): an unset RACK_ENV is treated as production. Avoid
|
|
66
|
+
# `proc { ENV['RACK_ENV'] == 'production' }` — when the variable is unset
|
|
67
|
+
# that returns false and silently falls back to the insecure development
|
|
68
|
+
# secret in what is really a production deploy.
|
|
69
|
+
auth_value_method :production_env_check, proc { ENV.fetch('RACK_ENV', 'production') == 'production' }
|
|
70
|
+
# Shared with hmac_secret_guard/jwt_secret_guard: gates whether post_configure
|
|
71
|
+
# calls the boot-time validator at all.
|
|
72
|
+
auth_value_method :validate_secrets_on_configure?, true
|
|
73
|
+
# Fixed (not random-per-process, unlike the guards' SecureRandom.hex fallback):
|
|
74
|
+
# obfuscated tokens must stay stable across restarts within a single
|
|
75
|
+
# development process' lifetime for links/cookies minted before a restart to
|
|
76
|
+
# keep decoding, and this feature has no server-side session to smooth that
|
|
77
|
+
# over. Still a placeholder — change it, or better, set ACCOUNT_ID_SECRET.
|
|
78
|
+
auth_value_method :development_account_id_obfuscation_secret_fallback,
|
|
79
|
+
'dev-only-insecure-account-id-obfuscation-secret-please-change-me'
|
|
80
|
+
|
|
81
|
+
translatable_method :account_id_obfuscation_secret_missing_error,
|
|
82
|
+
'ACCOUNT_ID_SECRET environment variable must be set in production'
|
|
83
|
+
translatable_method :account_id_obfuscation_secret_too_short_error,
|
|
84
|
+
'account id obfuscation secret must be at least 32 bytes'
|
|
85
|
+
translatable_method :account_id_obfuscation_key_version_error,
|
|
86
|
+
'account_id_obfuscation_key_version must be a single non-digit character'
|
|
87
|
+
translatable_method :account_id_obfuscation_secret_dev_warning,
|
|
88
|
+
'[rodauth] WARNING: Using default account id obfuscation secret for development only'
|
|
89
|
+
|
|
90
|
+
# post_configure runs on a throwaway instance, so cache the version=>cipher
|
|
91
|
+
# map lazily per runtime instance rather than in an instance ivar.
|
|
92
|
+
auth_cached_method :account_id_ciphers
|
|
93
|
+
|
|
94
|
+
auth_methods :obfuscate_account_id, :deobfuscate_account_id, :production?,
|
|
95
|
+
:validate_secrets!, :validate_account_id_obfuscation_secret!
|
|
96
|
+
|
|
97
|
+
def post_configure
|
|
98
|
+
super
|
|
99
|
+
|
|
100
|
+
load_account_id_secret_from_env
|
|
101
|
+
validate_key_version!
|
|
102
|
+
validate_account_id_obfuscation_secret! if validate_secrets_on_configure?
|
|
103
|
+
|
|
104
|
+
install_remember_cookie_obfuscation if account_id_obfuscation_remember_cookie? && features.include?(:remember)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ---- email-token chokepoints (plain overrides; email_base is a dependency,
|
|
108
|
+
# so +super+ always resolves, and internal_request delegates through) ----
|
|
109
|
+
|
|
110
|
+
# Encode the id in the outgoing email link. Wraps email_base's token
|
|
111
|
+
# ("<id><sep><hmac>") and rewrites only the id segment, staying decoupled from
|
|
112
|
+
# email_base's exact composition.
|
|
113
|
+
def token_param_value(key)
|
|
114
|
+
original = super
|
|
115
|
+
return original if account_id.nil?
|
|
116
|
+
|
|
117
|
+
_id, separator, remainder = original.partition(token_separator)
|
|
118
|
+
remainder.empty? ? original : "#{obfuscate_account_id(account_id)}#{separator}#{remainder}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Decode the id segment of an incoming token before email_base parses it.
|
|
122
|
+
# A legacy plaintext token (or any non-token) deobfuscates to nil and passes
|
|
123
|
+
# through unchanged, so in-flight links keep working.
|
|
124
|
+
def account_from_key(token, status_id = nil, &)
|
|
125
|
+
if token.is_a?(String)
|
|
126
|
+
segment, separator, remainder = token.partition(token_separator)
|
|
127
|
+
if !remainder.empty? && (real_id = deobfuscate_account_id(segment))
|
|
128
|
+
token = "#{real_id}#{separator}#{remainder}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
super
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ---- public API ----
|
|
136
|
+
|
|
137
|
+
# @return [String] version-tagged obfuscated form of an account id
|
|
138
|
+
def obfuscate_account_id(id)
|
|
139
|
+
version = account_id_obfuscation_key_version
|
|
140
|
+
"#{version}#{account_id_ciphers.fetch(version).encode(id)}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Integer, nil] the real id, or nil if +segment+ is not one of our
|
|
144
|
+
# tokens (legacy decimal id, unknown version, wrong width, or bad chars)
|
|
145
|
+
def deobfuscate_account_id(segment)
|
|
146
|
+
return nil unless segment.is_a?(String) &&
|
|
147
|
+
segment.length == 1 + Rodauth::Tools::AccountIdCipher::WIDTH
|
|
148
|
+
|
|
149
|
+
cipher = account_id_ciphers[segment[0]] or return nil
|
|
150
|
+
|
|
151
|
+
cipher.decode(segment[1..])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Check if we're running in production environment.
|
|
155
|
+
#
|
|
156
|
+
# Delegates to the same +Rodauth::SecretGuard.production?+ that
|
|
157
|
+
# +hmac_secret_guard+/+jwt_secret_guard+ use, so behavior is identical
|
|
158
|
+
# whichever guard's copy of this method happens to win when several are
|
|
159
|
+
# enabled together (see +validate_secrets!+ below for why that collision
|
|
160
|
+
# matters more for validation than for this read-only check).
|
|
161
|
+
#
|
|
162
|
+
# @return [Boolean] true if running in production mode based on production_env_check
|
|
163
|
+
def production?
|
|
164
|
+
Rodauth::SecretGuard.production?(self)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Validate that the account-id obfuscation secret(s) are properly
|
|
168
|
+
# configured. Raises ConfigurationError in production if the current
|
|
169
|
+
# secret is missing or blank. Always (in every environment) raises if the
|
|
170
|
+
# current secret, or any +account_id_obfuscation_previous_secrets+ entry,
|
|
171
|
+
# is present but shorter than +AccountIdCipher::MIN_SECRET_BYTES+ — unlike
|
|
172
|
+
# +minimum_secret_length+ on the sibling guards, this floor is not
|
|
173
|
+
# opt-in/production-only, because {Rodauth::Tools::AccountIdCipher} raises
|
|
174
|
+
# ArgumentError below it regardless of environment. In development, a
|
|
175
|
+
# missing current secret warns and installs a fallback.
|
|
176
|
+
#
|
|
177
|
+
# This is the collision-free entry point: prefer it over +validate_secrets!+
|
|
178
|
+
# when this feature is co-enabled with +hmac_secret_guard+/+jwt_secret_guard+
|
|
179
|
+
# (see +post_configure+, which calls this method by name so boot-time
|
|
180
|
+
# validation never depends on which +validate_secrets!+ wins the MRO).
|
|
181
|
+
#
|
|
182
|
+
# @raise [Rodauth::ConfigurationError] if the current or a previous secret is unusable
|
|
183
|
+
# @return [void]
|
|
184
|
+
def validate_account_id_obfuscation_secret!
|
|
185
|
+
secret = account_id_obfuscation_secret
|
|
186
|
+
|
|
187
|
+
if Rodauth::SecretGuard.blank?(secret)
|
|
188
|
+
raise Rodauth::ConfigurationError, account_id_obfuscation_secret_missing_error if Rodauth::SecretGuard.production?(self)
|
|
189
|
+
|
|
190
|
+
warn_dev_account_id_secret
|
|
191
|
+
fallback = development_account_id_obfuscation_secret_fallback
|
|
192
|
+
self.class.send(:define_method, :account_id_obfuscation_secret) { fallback }
|
|
193
|
+
secret = fallback
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
too_short = ->(value) { value.to_s.bytesize < Rodauth::Tools::AccountIdCipher::MIN_SECRET_BYTES }
|
|
197
|
+
if too_short.call(secret) || account_id_obfuscation_previous_secrets.values.any?(&too_short)
|
|
198
|
+
raise Rodauth::ConfigurationError, account_id_obfuscation_secret_too_short_error
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Force the version=>cipher map to build now so any other
|
|
202
|
+
# AccountIdCipher.new failure (e.g. a future stricter check) also fails
|
|
203
|
+
# closed at boot instead of lazily on first encode/decode.
|
|
204
|
+
begin
|
|
205
|
+
account_id_ciphers
|
|
206
|
+
rescue ArgumentError => e
|
|
207
|
+
raise Rodauth::ConfigurationError, e.message
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Backwards-compatible alias for +validate_account_id_obfuscation_secret!+.
|
|
212
|
+
#
|
|
213
|
+
# Note: +hmac_secret_guard+ and +jwt_secret_guard+ each define a
|
|
214
|
+
# +validate_secrets!+ of their own, so when this feature is co-enabled with
|
|
215
|
+
# either guard this name resolves to only one of them — collision-prone;
|
|
216
|
+
# not for boot. Boot-time validation does not rely on it (see
|
|
217
|
+
# +post_configure+); use +validate_account_id_obfuscation_secret!+ for an
|
|
218
|
+
# unambiguous manual call.
|
|
219
|
+
#
|
|
220
|
+
# @raise [Rodauth::ConfigurationError] if the current or a previous secret is unusable
|
|
221
|
+
# @return [void]
|
|
222
|
+
def validate_secrets!
|
|
223
|
+
validate_account_id_obfuscation_secret!
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# Backing method for the +account_id_ciphers+ auth_cached_method: a
|
|
229
|
+
# {version_char => AccountIdCipher} map. The current key_version is always
|
|
230
|
+
# present; previous secrets are added for decode-only rotation support.
|
|
231
|
+
def _account_id_ciphers
|
|
232
|
+
map = {}
|
|
233
|
+
account_id_obfuscation_previous_secrets.each do |version, old_secret|
|
|
234
|
+
map[version.to_s] = Rodauth::Tools::AccountIdCipher.new(old_secret)
|
|
235
|
+
end
|
|
236
|
+
map[account_id_obfuscation_key_version] =
|
|
237
|
+
Rodauth::Tools::AccountIdCipher.new(account_id_obfuscation_secret)
|
|
238
|
+
map
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Consume ACCOUNT_ID_SECRET from ENV (read + delete for security), unless an
|
|
242
|
+
# explicit secret was configured. Delegates to the same helper the sibling
|
|
243
|
+
# guards use, so a whitespace-only env value is treated as absent exactly
|
|
244
|
+
# like theirs.
|
|
245
|
+
def load_account_id_secret_from_env
|
|
246
|
+
Rodauth::SecretGuard.load_from_env!(self, :account_id_obfuscation)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The backward-compat guarantee rests on every version tag (current AND any
|
|
250
|
+
# +account_id_obfuscation_previous_secrets+ key) being a single, unique,
|
|
251
|
+
# non-digit char distinct from the separators; fail fast otherwise. A
|
|
252
|
+
# digit would be ambiguous with a legacy decimal id, and a duplicate would
|
|
253
|
+
# mean two secrets claim the same version, making rotation nondeterministic.
|
|
254
|
+
def validate_key_version!
|
|
255
|
+
versions = [account_id_obfuscation_key_version] + account_id_obfuscation_previous_secrets.keys
|
|
256
|
+
versions.each { |version| validate_version_char!(version) }
|
|
257
|
+
|
|
258
|
+
return if versions.tally.values.all? { |count| count == 1 }
|
|
259
|
+
|
|
260
|
+
raise Rodauth::ConfigurationError, account_id_obfuscation_key_version_error
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def validate_version_char!(version)
|
|
264
|
+
valid = version.is_a?(String) && version.length == 1 &&
|
|
265
|
+
version !~ /\d/ && version != token_separator && version != '_'
|
|
266
|
+
|
|
267
|
+
raise Rodauth::ConfigurationError, account_id_obfuscation_key_version_error unless valid
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Wrap the remember cookie so the id segment is obfuscated on the way out and
|
|
271
|
+
# restored on the way back in. remember.rb hardcodes '_' (not token_separator)
|
|
272
|
+
# as the id/key delimiter and splits with limit 2, so an HMAC key containing
|
|
273
|
+
# '_' stays intact and our version-tagged id (never contains '_', never starts
|
|
274
|
+
# with a digit) is safe. Legacy numeric cookies deobfuscate to nil -> passthrough.
|
|
275
|
+
#
|
|
276
|
+
# Installed directly on the Auth class (not as an ordinary module method) so
|
|
277
|
+
# the wrapper wins regardless of the order in which +remember+ and this
|
|
278
|
+
# feature are enabled: a method defined on the class itself always takes
|
|
279
|
+
# precedence over the included feature modules, whereas an ordinary module
|
|
280
|
+
# method would be shadowed by +remember+'s own +_set_/_get_remember_cookie+
|
|
281
|
+
# whenever this feature is enabled BEFORE +remember+ (silently leaving the
|
|
282
|
+
# cookie un-obfuscated).
|
|
283
|
+
#
|
|
284
|
+
# +internal_request+ re-runs +post_configure+ on an internally-created
|
|
285
|
+
# subclass of the already-configured Auth class. We must NOT re-install there:
|
|
286
|
+
# a second override on the subclass would resolve its +super+ to the parent
|
|
287
|
+
# class's override (double-obfuscating the id and crashing on
|
|
288
|
+
# +Integer("A...")+) instead of to remember.rb. Skipping the subclass leaves
|
|
289
|
+
# it inheriting the single parent-class wrapper for +_set_remember_cookie+,
|
|
290
|
+
# while +internal_request+'s own +_get_remember_cookie+ (reading the request
|
|
291
|
+
# param) still wins for internal requests since it sits higher in that
|
|
292
|
+
# subclass's ancestry.
|
|
293
|
+
def install_remember_cookie_obfuscation
|
|
294
|
+
return if defined?(Rodauth::InternalRequestMethods) && is_a?(Rodauth::InternalRequestMethods)
|
|
295
|
+
|
|
296
|
+
self.class.send(:define_method, :_set_remember_cookie) do |account_id, remember_key_value, deadline|
|
|
297
|
+
super(obfuscate_account_id(account_id), remember_key_value, deadline)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
self.class.send(:define_method, :_get_remember_cookie) do
|
|
301
|
+
raw = super()
|
|
302
|
+
next raw unless raw.is_a?(String)
|
|
303
|
+
|
|
304
|
+
segment, _sep, remainder = raw.partition('_')
|
|
305
|
+
!remainder.empty? && (real_id = deobfuscate_account_id(segment)) ? "#{real_id}_#{remainder}" : raw
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def warn_dev_account_id_secret
|
|
310
|
+
if respond_to?(:logger) && logger
|
|
311
|
+
logger.warn(account_id_obfuscation_secret_dev_warning)
|
|
312
|
+
else
|
|
313
|
+
warn(account_id_obfuscation_secret_dev_warning)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require_relative '../secret_guard'
|
|
7
|
+
|
|
5
8
|
module Rodauth
|
|
6
9
|
# Automatically sets hmac_secret based on HMAC_SECRET and validates it is properly
|
|
7
10
|
# configured before the application starts. This helps prevent deployment
|
|
@@ -9,23 +12,34 @@ module Rodauth
|
|
|
9
12
|
# particularly in production environments.
|
|
10
13
|
#
|
|
11
14
|
# By default, this feature checks during +post_configure+ that +hmac_secret+
|
|
12
|
-
# is set to a non-
|
|
15
|
+
# is set to a non-blank value. In production mode, it raises a
|
|
13
16
|
# ConfigurationError if the secret is missing. In development mode, it logs
|
|
14
17
|
# a warning and uses a fallback development secret.
|
|
15
18
|
#
|
|
19
|
+
# This feature and +jwt_secret_guard+ can be enabled together. Their shared
|
|
20
|
+
# logic lives in +Rodauth::SecretGuard+ and is keyed by secret kind, so each
|
|
21
|
+
# secret is validated independently at boot.
|
|
22
|
+
#
|
|
16
23
|
# @example Basic Configuration
|
|
17
24
|
# plugin :rodauth do
|
|
18
25
|
# enable :hmac_secret_guard
|
|
19
26
|
# end
|
|
20
27
|
#
|
|
21
|
-
# @example Customizing Production Detection
|
|
28
|
+
# @example Customizing Production Detection (fail-safe)
|
|
22
29
|
# plugin :rodauth do
|
|
23
30
|
# enable :hmac_secret_guard
|
|
24
|
-
#
|
|
25
|
-
# #
|
|
31
|
+
# # Treat an unset RACK_ENV as production so a misconfigured deploy fails
|
|
32
|
+
# # closed rather than silently using the development fallback secret:
|
|
33
|
+
# production_env_check proc { ENV.fetch('RACK_ENV', 'production') == 'production' }
|
|
34
|
+
# # Or force it:
|
|
26
35
|
# # production_env_check true
|
|
27
36
|
# end
|
|
28
37
|
#
|
|
38
|
+
# # The default already fails safe: an unset RACK_ENV is treated as
|
|
39
|
+
# # production. Avoid `proc { ENV['RACK_ENV'] == 'production' }` — when the
|
|
40
|
+
# # variable is unset that returns false and silently falls back to the
|
|
41
|
+
# # insecure development secret in what is really a production deploy.
|
|
42
|
+
#
|
|
29
43
|
# @example Customizing Error Messages
|
|
30
44
|
# plugin :rodauth do
|
|
31
45
|
# enable :hmac_secret_guard
|
|
@@ -39,6 +53,16 @@ module Rodauth
|
|
|
39
53
|
# development_hmac_secret_fallback 'my-custom-dev-secret'
|
|
40
54
|
# end
|
|
41
55
|
#
|
|
56
|
+
# # The default fallback is a random per-process value (SecureRandom.hex),
|
|
57
|
+
# # not a constant baked into source. Set an explicit value only if you need
|
|
58
|
+
# # HMAC output to be stable across restarts in development.
|
|
59
|
+
#
|
|
60
|
+
# @example Enforcing a Minimum Secret Length (production only)
|
|
61
|
+
# plugin :rodauth do
|
|
62
|
+
# enable :hmac_secret_guard
|
|
63
|
+
# minimum_secret_length 32 # reject short secrets in production; 0 disables (default)
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
42
66
|
# @example Disabling Validation
|
|
43
67
|
# plugin :rodauth do
|
|
44
68
|
# enable :hmac_secret_guard
|
|
@@ -49,8 +73,10 @@ module Rodauth
|
|
|
49
73
|
auth_value_method :hmac_secret_env_key, 'HMAC_SECRET'
|
|
50
74
|
auth_value_method :production_env_check, proc { ENV.fetch('RACK_ENV', 'production') == 'production' }
|
|
51
75
|
auth_value_method :validate_secrets_on_configure?, true
|
|
52
|
-
auth_value_method :
|
|
53
|
-
|
|
76
|
+
auth_value_method :minimum_secret_length, 0
|
|
77
|
+
# Random per-process fallback: never committed to source, and unstable across
|
|
78
|
+
# restarts so it can't be mistaken for a real, persistent secret.
|
|
79
|
+
auth_value_method :development_hmac_secret_fallback, SecureRandom.hex(32)
|
|
54
80
|
|
|
55
81
|
translatable_method :hmac_secret_missing_error, 'HMAC_SECRET environment variable must be set in production'
|
|
56
82
|
translatable_method :hmac_secret_dev_warning, '[rodauth] WARNING: Using default HMAC secret for development only'
|
|
@@ -58,62 +84,44 @@ module Rodauth
|
|
|
58
84
|
def post_configure
|
|
59
85
|
super
|
|
60
86
|
|
|
61
|
-
|
|
62
|
-
if
|
|
63
|
-
env_value = ENV.delete(hmac_secret_env_key)
|
|
64
|
-
self.class.send(:define_method, :hmac_secret) { env_value } if env_value && !env_value.empty?
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
validate_secrets! if validate_secrets_on_configure?
|
|
87
|
+
Rodauth::SecretGuard.load_from_env!(self, :hmac)
|
|
88
|
+
validate_hmac_secret! if validate_secrets_on_configure?
|
|
68
89
|
end
|
|
69
90
|
|
|
70
|
-
auth_methods :validate_secrets!, :production?
|
|
91
|
+
auth_methods :validate_secrets!, :validate_hmac_secret!, :production?
|
|
71
92
|
|
|
72
93
|
# Check if we're running in production environment.
|
|
73
94
|
#
|
|
74
95
|
# @return [Boolean] true if running in production mode based on production_env_check
|
|
75
96
|
def production?
|
|
76
|
-
|
|
77
|
-
when Proc
|
|
78
|
-
instance_exec(&v)
|
|
79
|
-
else
|
|
80
|
-
!!v
|
|
81
|
-
end
|
|
97
|
+
Rodauth::SecretGuard.production?(self)
|
|
82
98
|
end
|
|
83
99
|
|
|
84
|
-
# Validate that HMAC secret is properly configured.
|
|
85
|
-
#
|
|
86
|
-
#
|
|
100
|
+
# Validate that the HMAC secret is properly configured. Raises
|
|
101
|
+
# ConfigurationError in production if the secret is missing, blank, or (when
|
|
102
|
+
# +minimum_secret_length+ is set) too short. In development it warns and
|
|
103
|
+
# installs a fallback secret.
|
|
87
104
|
#
|
|
88
|
-
#
|
|
105
|
+
# This is the collision-free entry point: prefer it over +validate_secrets!+
|
|
106
|
+
# when both secret guards are enabled.
|
|
107
|
+
#
|
|
108
|
+
# @raise [Rodauth::ConfigurationError] if hmac_secret is unusable in production
|
|
89
109
|
# @return [void]
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
current_secret = hmac_secret
|
|
93
|
-
|
|
94
|
-
# Check if secret is missing or empty
|
|
95
|
-
return unless current_secret.nil? || (current_secret.respond_to?(:empty?) && current_secret.empty?)
|
|
96
|
-
raise Rodauth::ConfigurationError, hmac_secret_missing_error if production?
|
|
97
|
-
|
|
98
|
-
# In production, raise an error
|
|
99
|
-
|
|
100
|
-
# In development, warn and set a fallback
|
|
101
|
-
warn_dev_secret
|
|
102
|
-
self.class.send(:define_method, :hmac_secret) { development_hmac_secret_fallback }
|
|
110
|
+
def validate_hmac_secret!
|
|
111
|
+
Rodauth::SecretGuard.validate!(self, :hmac)
|
|
103
112
|
end
|
|
104
113
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
# Warn about using development secret.
|
|
108
|
-
# Logs to logger if available, otherwise to stderr.
|
|
114
|
+
# Backwards-compatible alias for +validate_hmac_secret!+.
|
|
109
115
|
#
|
|
116
|
+
# Note: +jwt_secret_guard+ defines a +validate_secrets!+ of its own, so when
|
|
117
|
+
# both guards are enabled this name resolves to only one of them. Boot-time
|
|
118
|
+
# validation does not rely on it (see +post_configure+); use
|
|
119
|
+
# +validate_hmac_secret!+ for an unambiguous manual call.
|
|
120
|
+
#
|
|
121
|
+
# @raise [Rodauth::ConfigurationError] if hmac_secret is unusable in production
|
|
110
122
|
# @return [void]
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
logger.warn(hmac_secret_dev_warning)
|
|
114
|
-
else
|
|
115
|
-
warn(hmac_secret_dev_warning)
|
|
116
|
-
end
|
|
123
|
+
def validate_secrets!
|
|
124
|
+
validate_hmac_secret!
|
|
117
125
|
end
|
|
118
126
|
end
|
|
119
127
|
end
|