From ebd29ce2116fe6b88dbafba0d8fde8cf37077c08 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 6 Jul 2026 07:14:51 -0700 Subject: [PATCH 1/4] feat(auth): PBKDF2 password hashing service (PasswordHasher) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds wheels.auth.PasswordHasher, the cross-engine password hashing service that unblocks the wheels generate auth scaffold (#3155, child of #2962). - PBKDF2-HMAC-SHA256 via javax.crypto.SecretKeyFactory (PBKDF2WithHmacSHA256) — byte-identical on Lucee, Adobe CF, and BoxLang by construction, so hashes survive engine migrations. - Defaults: 600000 iterations (OWASP 2023+), 16-byte SecureRandom salt, 256-bit derived key. - Self-describing modular-crypt storage format: $pbkdf2-sha256$i=$$ - verify() re-derives with the stored salt/iterations and compares raw digest bytes in constant time (MessageDigest.isEqual); returns false, never throws, on malformed/empty/unknown-format hashes. - needsRehash() flags hashes below the configured iteration count or with an unrecognized format for transparent work-factor upgrades. - init() validates iterations as a positive integer and throws Wheels.PasswordHasher.InvalidConfiguration otherwise. - Unicode passwords round-trip (UTF-8); empty password hashing is allowed by design — minimum-length policy lives in app validations. - Not auto-registered in DI (matches Authenticator): the generator will wire it in config/services.cfm. TDD: 24-spec PasswordHasherSpec written first and confirmed failing for the right reason before implementation. Co-Authored-By: Claude Fable 5 Signed-off-by: Peter Amiri --- changelog.d/3155-password-hasher.added.md | 1 + vendor/wheels/auth/PasswordHasher.cfc | 263 ++++++++++++++++++ .../tests/specs/auth/PasswordHasherSpec.cfc | 181 ++++++++++++ 3 files changed, 445 insertions(+) create mode 100644 changelog.d/3155-password-hasher.added.md create mode 100644 vendor/wheels/auth/PasswordHasher.cfc create mode 100644 vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc diff --git a/changelog.d/3155-password-hasher.added.md b/changelog.d/3155-password-hasher.added.md new file mode 100644 index 0000000000..1532aef5e8 --- /dev/null +++ b/changelog.d/3155-password-hasher.added.md @@ -0,0 +1 @@ +- Added `wheels.auth.PasswordHasher`, a cross-engine password hashing service using PBKDF2-HMAC-SHA256 (600,000 iterations by default per OWASP 2023+, 16-byte SecureRandom salt, 256-bit derived key) with a self-describing modular-crypt storage format (`$pbkdf2-sha256$i=$$`). `verify()` compares digests in constant time and returns `false` (never throws) on malformed input; `needsRehash()` enables transparent work-factor upgrades. Hashes are byte-identical across Lucee, Adobe CF, and BoxLang, so they survive engine migrations. Groundwork for `wheels generate auth` (#3155, #2962). diff --git a/vendor/wheels/auth/PasswordHasher.cfc b/vendor/wheels/auth/PasswordHasher.cfc new file mode 100644 index 0000000000..c4e2ceb9ac --- /dev/null +++ b/vendor/wheels/auth/PasswordHasher.cfc @@ -0,0 +1,263 @@ +/** + * Cross-engine password hashing service using PBKDF2-HMAC-SHA256. + * + * Produces and verifies self-describing, modular-crypt-style hashes: + * + * $pbkdf2-sha256$i=$$ + * + * One algorithm, one storage format: derivation goes through the JVM's + * javax.crypto.SecretKeyFactory ("PBKDF2WithHmacSHA256"), so the same + * (password, salt, iterations) always yields the same bytes on Lucee, + * Adobe CF, and BoxLang alike. Hashes are portable across engines and + * engine migrations, and the embedded iteration count lets deployments + * raise the work factor over time (see needsRehash()). + * + * Defaults: 600000 iterations (OWASP 2023+ recommendation for + * PBKDF2-HMAC-SHA256), 16-byte SecureRandom salt, 256-bit derived key. + * + * Passwords are UTF-8 encoded before derivation, so unicode passwords + * round-trip. Empty passwords hash and verify successfully by design — + * minimum-length policy belongs in application-level validations + * (e.g. validatesLengthOf() on the User model), not in the hasher. + * + * Usage: + * // Register during app init (config/services.cfm) + * injector().map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton(); + * + * // Hashing on signup / password change: + * user.passwordHash = service("passwordHasher").hash(params.password); + * + * // Verifying on login: + * if (service("passwordHasher").verify(params.password, user.passwordHash)) { ... } + * + * // Transparent work-factor upgrades after a successful verify: + * if (service("passwordHasher").needsRehash(user.passwordHash)) { + * user.passwordHash = service("passwordHasher").hash(params.password); + * } + * + * [section: Authentication] + * [category: Core] + */ +component output="false" { + + /** + * Creates a new PasswordHasher. + * + * @iterations PBKDF2 iteration count used by hash() and as the needsRehash() threshold. Must be a positive integer; construction throws Wheels.PasswordHasher.InvalidConfiguration otherwise. Default 600000 (OWASP 2023+). + */ + public PasswordHasher function init(numeric iterations = 600000) { + if (arguments.iterations <= 0 || arguments.iterations != Int(arguments.iterations)) { + throw( + type = "Wheels.PasswordHasher.InvalidConfiguration", + message = "PasswordHasher iterations must be a positive integer.", + extendedInfo = "Received `#arguments.iterations#`. Use the default (600000, the OWASP 2023+ recommendation for PBKDF2-HMAC-SHA256) unless you have measured a different work factor for your hardware." + ); + } + + variables.iterations = arguments.iterations; + variables.algorithmTag = "pbkdf2-sha256"; + variables.saltLengthBytes = 16; + variables.keyLengthBits = 256; + + // Cached Java handles. SecureRandom is documented thread-safe; + // MessageDigest is only used for its static isEqual(). SecretKeyFactory + // is NOT documented thread-safe, so $deriveKey() creates one per call — + // getInstance() cost is noise next to a 600k-iteration derivation. + variables.secureRandom = CreateObject("java", "java.security.SecureRandom").init(); + variables.messageDigest = CreateObject("java", "java.security.MessageDigest"); + + return this; + } + + /** + * Hash a password with a fresh random salt. + * + * Every call generates a new 16-byte SecureRandom salt, so hashing the + * same password twice yields different strings. The empty password is + * accepted by design; enforce minimum-length policy in your model + * validations instead. + * + * @password The plaintext password to hash (UTF-8 encoded before derivation). + * @return Self-describing hash string: $pbkdf2-sha256$i=$$. + */ + public string function hash(required string password) { + local.salt = $randomBytes(variables.saltLengthBytes); + local.derivedKey = $deriveKey( + password = arguments.password, + salt = local.salt, + iterations = variables.iterations, + keyLengthBits = variables.keyLengthBits + ); + + return "$" & variables.algorithmTag + & "$i=" & variables.iterations + & "$" & BinaryEncode(local.salt, "base64") + & "$" & BinaryEncode(local.derivedKey, "base64"); + } + + /** + * Verify a password against a stored hash. + * + * Re-derives the key using the salt and iteration count embedded in the + * stored hash (so hashes created under a different configured iteration + * count still verify) and compares the raw digest bytes in constant time + * via java.security.MessageDigest.isEqual(). + * + * Never throws: malformed, empty, truncated, or unknown-format hashes + * return false. + * + * @password The plaintext password to check. + * @hash The stored hash string produced by hash(). + * @return True if the password matches the stored hash. + */ + public boolean function verify(required string password, required string hash) { + try { + local.parsed = $parseHash(arguments.hash); + if (!local.parsed.valid) { + return false; + } + + local.candidate = $deriveKey( + password = arguments.password, + salt = local.parsed.salt, + iterations = local.parsed.iterations, + keyLengthBits = Len(local.parsed.derivedKey) * 8 + ); + + // Constant-time comparison of the raw digest bytes — never + // compare password hashes with string operators (timing leaks). + return variables.messageDigest.isEqual(local.candidate, local.parsed.derivedKey); + } catch (any e) { + // verify() is a boolean predicate on untrusted input: any parse or + // derivation error means "does not match", never an exception. + return false; + } + } + + /** + * Check whether a stored hash should be re-hashed under the current + * configuration. + * + * Returns true when the stored iteration count is below the configured + * one, or when the hash format/algorithm tag is unrecognized (including + * malformed hashes). Call after a successful verify() and re-hash the + * plaintext to transparently upgrade the work factor. + * + * @hash The stored hash string to inspect. + * @return True if the hash should be regenerated with hash(). + */ + public boolean function needsRehash(required string hash) { + local.parsed = $parseHash(arguments.hash); + if (!local.parsed.valid) { + return true; + } + return local.parsed.iterations < variables.iterations; + } + + /** + * Return the configured iteration count. + */ + public numeric function getIterations() { + return variables.iterations; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Parse a modular-crypt-style hash string into its components. + * + * Returns {valid, iterations, salt, derivedKey} where salt/derivedKey are + * byte arrays. Never throws: any structural problem (wrong segment count, + * unknown algorithm tag, non-numeric or non-positive iterations, invalid + * base64, empty salt/key) yields valid=false. + */ + private struct function $parseHash(required string hash) { + local.parsed = {valid = false, iterations = 0, salt = "", derivedKey = ""}; + + if (!Len(arguments.hash) || Left(arguments.hash, 1) != "$") { + return local.parsed; + } + + // Base64 never contains "$", so a well-formed hash splits into exactly + // four segments (ListToArray drops the leading empty element). + local.segments = ListToArray(arguments.hash, "$"); + if (ArrayLen(local.segments) != 4) { + return local.parsed; + } + + // Algorithm tag is lowercase by modular-crypt convention — compare + // case-sensitively (CFML == is case-insensitive, hence Compare()). + if (Compare(local.segments[1], variables.algorithmTag) != 0) { + return local.parsed; + } + + if (!REFind("^i=[1-9][0-9]*$", local.segments[2])) { + return local.parsed; + } + local.parsed.iterations = Val(ListLast(local.segments[2], "=")); + + try { + local.parsed.salt = BinaryDecode(local.segments[3], "base64"); + local.parsed.derivedKey = BinaryDecode(local.segments[4], "base64"); + } catch (any e) { + return local.parsed; + } + + if (Len(local.parsed.salt) == 0 || Len(local.parsed.derivedKey) == 0) { + return local.parsed; + } + + local.parsed.valid = true; + return local.parsed; + } + + /** + * Derive a PBKDF2-HMAC-SHA256 key for the given password and salt. + * + * Uses javax.crypto.SecretKeyFactory ("PBKDF2WithHmacSHA256"), which the + * JVM converts password characters to UTF-8 bytes for — byte-identical on + * every engine by construction. A fresh factory per call keeps this safe + * under the DI container's singleton scope (SecretKeyFactory instances + * are not documented thread-safe). + */ + private any function $deriveKey( + required string password, + required any salt, + required numeric iterations, + required numeric keyLengthBits + ) { + // Route through java.lang.String explicitly so toCharArray() resolves + // on every engine regardless of how CFML strings are wrapped. + local.passwordChars = CreateObject("java", "java.lang.String").init(arguments.password).toCharArray(); + + local.keySpec = CreateObject("java", "javax.crypto.spec.PBEKeySpec").init( + local.passwordChars, + arguments.salt, + JavaCast("int", arguments.iterations), + JavaCast("int", arguments.keyLengthBits) + ); + + try { + local.factory = CreateObject("java", "javax.crypto.SecretKeyFactory").getInstance("PBKDF2WithHmacSHA256"); + local.derivedKey = local.factory.generateSecret(local.keySpec).getEncoded(); + } finally { + // Zero the internal password copy held by the spec. + local.keySpec.clearPassword(); + } + + return local.derivedKey; + } + + /** + * Generate cryptographically secure random bytes. + */ + private any function $randomBytes(required numeric byteCount) { + // Allocate a zeroed byte[] of the right length, then fill it in place. + local.randomBytes = BinaryDecode(RepeatString("00", arguments.byteCount), "hex"); + variables.secureRandom.nextBytes(local.randomBytes); + return local.randomBytes; + } + +} diff --git a/vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc b/vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc new file mode 100644 index 0000000000..c2e18091b2 --- /dev/null +++ b/vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc @@ -0,0 +1,181 @@ +component extends="wheels.WheelsTest" { + + function run() { + + describe("PasswordHasher", function() { + + beforeEach(function() { + // Low iteration count keeps the suite fast; the algorithm is the + // same regardless of count. Default-count behavior is asserted + // in its own spec below. + hasher = new wheels.auth.PasswordHasher(iterations = 1000); + }); + + describe("init() validation", function() { + + it("throws InvalidConfiguration for zero iterations", function() { + expect(function() { + var svc = new wheels.auth.PasswordHasher(iterations = 0); + }).toThrow("Wheels.PasswordHasher.InvalidConfiguration"); + }); + + it("throws InvalidConfiguration for negative iterations", function() { + expect(function() { + var svc = new wheels.auth.PasswordHasher(iterations = -1); + }).toThrow("Wheels.PasswordHasher.InvalidConfiguration"); + }); + + it("throws InvalidConfiguration for non-integer iterations", function() { + expect(function() { + var svc = new wheels.auth.PasswordHasher(iterations = 1000.5); + }).toThrow("Wheels.PasswordHasher.InvalidConfiguration"); + }); + + it("defaults to 600000 iterations (OWASP 2023+)", function() { + var svc = new wheels.auth.PasswordHasher(); + var h = svc.hash("secret"); + expect(ListGetAt(h, 2, "$")).toBe("i=600000"); + }); + + }); + + describe("hash()", function() { + + it("produces the self-describing modular-crypt format", function() { + var h = hasher.hash("correct horse battery staple"); + // $pbkdf2-sha256$i=$$ + expect(Left(h, 1)).toBe("$"); + var parts = ListToArray(h, "$"); + expect(ArrayLen(parts)).toBe(4); + expect(parts[1]).toBe("pbkdf2-sha256"); + expect(parts[2]).toBe("i=1000"); + // Salt decodes to 16 random bytes, derived key to 32 bytes (256 bits) + expect(Len(BinaryDecode(parts[3], "base64"))).toBe(16); + expect(Len(BinaryDecode(parts[4], "base64"))).toBe(32); + }); + + it("produces different hashes for the same password (random salt)", function() { + var first = hasher.hash("same-password"); + var second = hasher.hash("same-password"); + expect(Compare(first, second)).notToBe(0); + // And both still verify + expect(hasher.verify("same-password", first)).toBeTrue(); + expect(hasher.verify("same-password", second)).toBeTrue(); + }); + + it("hashes an empty password (minimum-length policy lives in app validations)", function() { + var h = hasher.hash(""); + expect(hasher.verify("", h)).toBeTrue(); + expect(hasher.verify("not-empty", h)).toBeFalse(); + }); + + }); + + describe("verify()", function() { + + it("returns true for the correct password", function() { + var h = hasher.hash("s3cret!"); + expect(hasher.verify("s3cret!", h)).toBeTrue(); + }); + + it("returns false for the wrong password", function() { + var h = hasher.hash("s3cret!"); + expect(hasher.verify("wrong-password", h)).toBeFalse(); + }); + + it("is case-sensitive on the password", function() { + var h = hasher.hash("Secret"); + expect(hasher.verify("secret", h)).toBeFalse(); + }); + + it("round-trips unicode passwords via UTF-8 bytes", function() { + var unicodePassword = "pässwörd-契約-κωδικός"; + var h = hasher.hash(unicodePassword); + expect(hasher.verify(unicodePassword, h)).toBeTrue(); + expect(hasher.verify("passwoerd", h)).toBeFalse(); + }); + + it("verifies hashes produced under a different iteration count (stored count wins)", function() { + var older = new wheels.auth.PasswordHasher(iterations = 500); + var h = older.hash("migrate-me"); + // A hasher configured with more iterations still verifies the stored hash + expect(hasher.verify("migrate-me", h)).toBeTrue(); + }); + + it("returns false (never throws) for an empty hash", function() { + expect(hasher.verify("anything", "")).toBeFalse(); + }); + + it("returns false (never throws) for a non-hash string", function() { + expect(hasher.verify("anything", "not-a-hash-at-all")).toBeFalse(); + }); + + it("returns false (never throws) for a truncated hash", function() { + var h = hasher.hash("s3cret!"); + // Drop the derived-key segment entirely + var truncated = "$" & ListGetAt(h, 1, "$") & "$" & ListGetAt(h, 2, "$") & "$" & ListGetAt(h, 3, "$"); + expect(hasher.verify("s3cret!", truncated)).toBeFalse(); + }); + + it("returns false (never throws) for an unknown algorithm tag", function() { + var h = hasher.hash("s3cret!"); + var foreign = Replace(h, "pbkdf2-sha256", "argon2id"); + expect(hasher.verify("s3cret!", foreign)).toBeFalse(); + }); + + it("returns false (never throws) for invalid base64 in the hash", function() { + expect(hasher.verify("anything", "$pbkdf2-sha256$i=1000$!!!not-base64!!!$%%%also-bad%%%")).toBeFalse(); + }); + + it("returns false (never throws) for a zero-iterations hash", function() { + var h = hasher.hash("s3cret!"); + var doctored = Replace(h, "i=1000", "i=0"); + expect(hasher.verify("s3cret!", doctored)).toBeFalse(); + }); + + it("returns false when the format lacks the leading dollar sign", function() { + var h = hasher.hash("s3cret!"); + var noPrefix = Right(h, Len(h) - 1); + expect(hasher.verify("s3cret!", noPrefix)).toBeFalse(); + }); + + }); + + describe("needsRehash()", function() { + + it("returns false for a hash produced at the configured iteration count", function() { + var h = hasher.hash("s3cret!"); + expect(hasher.needsRehash(h)).toBeFalse(); + }); + + it("returns true when the stored iteration count is below the configured one", function() { + var older = new wheels.auth.PasswordHasher(iterations = 500); + var h = older.hash("migrate-me"); + expect(hasher.needsRehash(h)).toBeTrue(); + }); + + it("returns false when the stored iteration count exceeds the configured one", function() { + var stronger = new wheels.auth.PasswordHasher(iterations = 2000); + var h = stronger.hash("already-strong"); + expect(hasher.needsRehash(h)).toBeFalse(); + }); + + it("returns true for an unknown algorithm tag", function() { + var h = hasher.hash("s3cret!"); + var foreign = Replace(h, "pbkdf2-sha256", "argon2id"); + expect(hasher.needsRehash(foreign)).toBeTrue(); + }); + + it("returns true for a malformed hash", function() { + expect(hasher.needsRehash("")).toBeTrue(); + expect(hasher.needsRehash("not-a-hash")).toBeTrue(); + expect(hasher.needsRehash("$pbkdf2-sha256$i=1000$only-three-parts")).toBeTrue(); + }); + + }); + + }); + + } + +} From 81ac49b95794ac1732050dd1b881a25afc07dbea Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 6 Jul 2026 08:05:51 -0700 Subject: [PATCH 2/4] =?UTF-8?q?feat(cli):=20wheels=20generate=20auth=20?= =?UTF-8?q?=E2=80=94=20session/token/jwt=20scaffold=20on=20the=20auth=20pr?= =?UTF-8?q?imitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #3155 (child of #2962): a one-command authentication scaffold built on the wheels.auth primitives (PasswordHasher, Authenticator, Session/Token/Jwt strategies). Dispatch: new 'auth' case in generate() -> generateAuth() in Module.cfc, orchestrated by Scaffold.generateAuth() with templates under cli/lucli/templates/auth/. Flags: --model=User (default), --strategy=session| token|jwt (default session), --registration/--no-registration (default on, session only), --force. Session strategy (default) emits a User model (PBKDF2 hashing via the passwordHasher service, transient password property validated then hashed and scrubbed in beforeSave, authenticate() with transparent rehash-on-login, single-use SHA-256-digested reset tokens expiring after 2h), Sessions/ Passwords/Registrations controllers (super.config() first line, private filters, all-named verifies, injection-safe query-builder finders), startFormTag-based views, a create-users migration with a unique email index, and generated app specs. Token/jwt emit app/controllers/api/Sessions.cfc instead (opaque digested bearer tokens with revocation, or JwtService-signed JWTs whose WHEELS_JWT_SECRET fails loudly at startup; no server-side revocation, documented in the generated header). Route, service, and strategy wiring are injected between // wheels:generate-auth:* markers in config/routes.cfm, config/services.cfm (created if absent), and app/events/onapplicationstart.cfm, always before root/wildcard, and replaced in place on --force — never duplicated. Generated code is code-you-own: stamped headers, re-run --force + git diff to upgrade. Migrations are never overwritten. Token validator and strategy constructors hoist closures (Cross-Engine Invariant 5). Tests: cli/lucli/tests/specs/services/GenerateAuthSpec.cfc (31 specs) covers dispatch, all three strategies' file sets, --no-registration, force/refuse semantics, marker idempotency, comment-stripped super.config() scans, and the hoisted-validator guard. Full CLI suite: 1122 pass, 0 fail, 0 error. Docs: generate-auth section in the CLI code-generation reference and a 'Scaffold it in one command' lead-in on the authentication-patterns guide. Closes #3155 Co-Authored-By: Claude Fable 5 Signed-off-by: Peter Amiri --- changelog.d/3155-generate-auth.added.md | 1 + cli/lucli/Module.cfc | 92 +++- cli/lucli/services/Scaffold.cfc | 472 +++++++++++++++++- .../templates/auth/api-token-methods.txt | 19 + cli/lucli/templates/auth/bootstrap-jwt.txt | 20 + .../templates/auth/bootstrap-session.txt | 10 + cli/lucli/templates/auth/bootstrap-token.txt | 20 + .../auth/controller-api-sessions-jwt.txt | 56 +++ .../auth/controller-api-sessions-token.txt | 55 ++ .../templates/auth/controller-passwords.txt | 96 ++++ .../auth/controller-registrations.txt | 36 ++ .../templates/auth/controller-sessions.txt | 48 ++ cli/lucli/templates/auth/migration.txt | 52 ++ cli/lucli/templates/auth/model.txt | 92 ++++ cli/lucli/templates/auth/routes-api.txt | 6 + .../templates/auth/routes-registration.txt | 2 + cli/lucli/templates/auth/routes-session.txt | 6 + cli/lucli/templates/auth/services-api.txt | 8 + cli/lucli/templates/auth/services-session.txt | 8 + .../templates/auth/spec-api-sessions.txt | 31 ++ cli/lucli/templates/auth/spec-model.txt | 72 +++ .../auth/spec-sessions-controller.txt | 49 ++ .../templates/auth/view-passwords-edit.txt | 25 + .../templates/auth/view-passwords-new.txt | 23 + .../templates/auth/view-registrations-new.txt | 28 ++ .../templates/auth/view-sessions-new.txt | 26 + .../tests/specs/services/GenerateAuthSpec.cfc | 377 ++++++++++++++ .../wheels-commands/code-generation.mdx | 53 +- .../authentication-patterns.mdx | 20 + 29 files changed, 1800 insertions(+), 3 deletions(-) create mode 100644 changelog.d/3155-generate-auth.added.md create mode 100644 cli/lucli/templates/auth/api-token-methods.txt create mode 100644 cli/lucli/templates/auth/bootstrap-jwt.txt create mode 100644 cli/lucli/templates/auth/bootstrap-session.txt create mode 100644 cli/lucli/templates/auth/bootstrap-token.txt create mode 100644 cli/lucli/templates/auth/controller-api-sessions-jwt.txt create mode 100644 cli/lucli/templates/auth/controller-api-sessions-token.txt create mode 100644 cli/lucli/templates/auth/controller-passwords.txt create mode 100644 cli/lucli/templates/auth/controller-registrations.txt create mode 100644 cli/lucli/templates/auth/controller-sessions.txt create mode 100644 cli/lucli/templates/auth/migration.txt create mode 100644 cli/lucli/templates/auth/model.txt create mode 100644 cli/lucli/templates/auth/routes-api.txt create mode 100644 cli/lucli/templates/auth/routes-registration.txt create mode 100644 cli/lucli/templates/auth/routes-session.txt create mode 100644 cli/lucli/templates/auth/services-api.txt create mode 100644 cli/lucli/templates/auth/services-session.txt create mode 100644 cli/lucli/templates/auth/spec-api-sessions.txt create mode 100644 cli/lucli/templates/auth/spec-model.txt create mode 100644 cli/lucli/templates/auth/spec-sessions-controller.txt create mode 100644 cli/lucli/templates/auth/view-passwords-edit.txt create mode 100644 cli/lucli/templates/auth/view-passwords-new.txt create mode 100644 cli/lucli/templates/auth/view-registrations-new.txt create mode 100644 cli/lucli/templates/auth/view-sessions-new.txt create mode 100644 cli/lucli/tests/specs/services/GenerateAuthSpec.cfc diff --git a/changelog.d/3155-generate-auth.added.md b/changelog.d/3155-generate-auth.added.md new file mode 100644 index 0000000000..cf47d7f080 --- /dev/null +++ b/changelog.d/3155-generate-auth.added.md @@ -0,0 +1 @@ +- `wheels generate auth` — one-command authentication scaffold built on the `wheels.auth` primitives ([#3155](https://github.com/wheels-dev/wheels/issues/3155)). The default session strategy emits a `User` model with PBKDF2 password hashing via the `passwordHasher` service, `Sessions`/`Passwords`/`Registrations` controllers (registration on by default; disable with `--no-registration`), CSRF-safe `startFormTag` views, a create-users migration with a unique email index, marked route/service/strategy blocks injected into `config/routes.cfm`, `config/services.cfm`, and `app/events/onapplicationstart.cfm`, plus generated app specs. `--strategy=token` and `--strategy=jwt` emit an `api/Sessions.cfc` controller (opaque SHA-256-digested bearer tokens, or JWTs signed with `WHEELS_JWT_SECRET` that fail loudly at startup when the secret is missing). Generated code is code you own: every file carries a stamped header, and re-running with `--force` regenerates files and replaces the injected blocks in place without duplicating them. diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 25704db14b..a4ba2a1df5 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -529,6 +529,7 @@ component extends="modules.BaseModule" { out(" helper Generate a helper file in app/helpers/"); out(" snippets Generate common code pattern snippets (auth, soft-delete, api, etc.)"); out(" admin Generate admin CRUD interface for an existing model"); + out(" auth Generate a full authentication scaffold (session, token, or JWT)"); out(""); out("Examples:", "bold"); out(" wheels generate app myapp"); @@ -543,6 +544,8 @@ component extends="modules.BaseModule" { out(" wheels generate helper formatting"); out(" wheels generate snippets auth"); out(" wheels generate admin User"); + out(" wheels generate auth"); + out(" wheels generate auth --strategy=jwt"); return ""; } @@ -588,6 +591,8 @@ component extends="modules.BaseModule" { return generateSnippets(remaining); case "admin": return generateAdmin(remaining); + case "auth": + return generateAuth(remaining); default: out("Unknown generator type: #type#", "red"); out("Run 'wheels generate' for available types."); @@ -4048,6 +4053,90 @@ component extends="modules.BaseModule" { return ""; } + /** + * Generate a complete authentication scaffold on the wheels.auth + * primitives (issue ##3155). Session strategy (default) emits browser + * login/registration/password-reset; token and jwt emit an API + * sessions controller instead. + */ + private string function generateAuth(array args = []) { + var model = "User"; + var strategy = "session"; + var registration = true; + var force = false; + + for (var arg in arguments.args) { + if (arg == "--force") { + force = true; + } else if (arg == "--registration") { + registration = true; + } else if (arg == "--no-registration") { + registration = false; + } else if (left(arg, 8) == "--model=") { + model = trim(mid(arg, 9, len(arg))); + } else if (left(arg, 11) == "--strategy=") { + strategy = trim(mid(arg, 12, len(arg))); + } else if (left(arg, 2) == "--") { + out("Unknown option: #arg#", "red"); + out("Usage: wheels generate auth [ModelName] [--model=User] [--strategy=session|token|jwt] [--registration|--no-registration] [--force]", "yellow"); + throw(type = "Wheels.InvalidArguments", message = "Unknown option for generate auth: #arg#"); + } else { + // First bare positional is the model name (same as --model=). + model = trim(arg); + } + } + + if (!len(model)) { + model = "User"; + } + if (!listFindNoCase("session,token,jwt", strategy)) { + out("Unknown strategy: #strategy# (valid: session, token, jwt)", "red"); + throw(type = "Wheels.InvalidArguments", message = "Unknown auth strategy: #strategy#. Valid strategies: session, token, jwt."); + } + + out("Generating #strategy# authentication for #capitalize(model)#...", "cyan"); + out(""); + + var scaffold = getService("scaffold"); + var results = scaffold.generateAuth( + model = model, + strategy = strategy, + registration = registration, + force = force, + cliVersion = super.version() + ); + + if (results.success) { + for (var item in results.generated) { + var relPath = replace(item.path, variables.projectRoot & "/", ""); + printCreated("#item.type#: #relPath#"); + } + for (var note in results.skipped ?: []) { + out(" skip #note#", "yellow"); + } + out(""); + out("Authentication scaffold complete! Next steps:", "green"); + out(" 1. Run the migration: wheels migrate latest"); + if (strategy == "session") { + out(" 2. Restart or reload, then visit /login (and /register)."); + out(" 3. Protect actions with a filter that calls service(""authenticator"").authenticate(request)."); + } else if (strategy == "jwt") { + out(" 2. Set WHEELS_JWT_SECRET in .env (at least 32 random bytes) — startup fails loudly without it."); + out(" 3. Restart, then POST credentials to /api/session to receive a JWT."); + } else { + out(" 2. Restart, then POST credentials to /api/session to receive a bearer token."); + } + out(" Generated code is yours to edit — re-run with --force and review `git diff` to upgrade."); + } else { + out("Auth generation failed:", "red"); + for (var err in results.errors) { + out(" #err#", "red"); + } + } + + return ""; + } + /** * List all available snippet patterns */ @@ -7581,7 +7670,8 @@ component extends="modules.BaseModule" { variables.services.scaffold = new services.Scaffold( codeGenService = getService("codegen"), helpers = getService("helpers"), - projectRoot = variables.projectRoot + projectRoot = variables.projectRoot, + moduleRoot = variables.moduleRoot ); break; case "analysis": diff --git a/cli/lucli/services/Scaffold.cfc b/cli/lucli/services/Scaffold.cfc index 3df5497f1f..4a49e95903 100644 --- a/cli/lucli/services/Scaffold.cfc +++ b/cli/lucli/services/Scaffold.cfc @@ -11,11 +11,16 @@ component { public function init( required any codeGenService, required any helpers, - required string projectRoot + required string projectRoot, + string moduleRoot = "" ) { variables.codeGenService = arguments.codeGenService; variables.helpers = arguments.helpers; variables.projectRoot = arguments.projectRoot; + // Optional: only needed by generators that read bundled template + // directories directly (generateAuth). Ends with a trailing slash + // when provided (same convention as the Admin service). + variables.moduleRoot = arguments.moduleRoot; return this; } @@ -614,8 +619,473 @@ component { return {success: true, path: filePath, message: "Generated API controller test"}; } + /** + * Generate a complete authentication scaffold over the wheels.auth + * primitives (issue ##3155): User model with PBKDF2 password hashing, + * sessions/passwords/registrations controllers + views (session + * strategy), or an api/Sessions controller (token/jwt strategies), + * a create-table migration, marked route/service/strategy blocks + * injected into config + app events, and generated app specs. + * + * Generated code is code-you-own: every file carries a stamped header + * and re-running with force=true regenerates it (marker blocks are + * replaced in place, never duplicated). + */ + public struct function generateAuth( + string model = "User", + string strategy = "session", + boolean registration = true, + boolean force = false, + string cliVersion = "" + ) { + var results = {success: true, generated: [], skipped: [], errors: [], rollback: []}; + var nl = chr(10); + var t = chr(9); + + var strategyName = lCase(trim(arguments.strategy)); + if (!listFindNoCase("session,token,jwt", strategyName)) { + throw( + type = "Wheels.InvalidArguments", + message = "Unknown auth strategy: #arguments.strategy#. Valid strategies: session, token, jwt." + ); + } + if (!len(variables.moduleRoot)) { + throw( + type = "Wheels.InvalidArguments", + message = "The Scaffold service needs a moduleRoot to locate the auth templates." + ); + } + + var modelName = variables.helpers.capitalize(trim(arguments.model)); + var modelVar = lCase(left(modelName, 1)) & (len(modelName) > 1 ? mid(modelName, 2, len(modelName)) : ""); + var tableName = lCase(variables.helpers.pluralize(modelName)); + var isApi = strategyName != "session"; + var withRegistration = arguments.registration && !isApi; + + var ctx = { + modelName: modelName, + modelVar: modelVar, + tableName: tableName, + strategy: strategyName, + cliVersion: len(arguments.cliVersion) ? arguments.cliVersion : "dev", + generatedDate: dateFormat(now(), "yyyy-mm-dd") + }; + ctx.protectedApiToken = strategyName == "token" ? ",apiTokenDigest" : ""; + ctx.apiTokenMethods = strategyName == "token" ? $renderAuthTemplate("api-token-methods", ctx) : ""; + ctx.apiTokenColumn = strategyName == "token" + ? t & t & t & t & 't.string(columnNames="apiTokenDigest", allowNull=true, limit=64);' & nl + : ""; + // Emits `#linkTo(...)#` into the login view (## collapses to # in this + // CFC's string literal; the .txt templates are raw and keep single #). + ctx.registrationLink = withRegistration + ? '
##linkTo(route="register", text="Create an account")##' + : ""; + + try { + // 1. Model + $writeAuthFile( + relPath = "app/models/#modelName#.cfc", + content = $renderAuthTemplate("model", ctx), + force = arguments.force, + results = results, + label = "model" + ); + + // 2. Migration. Never overwritten (even with force) — rewriting an + // already-applied migration would desync the tracking table. + if (!migrationAlreadyExists(modelName)) { + var migrationDir = variables.projectRoot & "/app/migrator/migrations"; + if (!directoryExists(migrationDir)) { + directoryCreate(migrationDir, true); + } + var migrationPath = migrationDir & "/" & variables.helpers.generateMigrationTimestamp() + & "_create_" & tableName & "_table.cfc"; + fileWrite(migrationPath, $renderAuthTemplate("migration", ctx)); + arrayAppend(results.generated, {type: "migration", path: migrationPath}); + arrayAppend(results.rollback, migrationPath); + } else { + arrayAppend(results.skipped, "migration: create_#tableName#_table already exists (never overwritten — edit it directly)"); + } + + // 3. Controllers + views + specs per strategy + if (isApi) { + $writeAuthFile( + relPath = "app/controllers/api/Sessions.cfc", + content = $renderAuthTemplate("controller-api-sessions-#strategyName#", ctx), + force = arguments.force, + results = results, + label = "controller" + ); + $writeAuthFile( + relPath = "tests/specs/controllers/ApiSessionsControllerSpec.cfc", + content = $renderAuthTemplate("spec-api-sessions", ctx), + force = arguments.force, + results = results, + label = "test" + ); + arrayAppend(results.skipped, "registration: not applicable to the #strategyName# strategy (no browser sign-up flow)"); + } else { + $writeAuthFile( + relPath = "app/controllers/Sessions.cfc", + content = $renderAuthTemplate("controller-sessions", ctx), + force = arguments.force, + results = results, + label = "controller" + ); + $writeAuthFile( + relPath = "app/controllers/Passwords.cfc", + content = $renderAuthTemplate("controller-passwords", ctx), + force = arguments.force, + results = results, + label = "controller" + ); + $writeAuthFile( + relPath = "app/views/sessions/new.cfm", + content = $renderAuthTemplate("view-sessions-new", ctx), + force = arguments.force, + results = results, + label = "view" + ); + $writeAuthFile( + relPath = "app/views/passwords/new.cfm", + content = $renderAuthTemplate("view-passwords-new", ctx), + force = arguments.force, + results = results, + label = "view" + ); + $writeAuthFile( + relPath = "app/views/passwords/edit.cfm", + content = $renderAuthTemplate("view-passwords-edit", ctx), + force = arguments.force, + results = results, + label = "view" + ); + if (withRegistration) { + $writeAuthFile( + relPath = "app/controllers/Registrations.cfc", + content = $renderAuthTemplate("controller-registrations", ctx), + force = arguments.force, + results = results, + label = "controller" + ); + $writeAuthFile( + relPath = "app/views/registrations/new.cfm", + content = $renderAuthTemplate("view-registrations-new", ctx), + force = arguments.force, + results = results, + label = "view" + ); + } + $writeAuthFile( + relPath = "tests/specs/controllers/SessionsControllerSpec.cfc", + content = $renderAuthTemplate("spec-sessions-controller", ctx), + force = arguments.force, + results = results, + label = "test" + ); + } + + // Model spec (all strategies) + $writeAuthFile( + relPath = "tests/specs/models/#modelName#AuthSpec.cfc", + content = $renderAuthTemplate("spec-model", ctx), + force = arguments.force, + results = results, + label = "test" + ); + + // 4. Routes — marked block, replaced in place on --force. + var routesBlock = ""; + if (isApi) { + routesBlock = $renderAuthTemplate("routes-api", ctx); + } else { + ctx.registrationRoutes = withRegistration ? $renderAuthTemplate("routes-registration", ctx) : ""; + routesBlock = $renderAuthTemplate("routes-session", ctx); + } + $injectAuthBlock( + relPath = "config/routes.cfm", + block = routesBlock, + beginMarker = "// wheels:generate-auth:routes:begin", + endMarker = "// wheels:generate-auth:routes:end", + force = arguments.force, + results = results, + anchorMode = "routes", + label = "routes" + ); + + // 5. Service registrations — config/services.cfm (created if absent). + $injectAuthBlock( + relPath = "config/services.cfm", + block = $renderAuthTemplate(isApi ? "services-api" : "services-session", ctx), + beginMarker = "// wheels:generate-auth:services:begin", + endMarker = "// wheels:generate-auth:services:end", + force = arguments.force, + results = results, + anchorMode = "cfscript", + label = "services" + ); + + // 6. Strategy wiring — app/events/onapplicationstart.cfm (the DI + // container isn't available yet in config/app.cfm; see the auth + // chapter in the guides). + $injectAuthBlock( + relPath = "app/events/onapplicationstart.cfm", + block = $renderAuthTemplate("bootstrap-#strategyName#", ctx), + beginMarker = "// wheels:generate-auth:strategy:begin", + endMarker = "// wheels:generate-auth:strategy:end", + force = arguments.force, + results = results, + anchorMode = "cfscript", + label = "strategy" + ); + } catch (any e) { + results.success = false; + arrayAppend(results.errors, e.message); + if (e.type == "ScaffoldError") { + rollbackScaffold(results.rollback); + } + } + + return results; + } + // ── Private helpers ────────────────────────────── + /** + * Read and render a template from cli/lucli/templates/auth/. + * Simple {{key}} replacement — values are inserted verbatim. + */ + private string function $renderAuthTemplate(required string template, required struct context) { + var path = variables.moduleRoot & "templates/auth/" & arguments.template & ".txt"; + if (!fileExists(path)) { + throw(type = "ScaffoldError", message = "Auth template not found: #path#"); + } + var content = fileRead(path); + for (var key in arguments.context) { + if (isSimpleValue(arguments.context[key])) { + content = replaceNoCase(content, "{{" & key & "}}", arguments.context[key], "all"); + } + } + return content; + } + + /** + * Write a generated auth file. Existing files are skipped unless force + * is set; only newly created files are registered for rollback so a + * failed run never deletes a user's pre-existing file. + */ + private boolean function $writeAuthFile( + required string relPath, + required string content, + required boolean force, + required struct results, + required string label + ) { + var absPath = variables.projectRoot & "/" & arguments.relPath; + var existed = fileExists(absPath); + if (existed && !arguments.force) { + arrayAppend(arguments.results.skipped, "#arguments.label#: #arguments.relPath# already exists (use --force to overwrite)"); + return false; + } + var dir = getDirectoryFromPath(absPath); + if (!directoryExists(dir)) { + directoryCreate(dir, true); + } + fileWrite(absPath, arguments.content); + arrayAppend(arguments.results.generated, {type: arguments.label, path: absPath}); + if (!existed) { + arrayAppend(arguments.results.rollback, absPath); + } + return true; + } + + /** + * Inject (or, with force, replace in place) a marker-delimited block into + * a config file. anchorMode "routes" inserts inside the mapper() chain — + * at // CLI-Appends-Here, else before .root(), else before the last + * .end() — so the auth routes always precede root/wildcard. anchorMode + * "cfscript" inserts before the file's closing cfscript end tag + * (creating the file with a cfscript wrapper when absent). Tag tokens + * are chr(60)-concatenated below — a literal tag in a string or + * comment trips Lucee's tag scanner and crashes the whole bundle. + */ + private void function $injectAuthBlock( + required string relPath, + required string block, + required string beginMarker, + required string endMarker, + required boolean force, + required struct results, + required string anchorMode, + required string label + ) { + var nl = chr(10); + var t = chr(9); + var scriptOpenTag = chr(60) & "cfscript" & chr(62); + var scriptCloseTag = chr(60) & "/cfscript" & chr(62); + var absPath = variables.projectRoot & "/" & arguments.relPath; + var blockText = reReplace(arguments.block, "[\r\n]+$", ""); + + if (!fileExists(absPath)) { + if (arguments.anchorMode == "cfscript") { + var dir = getDirectoryFromPath(absPath); + if (!directoryExists(dir)) { + directoryCreate(dir, true); + } + fileWrite(absPath, scriptOpenTag & nl & $indentBlock(blockText, t) & nl & scriptCloseTag & nl); + arrayAppend(arguments.results.generated, {type: arguments.label, path: absPath}); + arrayAppend(arguments.results.rollback, absPath); + return; + } + arrayAppend( + arguments.results.skipped, + "#arguments.label#: #arguments.relPath# not found — add this block manually inside the mapper() chain, before .root()/.wildcard():" & nl & blockText + ); + return; + } + + var content = fileRead(absPath); + var beginPos = find(arguments.beginMarker, content); + + // Replace an existing block in place (idempotent under --force). + if (beginPos > 0) { + if (!arguments.force) { + arrayAppend(arguments.results.skipped, "#arguments.label#: block already present in #arguments.relPath# (use --force to regenerate)"); + return; + } + var endPos = find(arguments.endMarker, content, beginPos); + if (endPos == 0) { + arrayAppend(arguments.results.skipped, "#arguments.label#: begin marker without matching end marker in #arguments.relPath# — fix the file manually"); + return; + } + var regionStart = $lineStart(content, beginPos); + var regionEnd = $lineEnd(content, endPos + len(arguments.endMarker) - 1); + var indent = $lineIndent(content, beginPos); + content = left(content, regionStart - 1) + & $indentBlock(blockText, indent) & nl + & mid(content, regionEnd + 1, len(content)); + fileWrite(absPath, content); + arrayAppend(arguments.results.generated, {type: arguments.label, path: absPath}); + return; + } + + // First-time insertion. + if (arguments.anchorMode == "routes") { + var anchorPos = find("// CLI-Appends-Here", content); + if (anchorPos == 0) { + // Skip commented-out `.root(` lines (anti-pattern ##14) — the + // stock routes.cfm ships a commented example above the real one. + anchorPos = $findCodePosition(content, ".root("); + } + if (anchorPos == 0) { + var lastEnd = content.lastIndexOf(".end()"); + if (lastEnd >= 0) { + anchorPos = lastEnd + 1; + } + } + if (anchorPos == 0) { + arrayAppend( + arguments.results.skipped, + "#arguments.label#: could not find an insertion anchor (// CLI-Appends-Here, .root(), or .end()) in #arguments.relPath# — add this block manually inside the mapper() chain:" & nl & blockText + ); + return; + } + var insertLineStart = $lineStart(content, anchorPos); + var anchorIndent = $lineIndent(content, anchorPos); + content = left(content, insertLineStart - 1) + & $indentBlock(blockText, anchorIndent) & nl + & mid(content, insertLineStart, len(content)); + fileWrite(absPath, content); + arrayAppend(arguments.results.generated, {type: arguments.label, path: absPath}); + return; + } + + // cfscript mode: insert before the last closing tag, else append a block. + var closePos = content.lastIndexOf(scriptCloseTag); + if (closePos >= 0) { + content = left(content, closePos) + & nl & $indentBlock(blockText, t) & nl + & mid(content, closePos + 1, len(content)); + } else { + content = content & nl & scriptOpenTag & nl & $indentBlock(blockText, t) & nl & scriptCloseTag & nl; + } + fileWrite(absPath, content); + arrayAppend(arguments.results.generated, {type: arguments.label, path: absPath}); + } + + /** + * Position of the first occurrence of needle that is NOT on a + * line-comment (`// ...`) portion of its line. Returns 0 when only + * commented occurrences exist. + */ + private numeric function $findCodePosition(required string content, required string needle) { + var pos = find(arguments.needle, arguments.content); + while (pos > 0) { + var lineStartPos = $lineStart(arguments.content, pos); + var linePrefix = mid(arguments.content, lineStartPos, pos - lineStartPos); + if (!find("//", linePrefix)) { + return pos; + } + pos = find(arguments.needle, arguments.content, pos + 1); + } + return 0; + } + + /** + * 1-based index of the first character of the line containing pos. + */ + private numeric function $lineStart(required string content, required numeric pos) { + var i = arguments.pos; + while (i > 1 && mid(arguments.content, i - 1, 1) != chr(10)) { + i--; + } + return i; + } + + /** + * 1-based index of the newline terminating the line containing pos + * (or of the last character when the file ends without one). + */ + private numeric function $lineEnd(required string content, required numeric pos) { + var i = arguments.pos; + var total = len(arguments.content); + while (i <= total && mid(arguments.content, i, 1) != chr(10)) { + i++; + } + return i > total ? total : i; + } + + /** + * Leading whitespace of the line containing pos. + */ + private string function $lineIndent(required string content, required numeric pos) { + var i = $lineStart(arguments.content, arguments.pos); + var total = len(arguments.content); + var indent = ""; + while (i <= total) { + var ch = mid(arguments.content, i, 1); + if (ch == chr(9) || ch == " ") { + indent &= ch; + i++; + } else { + break; + } + } + return indent; + } + + /** + * Prefix every non-empty line of a block with the given indentation. + */ + private string function $indentBlock(required string block, required string indent) { + var lines = listToArray(replace(arguments.block, chr(13), "", "all"), chr(10), true); + var indented = []; + for (var line in lines) { + arrayAppend(indented, len(trim(line)) ? arguments.indent & line : line); + } + return arrayToList(indented, chr(10)); + } + /** * Detect the indentation used before a given position in content */ diff --git a/cli/lucli/templates/auth/api-token-methods.txt b/cli/lucli/templates/auth/api-token-methods.txt new file mode 100644 index 0000000000..4adee67967 --- /dev/null +++ b/cli/lucli/templates/auth/api-token-methods.txt @@ -0,0 +1,19 @@ + + /** + * Issue a new API token, replacing any previous one. Returns the + * plaintext token exactly once; only its SHA-256 digest is stored. + */ + public string function generateApiToken() { + var token = newSecureToken(); + this.apiTokenDigest = LCase(Hash(token, "SHA-256")); + this.save(validate=false, callbacks=false); + return token; + } + + /** + * Revoke the current API token. + */ + public void function revokeApiToken() { + this.apiTokenDigest = ""; + this.save(validate=false, callbacks=false); + } diff --git a/cli/lucli/templates/auth/bootstrap-jwt.txt b/cli/lucli/templates/auth/bootstrap-jwt.txt new file mode 100644 index 0000000000..fd8fc09861 --- /dev/null +++ b/cli/lucli/templates/auth/bootstrap-jwt.txt @@ -0,0 +1,20 @@ +// wheels:generate-auth:strategy:begin — generated by `wheels generate auth`; re-run with --force to regenerate +// Wire the JWT strategy into the authenticator once per app boot. +if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { + var auth = application.wo.service("authenticator"); + if (!auth.hasStrategy("jwt")) { + // Fail loudly at startup rather than issuing brute-forceable tokens: + // HMAC-SHA256 needs a secret of at least 32 bytes (RFC 7518 §3.2). + var jwtSecret = application.wo.env("WHEELS_JWT_SECRET", ""); + if (Len(jwtSecret) < 32) { + throw( + type="Wheels.Auth.JWT.MissingSecret", + message="WHEELS_JWT_SECRET is missing or shorter than 32 bytes.", + extendedInfo="Generate a random secret of at least 32 bytes (e.g. `openssl rand -base64 48`) and set it in .env — never commit it to source control." + ); + } + var jwtService = new wheels.auth.JwtService(secretKey=jwtSecret); + auth.registerStrategy(name="jwt", strategy=new wheels.auth.JwtStrategy(jwtService=jwtService)); + } +} +// wheels:generate-auth:strategy:end diff --git a/cli/lucli/templates/auth/bootstrap-session.txt b/cli/lucli/templates/auth/bootstrap-session.txt new file mode 100644 index 0000000000..ac264f2b58 --- /dev/null +++ b/cli/lucli/templates/auth/bootstrap-session.txt @@ -0,0 +1,10 @@ +// wheels:generate-auth:strategy:begin — generated by `wheels generate auth`; re-run with --force to regenerate +// Wire the session strategy into the authenticator once per app boot. +// registerStrategy() replaces same-name entries, so warm reloads can't stack duplicates. +if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { + var auth = application.wo.service("authenticator"); + if (!auth.hasStrategy("session")) { + auth.registerStrategy(name="session", strategy=application.wo.service("sessionStrategy")); + } +} +// wheels:generate-auth:strategy:end diff --git a/cli/lucli/templates/auth/bootstrap-token.txt b/cli/lucli/templates/auth/bootstrap-token.txt new file mode 100644 index 0000000000..53ef054487 --- /dev/null +++ b/cli/lucli/templates/auth/bootstrap-token.txt @@ -0,0 +1,20 @@ +// wheels:generate-auth:strategy:begin — generated by `wheels generate auth`; re-run with --force to regenerate +// Wire the bearer-token strategy into the authenticator once per app boot. +if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { + var auth = application.wo.service("authenticator"); + if (!auth.hasStrategy("token")) { + // Hoist the validator into a variable first — an inline function + // literal as a constructor named argument crashes Adobe ColdFusion. + var tokenValidator = function(required string token) { + // Look up by the token's SHA-256 digest — the raw token is never stored. + var digest = LCase(Hash(arguments.token, "SHA-256")); + var account = application.wo.model("{{modelName}}").where("apiTokenDigest", digest).first(); + if (IsObject(account)) { + return {id: account.key(), email: account.email}; + } + return false; + }; + auth.registerStrategy(name="token", strategy=new wheels.auth.TokenStrategy(validator=tokenValidator)); + } +} +// wheels:generate-auth:strategy:end diff --git a/cli/lucli/templates/auth/controller-api-sessions-jwt.txt b/cli/lucli/templates/auth/controller-api-sessions-jwt.txt new file mode 100644 index 0000000000..a7fbacdfe3 --- /dev/null +++ b/cli/lucli/templates/auth/controller-api-sessions-jwt.txt @@ -0,0 +1,56 @@ +/** + * api.Sessions — JWT session controller generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * POST /api/session exchanges credentials for a signed JWT. Clients send it + * back as `Authorization: Bearer `; the JwtStrategy registered in + * app/events/onapplicationstart.cfm verifies the signature and expiry. + * + * NOTE: JWTs are stateless — there is NO server-side revocation. An issued + * token stays valid until it expires (default 1 hour). If you need instant + * revocation, use `--strategy=token` (database-backed opaque tokens) instead. + * + * The signing secret comes from the WHEELS_JWT_SECRET environment variable + * (at least 32 random bytes; see .env). App startup fails loudly when it is + * missing or too short. + */ +component extends="Controller" { + + function config() { + // Inherit app-wide defaults from app/controllers/Controller.cfc. + super.config(); + // Bearer-token APIs are not cookie-authenticated, so CSRF does not + // apply — replace the inherited exception mode for this controller. + protectsFromForgery(with="ignore"); + provides("json"); + verifies(only="create", post=true, params="email,password"); + } + + // POST /api/session — verify credentials and mint a JWT + function create() { + var email = LCase(Trim(params.email ?: "")); + // Injection-safe query builder — never interpolate user input into a + // where string. + var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (IsObject({{modelVar}}) && {{modelVar}}.authenticate(params.password ?: "")) { + // Startup validated WHEELS_JWT_SECRET (see app/events/onapplicationstart.cfm), + // so construction here cannot fail on a running app. + var jwtService = new wheels.auth.JwtService(secretKey=env("WHEELS_JWT_SECRET")); + var token = jwtService.encode(claims={sub: {{modelVar}}.key(), email: {{modelVar}}.email}); + renderWith(data={token: token, tokenType: "Bearer", expiresIn: 3600}, status=201); + } else { + renderWith(data={error: "Invalid email or password."}, status=401); + } + } + + // DELETE /api/session — documentation endpoint: JWTs can't be revoked server-side + function delete() { + renderWith(data={ + message: "JWTs are stateless and cannot be revoked server-side. Discard the token client-side; it expires on its own." + }); + } + +} diff --git a/cli/lucli/templates/auth/controller-api-sessions-token.txt b/cli/lucli/templates/auth/controller-api-sessions-token.txt new file mode 100644 index 0000000000..40e5826a89 --- /dev/null +++ b/cli/lucli/templates/auth/controller-api-sessions-token.txt @@ -0,0 +1,55 @@ +/** + * api.Sessions — API token session controller generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * POST /api/session exchanges credentials for an opaque bearer token. The + * plaintext token is returned exactly once; only its SHA-256 digest is + * stored, so a database leak can't redeem it. Clients send it back as + * `Authorization: Bearer `; the TokenStrategy registered in + * app/events/onapplicationstart.cfm resolves it to the account. + * DELETE /api/session revokes the current token. + */ +component extends="Controller" { + + function config() { + // Inherit app-wide defaults from app/controllers/Controller.cfc. + super.config(); + // Bearer-token APIs are not cookie-authenticated, so CSRF does not + // apply — replace the inherited exception mode for this controller. + protectsFromForgery(with="ignore"); + provides("json"); + verifies(only="create", post=true, params="email,password"); + } + + // POST /api/session — verify credentials, mint and return a token + function create() { + var email = LCase(Trim(params.email ?: "")); + // Injection-safe query builder — never interpolate user input into a + // where string. + var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (IsObject({{modelVar}}) && {{modelVar}}.authenticate(params.password ?: "")) { + var token = {{modelVar}}.generateApiToken(); + renderWith(data={token: token, tokenType: "Bearer"}, status=201); + } else { + renderWith(data={error: "Invalid email or password."}, status=401); + } + } + + // DELETE /api/session — revoke the presented token + function delete() { + var result = service("authenticator").authenticate(request); + if (result.success) { + var {{modelVar}} = model("{{modelName}}").findByKey(result.principal.id); + if (IsObject({{modelVar}})) { + {{modelVar}}.revokeApiToken(); + } + renderWith(data={revoked: true}); + } else { + renderWith(data={error: result.error}, status=result.statusCode); + } + } + +} diff --git a/cli/lucli/templates/auth/controller-passwords.txt b/cli/lucli/templates/auth/controller-passwords.txt new file mode 100644 index 0000000000..94d20bc170 --- /dev/null +++ b/cli/lucli/templates/auth/controller-passwords.txt @@ -0,0 +1,96 @@ +/** + * Passwords — password reset controller generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * Reset flow: `new`/`create` request a reset link, `edit`/`update` redeem it. + * The single-use token travels as the route key (/passwords/[token]/edit). + * Only the token's SHA-256 digest is stored; it expires after 2 hours and is + * cleared when the password is changed. + */ +component extends="Controller" { + + function config() { + // Inherit CSRF protection (and other app-wide defaults) from + // app/controllers/Controller.cfc. + super.config(); + verifies(only="create", post=true, params="email"); + verifies(only="edit,update", params="key"); + } + + // GET /passwords/new — request a reset link + function new() { + } + + // POST /passwords — issue a single-use reset token + function create() { + var email = LCase(Trim(params.email ?: "")); + var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (IsObject({{modelVar}})) { + var token = {{modelVar}}.generateResetToken(); + // TODO: deliver the link by email, e.g.: + // sendEmail( + // to={{modelVar}}.email, + // from="no-reply@example.com", + // subject="Reset your password", + // template="/passwords/resetEmail", + // resetUrl=urlFor(route="editPassword", key=token, onlyPath=false) + // ); + } + // Same response whether or not the account exists — don't leak + // which email addresses are registered. + redirectTo(route="login", success="If that email address has an account, a reset link is on its way."); + } + + // GET /passwords/[token]/edit — reset form + function edit() { + // Unscoped so the view can render validation errors for it. + {{modelVar}} = findByResetToken(params.key ?: ""); + if (!IsObject({{modelVar}})) { + redirectTo(route="newPassword", error="That password reset link is invalid or has expired."); + } + } + + // PUT /passwords/[token] — set the new password and burn the token + function update() { + {{modelVar}} = findByResetToken(params.key ?: ""); + if (!IsObject({{modelVar}})) { + redirectTo(route="newPassword", error="That password reset link is invalid or has expired."); + return; + } + {{modelVar}}.password = params.{{modelVar}}.password ?: ""; + {{modelVar}}.passwordConfirmation = params.{{modelVar}}.passwordConfirmation ?: ""; + // Burn the token in the same save — it only clears if validation passes. + {{modelVar}}.resetTokenDigest = ""; + {{modelVar}}.resetTokenExpiresAt = ""; + if ({{modelVar}}.save()) { + redirectTo(route="login", success="Your password has been reset. Please log in."); + } else { + renderView(action="edit"); + } + } + + /** + * Look up the account for an unexpired reset token. Lookup is by the + * token's SHA-256 digest, so the raw token never touches the database. + * Returns false when the token is unknown or expired. + */ + private any function findByResetToken(required string token) { + if (!Len(arguments.token)) { + return false; + } + var digest = LCase(Hash(arguments.token, "SHA-256")); + var candidate = model("{{modelName}}").where("resetTokenDigest", digest).first(); + if ( + !IsObject(candidate) + || !IsDate(candidate.resetTokenExpiresAt ?: "") + || DateCompare(candidate.resetTokenExpiresAt, Now()) < 0 + ) { + return false; + } + return candidate; + } + +} diff --git a/cli/lucli/templates/auth/controller-registrations.txt b/cli/lucli/templates/auth/controller-registrations.txt new file mode 100644 index 0000000000..cf65bfb67d --- /dev/null +++ b/cli/lucli/templates/auth/controller-registrations.txt @@ -0,0 +1,36 @@ +/** + * Registrations — sign-up controller generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * Don't want public sign-up? Delete this controller (and its view/routes), + * or re-run the generator with `--no-registration`. + */ +component extends="Controller" { + + function config() { + // Inherit CSRF protection (and other app-wide defaults) from + // app/controllers/Controller.cfc. + super.config(); + verifies(only="create", post=true, params="{{modelVar}}"); + } + + // GET /register — sign-up form + function new() { + {{modelVar}} = model("{{modelName}}").new(); + } + + // POST /register — create the account and log straight in + function create() { + {{modelVar}} = model("{{modelName}}").new(params.{{modelVar}}); + if ({{modelVar}}.save()) { + service("sessionStrategy").login(principal={id: {{modelVar}}.key(), email: {{modelVar}}.email}); + redirectTo(route="root", success="Welcome!"); + } else { + renderView(action="new"); + } + } + +} diff --git a/cli/lucli/templates/auth/controller-sessions.txt b/cli/lucli/templates/auth/controller-sessions.txt new file mode 100644 index 0000000000..18290c6447 --- /dev/null +++ b/cli/lucli/templates/auth/controller-sessions.txt @@ -0,0 +1,48 @@ +/** + * Sessions — login/logout controller generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * Logging in is modeled as creating a "session" resource: GET /login shows + * the form, POST /login creates the session, DELETE /logout destroys it. + * Use `buttonTo(route="logout", method="delete", text="Log out")` in your + * layout for the logout control (links can't issue DELETE). + */ +component extends="Controller" { + + function config() { + // Inherit CSRF protection (and other app-wide defaults) from + // app/controllers/Controller.cfc. Skipping super.config() would + // silently drop protectsFromForgery() from the login form. + super.config(); + verifies(only="create", post=true, params="email,password"); + } + + // GET /login — login form + function new() { + } + + // POST /login — verify credentials and establish the session + function create() { + var email = LCase(Trim(params.email ?: "")); + // Injection-safe query builder — never interpolate user input into a + // where string (a quote in the email would rewrite the SQL). + var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (IsObject({{modelVar}}) && {{modelVar}}.authenticate(params.password ?: "")) { + service("sessionStrategy").login(principal={id: {{modelVar}}.key(), email: {{modelVar}}.email}); + redirectTo(route="root", success="Welcome back."); + } else { + flashInsert(error="Invalid email or password."); + renderView(action="new"); + } + } + + // DELETE /logout — destroy the session + function delete() { + service("sessionStrategy").logout(); + redirectTo(route="login", success="You have been logged out."); + } + +} diff --git a/cli/lucli/templates/auth/migration.txt b/cli/lucli/templates/auth/migration.txt new file mode 100644 index 0000000000..199e2e13dc --- /dev/null +++ b/cli/lucli/templates/auth/migration.txt @@ -0,0 +1,52 @@ +/** + * Migration: create_{{tableName}}_table — generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely before applying. Passwords are + * stored as PBKDF2 digests; reset tokens as SHA-256 digests. + */ +component extends="wheels.migrator.Migration" hint="create {{tableName}} table for authentication" { + + function up() { + var state = {}; + transaction { + try { + t = createTable(name="{{tableName}}"); + t.string(columnNames="email", allowNull=false, limit=255); + t.string(columnNames="passwordDigest", allowNull=false, limit=500); + t.string(columnNames="resetTokenDigest", allowNull=true, limit=64); + t.datetime(columnNames="resetTokenExpiresAt", allowNull=true); +{{apiTokenColumn}} t.timestamps(); + t.create(); + addIndex(table="{{tableName}}", columnNames="email", unique=true); + } catch (any e) { + state.exception = e; + } + + if (StructKeyExists(state, "exception")) { + transaction action="rollback"; + Throw(errorCode="1", detail=state.exception.detail, message=state.exception.message, type="any"); + } else { + transaction action="commit"; + } + } + } + + function down() { + var state = {}; + transaction { + try { + dropTable("{{tableName}}"); + } catch (any e) { + state.exception = e; + } + + if (StructKeyExists(state, "exception")) { + transaction action="rollback"; + Throw(errorCode="1", detail=state.exception.detail, message=state.exception.message, type="any"); + } else { + transaction action="commit"; + } + } + } + +} diff --git a/cli/lucli/templates/auth/model.txt b/cli/lucli/templates/auth/model.txt new file mode 100644 index 0000000000..1fca8cdf24 --- /dev/null +++ b/cli/lucli/templates/auth/model.txt @@ -0,0 +1,92 @@ +/** + * {{modelName}} — authentication model generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * Passwords are hashed with PBKDF2-HMAC-SHA256 via the framework's + * `passwordHasher` service (registered in config/services.cfm). The plaintext + * `password` property is transient: it is validated, hashed into + * `passwordDigest` by the beforeSave callback, then scrubbed. + */ +component extends="Model" { + + function config() { + // Never allow these to be set by mass assignment (params structs). + protectedProperties("passwordDigest,resetTokenDigest,resetTokenExpiresAt{{protectedApiToken}}"); + + // Validations. The transient `password` property is only validated when + // present, so updates that don't touch the password pass untouched. + validatesPresenceOf(property="email"); + validatesUniquenessOf(property="email"); + validatesFormatOf(property="email", regEx="^[\w\.\-\+]+@[\w\.\-]+\.\w+$"); + validatesPresenceOf(property="password", when="onCreate"); + validatesLengthOf(property="password", minimum=12, allowBlank=true); + validatesConfirmationOf(property="password"); + + // Normalize before validating, hash after validating. + beforeValidation("normalizeEmail"); + beforeSave("hashPasswordProperty"); + } + + /** + * Verify a plaintext password against the stored PBKDF2 digest. + * Transparently re-hashes after a successful verify when the stored + * work factor is below the hasher's current configuration. + */ + public boolean function authenticate(required string password) { + if (!Len(this.passwordDigest ?: "")) { + return false; + } + var hasher = service("passwordHasher"); + if (!hasher.verify(password=arguments.password, hash=this.passwordDigest)) { + return false; + } + if (hasher.needsRehash(this.passwordDigest)) { + this.passwordDigest = hasher.hash(arguments.password); + this.save(validate=false, callbacks=false); + } + return true; + } + + /** + * Issue a single-use password reset token, valid for 2 hours. + * Returns the plaintext token for delivery (e.g. by email); only its + * SHA-256 digest is stored, so a database leak can't redeem it. + */ + public string function generateResetToken() { + var token = newSecureToken(); + this.resetTokenDigest = LCase(Hash(token, "SHA-256")); + this.resetTokenExpiresAt = DateAdd("h", 2, Now()); + this.save(validate=false, callbacks=false); + return token; + } +{{apiTokenMethods}} + // ── Callbacks ───────────────────────────────────────────── + + private function normalizeEmail() { + if (StructKeyExists(this, "email") && IsSimpleValue(this.email)) { + this.email = LCase(Trim(this.email)); + } + } + + private function hashPasswordProperty() { + if (StructKeyExists(this, "password") && Len(this.password)) { + this.passwordDigest = service("passwordHasher").hash(this.password); + // Scrub the plaintext so it never persists or leaks in dumps. + StructDelete(this, "password"); + StructDelete(this, "passwordConfirmation"); + } + } + + /** + * 256-bit cryptographically secure random token, hex-encoded. + */ + private string function newSecureToken() { + var bytes = BinaryDecode(RepeatString("00", 32), "hex"); + CreateObject("java", "java.security.SecureRandom").init().nextBytes(bytes); + return LCase(BinaryEncode(bytes, "hex")); + } + +} diff --git a/cli/lucli/templates/auth/routes-api.txt b/cli/lucli/templates/auth/routes-api.txt new file mode 100644 index 0000000000..03168452ea --- /dev/null +++ b/cli/lucli/templates/auth/routes-api.txt @@ -0,0 +1,6 @@ +// wheels:generate-auth:routes:begin — generated by `wheels generate auth`; re-run with --force to regenerate +.namespace("api") + .post(name="session", pattern="/session", to="sessions##create") + .delete(name="logout", pattern="/session", to="sessions##delete") +.end() +// wheels:generate-auth:routes:end diff --git a/cli/lucli/templates/auth/routes-registration.txt b/cli/lucli/templates/auth/routes-registration.txt new file mode 100644 index 0000000000..6ee0f26567 --- /dev/null +++ b/cli/lucli/templates/auth/routes-registration.txt @@ -0,0 +1,2 @@ +.get(name="register", pattern="/register", to="registrations##new") +.post(name="registrations", pattern="/register", to="registrations##create") diff --git a/cli/lucli/templates/auth/routes-session.txt b/cli/lucli/templates/auth/routes-session.txt new file mode 100644 index 0000000000..c2ae036b32 --- /dev/null +++ b/cli/lucli/templates/auth/routes-session.txt @@ -0,0 +1,6 @@ +// wheels:generate-auth:routes:begin — generated by `wheels generate auth`; re-run with --force to regenerate +.get(name="login", pattern="/login", to="sessions##new") +.post(name="session", pattern="/login", to="sessions##create") +.delete(name="logout", pattern="/logout", to="sessions##delete") +{{registrationRoutes}}.resources(name="passwords", only="new,create,edit,update") +// wheels:generate-auth:routes:end diff --git a/cli/lucli/templates/auth/services-api.txt b/cli/lucli/templates/auth/services-api.txt new file mode 100644 index 0000000000..2612b470a2 --- /dev/null +++ b/cli/lucli/templates/auth/services-api.txt @@ -0,0 +1,8 @@ +// wheels:generate-auth:services:begin — generated by `wheels generate auth`; re-run with --force to regenerate +// PBKDF2 password hashing + the strategy-registry authenticator. +// The {{strategy}} strategy itself takes constructor arguments, so it is +// built and registered in app/events/onapplicationstart.cfm. +local.di = injector(); +local.di.map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton(); +local.di.map("authenticator").to("wheels.auth.Authenticator").asSingleton(); +// wheels:generate-auth:services:end diff --git a/cli/lucli/templates/auth/services-session.txt b/cli/lucli/templates/auth/services-session.txt new file mode 100644 index 0000000000..9201df76b9 --- /dev/null +++ b/cli/lucli/templates/auth/services-session.txt @@ -0,0 +1,8 @@ +// wheels:generate-auth:services:begin — generated by `wheels generate auth`; re-run with --force to regenerate +// PBKDF2 password hashing + the strategy-registry authenticator + session strategy. +// All singletons: one instance per application lifetime. +local.di = injector(); +local.di.map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton(); +local.di.map("authenticator").to("wheels.auth.Authenticator").asSingleton(); +local.di.map("sessionStrategy").to("wheels.auth.SessionStrategy").asSingleton(); +// wheels:generate-auth:services:end diff --git a/cli/lucli/templates/auth/spec-api-sessions.txt b/cli/lucli/templates/auth/spec-api-sessions.txt new file mode 100644 index 0000000000..89ea720d3d --- /dev/null +++ b/cli/lucli/templates/auth/spec-api-sessions.txt @@ -0,0 +1,31 @@ +/** + * api.Sessions controller spec — generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — extend it as your API auth flow grows. Run with + * `wheels test`. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("api.Sessions controller", () => { + + it("returns 401 for invalid credentials", () => { + var result = processRequest( + params = { + route: "apiSession", + format: "json", + email: "auth-spec-nobody@example.com", + password: "not-the-password-1" + }, + method = "post", + returnAs = "struct" + ); + expect(result.status).toBe(401); + }); + + }); + + } + +} diff --git a/cli/lucli/templates/auth/spec-model.txt b/cli/lucli/templates/auth/spec-model.txt new file mode 100644 index 0000000000..b156e826bf --- /dev/null +++ b/cli/lucli/templates/auth/spec-model.txt @@ -0,0 +1,72 @@ +/** + * {{modelName}} authentication spec — generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — extend it as your auth rules grow. Run with + * `wheels test` (requires the create_{{tableName}} migration applied to the + * test database). + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("{{modelName}} authentication", () => { + + afterEach(() => { + model("{{modelName}}").deleteAll(where="email LIKE 'auth-spec-%'", instantiate=false, softDelete=false); + }); + + it("requires email and password on create", () => { + var account = model("{{modelName}}").new(); + expect(account.valid()).toBeFalse(); + }); + + it("rejects a password shorter than 12 characters", () => { + var account = model("{{modelName}}").new({ + email: "auth-spec-short@example.com", + password: "too-short", + passwordConfirmation: "too-short" + }); + expect(account.valid()).toBeFalse(); + }); + + it("rejects a mismatched password confirmation", () => { + var account = model("{{modelName}}").new({ + email: "auth-spec-mismatch@example.com", + password: "a-long-password-123", + passwordConfirmation: "a-different-password-123" + }); + expect(account.valid()).toBeFalse(); + }); + + it("hashes the password into passwordDigest and authenticates round-trip", () => { + var account = model("{{modelName}}").create({ + email: "auth-spec-roundtrip@example.com", + password: "a-long-password-123", + passwordConfirmation: "a-long-password-123" + }); + expect(account.hasErrors()).toBeFalse(); + expect(Len(account.passwordDigest ?: "")).toBeGT(0); + expect(StructKeyExists(account, "password")).toBeFalse(); + expect(account.authenticate("a-long-password-123")).toBeTrue(); + expect(account.authenticate("not-the-password-1")).toBeFalse(); + }); + + it("enforces email uniqueness", () => { + model("{{modelName}}").create({ + email: "auth-spec-unique@example.com", + password: "a-long-password-123", + passwordConfirmation: "a-long-password-123" + }); + var duplicate = model("{{modelName}}").new({ + email: "auth-spec-unique@example.com", + password: "a-long-password-123", + passwordConfirmation: "a-long-password-123" + }); + expect(duplicate.valid()).toBeFalse(); + }); + + }); + + } + +} diff --git a/cli/lucli/templates/auth/spec-sessions-controller.txt b/cli/lucli/templates/auth/spec-sessions-controller.txt new file mode 100644 index 0000000000..5333cbc3c6 --- /dev/null +++ b/cli/lucli/templates/auth/spec-sessions-controller.txt @@ -0,0 +1,49 @@ +/** + * Sessions controller spec — generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — extend it as your login flow grows. Run with + * `wheels test`. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("Sessions controller", () => { + + beforeEach(() => { + // The create action is CSRF-protected (super.config() inherits + // protectsFromForgery from the base controller) and verified + // as POST-only, so simulate both. + variables.$originalMethod = request.cgi.request_method; + request.cgi.request_method = "POST"; + variables.csrfToken = CsrfGenerateToken(); + }); + + afterEach(() => { + request.cgi.request_method = variables.$originalMethod; + }); + + it("renders the login form", () => { + request.cgi.request_method = "GET"; + var result = processRequest(params={route: "login"}, method="get", returnAs="struct"); + expect(result.status).toBe(200); + }); + + it("re-renders the form with an error for invalid credentials", () => { + var loginParams = { + controller: "sessions", + action: "create", + email: "auth-spec-nobody@example.com", + password: "not-the-password-1", + authenticityToken: variables.csrfToken + }; + var sessionsController = application.wo.controller("sessions", loginParams); + sessionsController.processAction("create", loginParams); + expect(sessionsController.response()).toInclude("Invalid email or password"); + }); + + }); + + } + +} diff --git a/cli/lucli/templates/auth/view-passwords-edit.txt b/cli/lucli/templates/auth/view-passwords-edit.txt new file mode 100644 index 0000000000..59d06d85a6 --- /dev/null +++ b/cli/lucli/templates/auth/view-passwords-edit.txt @@ -0,0 +1,25 @@ + + + + +

Choose a new password

+ +#flashMessages()# + + #errorMessagesFor("{{modelVar}}")# + + +#startFormTag(route="password", method="put", key=params.key)# +
+ #passwordFieldTag(name="{{modelVar}}[password]", label="New password (12 characters minimum)")# +
+
+ #passwordFieldTag(name="{{modelVar}}[passwordConfirmation]", label="Confirm new password")# +
+
+ #submitTag(value="Reset password")# +
+#endFormTag()# + +
diff --git a/cli/lucli/templates/auth/view-passwords-new.txt b/cli/lucli/templates/auth/view-passwords-new.txt new file mode 100644 index 0000000000..887d9f2537 --- /dev/null +++ b/cli/lucli/templates/auth/view-passwords-new.txt @@ -0,0 +1,23 @@ + + + + +

Forgot your password?

+ +#flashMessages()# + +

Enter your email address and we'll send you a link to reset it.

+ +#startFormTag(route="passwords")# +
+ #emailFieldTag(name="email", label="Email", value=params.email)# +
+
+ #submitTag(value="Send reset link")# +
+#endFormTag()# + +

#linkTo(route="login", text="Back to log in")#

+ +
diff --git a/cli/lucli/templates/auth/view-registrations-new.txt b/cli/lucli/templates/auth/view-registrations-new.txt new file mode 100644 index 0000000000..5931f750b6 --- /dev/null +++ b/cli/lucli/templates/auth/view-registrations-new.txt @@ -0,0 +1,28 @@ + + + + +

Create your account

+ +#flashMessages()# +#errorMessagesFor("{{modelVar}}")# + +#startFormTag(route="registrations")# +
+ #emailField(objectName="{{modelVar}}", property="email", label="Email")# +
+
+ #passwordField(objectName="{{modelVar}}", property="password", label="Password (12 characters minimum)")# +
+
+ #passwordField(objectName="{{modelVar}}", property="passwordConfirmation", label="Confirm password")# +
+
+ #submitTag(value="Sign up")# +
+#endFormTag()# + +

Already have an account? #linkTo(route="login", text="Log in")#

+ +
diff --git a/cli/lucli/templates/auth/view-sessions-new.txt b/cli/lucli/templates/auth/view-sessions-new.txt new file mode 100644 index 0000000000..0b003bfb12 --- /dev/null +++ b/cli/lucli/templates/auth/view-sessions-new.txt @@ -0,0 +1,26 @@ + + + + +

Log in

+ +#flashMessages()# + +#startFormTag(route="session")# +
+ #emailFieldTag(name="email", label="Email", value=params.email)# +
+
+ #passwordFieldTag(name="password", label="Password")# +
+
+ #submitTag(value="Log in")# +
+#endFormTag()# + +

+ #linkTo(route="newPassword", text="Forgot your password?")#{{registrationLink}} +

+ +
diff --git a/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc b/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc new file mode 100644 index 0000000000..3346234d56 --- /dev/null +++ b/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc @@ -0,0 +1,377 @@ +/** + * Tests `wheels generate auth` (issue #3155) — the session/token/jwt + * authentication scaffold built on the wheels.auth primitives. + * + * Service-level coverage runs Scaffold.generateAuth() directly against + * isolated temp projects (one per strategy fixture). A small Module-level + * describe verifies the generate() dispatch reaches generateAuth. + */ +component extends="wheels.wheelstest.system.BaseSpec" { + + function beforeAll() { + variables.testHelper = new cli.lucli.tests.TestHelper(); + variables.moduleRoot = expandPath("/cli/lucli/"); + variables.helpers = new cli.lucli.services.Helpers(); + + // One temp project per strategy fixture, generated once up front. + variables.fixtures = {}; + variables.fixtures.session = $makeFixture({}); + variables.fixtures.noReg = $makeFixture({registration: false}); + variables.fixtures.token = $makeFixture({strategy: "token"}); + variables.fixtures.jwt = $makeFixture({strategy: "jwt"}); + + // Module-level dispatch fixture + variables.dispatchRoot = testHelper.scaffoldTempProject(expandPath("/")); + directoryCreate(variables.dispatchRoot & "/vendor/wheels", true, true); + variables.mod = new cli.lucli.Module(cwd = variables.dispatchRoot); + } + + function afterAll() { + for (var key in variables.fixtures) { + testHelper.cleanupTempProject(variables.fixtures[key].root); + } + testHelper.cleanupTempProject(variables.dispatchRoot); + } + + // ── Fixture helpers ───────────────────────────────────────── + + private struct function $makeFixture(required struct options) { + var root = testHelper.scaffoldTempProject(expandPath("/")); + var scaffold = $newScaffold(root); + var args = duplicate(arguments.options); + args.cliVersion = "test-version"; + var result = scaffold.generateAuth(argumentCollection = args); + return {root: root, scaffold: scaffold, result: result}; + } + + private any function $newScaffold(required string root) { + var templates = new cli.lucli.services.Templates( + helpers = variables.helpers, + projectRoot = arguments.root, + moduleRoot = variables.moduleRoot + ); + var codegen = new cli.lucli.services.CodeGen( + templateService = templates, + helpers = variables.helpers, + projectRoot = arguments.root + ); + return new cli.lucli.services.Scaffold( + codeGenService = codegen, + helpers = variables.helpers, + projectRoot = arguments.root, + moduleRoot = variables.moduleRoot + ); + } + + /** + * Strip CFML line, block, and tag comments so content assertions never + * match commented-out code (anti-pattern ##14). + */ + private string function $stripComments(required string source) { + var result = arguments.source; + result = reReplace(result, "", "", "all"); + result = reReplace(result, "/\*[\s\S]*?\*/", "", "all"); + result = reReplace(result, "//[^\r\n]*", "", "all"); + return result; + } + + private string function $strippedFile(required string path) { + return $stripComments(fileRead(arguments.path)); + } + + private numeric function $countOccurrences(required string haystack, required string needle) { + if (!len(arguments.needle)) return 0; + return (len(arguments.haystack) - len(replace(arguments.haystack, arguments.needle, "", "all"))) / len(arguments.needle); + } + + function run() { + + describe("generateAuth() — session strategy (default)", () => { + + it("succeeds and reports generated files", () => { + expect(fixtures.session.result.success).toBeTrue(); + expect(arrayLen(fixtures.session.result.generated)).toBeGTE(10); + }); + + it("emits the full session file set", () => { + var root = fixtures.session.root; + expect(fileExists(root & "/app/models/User.cfc")).toBeTrue(); + expect(fileExists(root & "/app/controllers/Sessions.cfc")).toBeTrue(); + expect(fileExists(root & "/app/controllers/Passwords.cfc")).toBeTrue(); + expect(fileExists(root & "/app/controllers/Registrations.cfc")).toBeTrue(); + expect(fileExists(root & "/app/views/sessions/new.cfm")).toBeTrue(); + expect(fileExists(root & "/app/views/registrations/new.cfm")).toBeTrue(); + expect(fileExists(root & "/app/views/passwords/new.cfm")).toBeTrue(); + expect(fileExists(root & "/app/views/passwords/edit.cfm")).toBeTrue(); + expect(fileExists(root & "/tests/specs/models/UserAuthSpec.cfc")).toBeTrue(); + expect(fileExists(root & "/tests/specs/controllers/SessionsControllerSpec.cfc")).toBeTrue(); + expect(fileExists(root & "/config/services.cfm")).toBeTrue(); + }); + + it("emits a create-users migration with digest columns, unique email index, and no api token column", () => { + var files = directoryList(fixtures.session.root & "/app/migrator/migrations", false, "name", "*_create_users_table.cfc"); + expect(arrayLen(files)).toBe(1); + var content = fileRead(fixtures.session.root & "/app/migrator/migrations/" & files[1]); + expect(content).toInclude('t.string(columnNames="email"'); + expect(content).toInclude('t.string(columnNames="passwordDigest"'); + expect(content).toInclude('t.string(columnNames="resetTokenDigest"'); + expect(content).toInclude('t.datetime(columnNames="resetTokenExpiresAt"'); + expect(content).toInclude("t.timestamps();"); + expect(content).toInclude('addIndex(table="users", columnNames="email", unique=true)'); + expect(content).notToInclude("apiTokenDigest"); + }); + + it("injects the marked auth route block before the wildcard route", () => { + var content = fileRead(fixtures.session.root & "/config/routes.cfm"); + expect(content).toInclude("wheels:generate-auth:routes:begin"); + expect(content).toInclude("wheels:generate-auth:routes:end"); + expect(content).toInclude('.get(name="login"'); + expect(content).toInclude('.delete(name="logout"'); + expect(content).toInclude('.resources(name="passwords", only="new,create,edit,update")'); + expect(content).toInclude('.get(name="register"'); + expect(find("wheels:generate-auth:routes:begin", content)).toBeLT(find(".wildcard()", content)); + }); + + it("wires passwordHasher, authenticator, and sessionStrategy singletons in config/services.cfm", () => { + var content = fileRead(fixtures.session.root & "/config/services.cfm"); + expect(content).toInclude("wheels:generate-auth:services:begin"); + expect(content).toInclude('map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton()'); + expect(content).toInclude('map("authenticator").to("wheels.auth.Authenticator").asSingleton()'); + expect(content).toInclude('map("sessionStrategy").to("wheels.auth.SessionStrategy").asSingleton()'); + }); + + it("registers the session strategy in app/events/onapplicationstart.cfm", () => { + var content = fileRead(fixtures.session.root & "/app/events/onapplicationstart.cfm"); + expect(content).toInclude("wheels:generate-auth:strategy:begin"); + expect(content).toInclude('registerStrategy(name="session"'); + }); + + it("calls super.config() first in every generated controller (##2960)", () => { + for (var name in ["Sessions", "Passwords", "Registrations"]) { + var stripped = $strippedFile(fixtures.session.root & "/app/controllers/" & name & ".cfc"); + expect(reFind("function config\(\)\s*\{\s*super\.config\(\);", stripped)).toBeGT( + 0, + name & ".cfc must call super.config() as the first statement of config()" + ); + } + }); + + it("hashes via the passwordHasher service and scrubs the transient password in the model", () => { + var stripped = $strippedFile(fixtures.session.root & "/app/models/User.cfc"); + expect(stripped).toInclude('beforeSave("hashPasswordProperty")'); + expect(stripped).toInclude('service("passwordHasher")'); + expect(stripped).toInclude("function authenticate("); + expect(stripped).toInclude("needsRehash"); + expect(stripped).toInclude('protectedProperties('); + }); + + it("uses the injection-safe query builder rather than interpolated where strings", () => { + var stripped = $strippedFile(fixtures.session.root & "/app/controllers/Sessions.cfc"); + expect(stripped).toInclude('.where("email", email)'); + expect(reFindNoCase("where\s*=\s*""[^""]*##", stripped)).toBe(0); + }); + + it("stamps every generated CFC with the code-you-own header", () => { + for (var rel in ["app/models/User.cfc", "app/controllers/Sessions.cfc", "app/controllers/Passwords.cfc"]) { + var content = fileRead(fixtures.session.root & "/" & rel); + expect(content).toInclude("wheels generate auth"); + expect(content).toInclude("--force"); + expect(content).toInclude("test-version"); + } + }); + + it("uses startFormTag-based forms with cfoutput in every view", () => { + // chr(60)-concat keeps a literal tag out of this source file — + // Lucee's tag scanner crashes the whole bundle otherwise. + var openingOutputTag = chr(60) & "cfoutput" & chr(62); + for (var rel in ["app/views/sessions/new.cfm", "app/views/registrations/new.cfm", "app/views/passwords/new.cfm", "app/views/passwords/edit.cfm"]) { + var content = fileRead(fixtures.session.root & "/" & rel); + expect(content).toInclude("startFormTag("); + expect(content).toInclude("endFormTag()"); + expect(content).toInclude(openingOutputTag); + } + }); + + it("never passes an inline closure as a constructor named argument (Cross-Engine Invariant 5)", () => { + var bootstrap = $stripComments(fileRead(fixtures.session.root & "/app/events/onapplicationstart.cfm")); + expect(reFindNoCase("new\s+wheels\.auth\.[A-Za-z]+\([^)]*=\s*function", bootstrap)).toBe(0); + }); + + }); + + describe("generateAuth() — --no-registration", () => { + + it("omits the Registrations controller, its view, and its routes", () => { + var root = fixtures.noReg.root; + expect(fixtures.noReg.result.success).toBeTrue(); + expect(fileExists(root & "/app/controllers/Registrations.cfc")).toBeFalse(); + expect(fileExists(root & "/app/views/registrations/new.cfm")).toBeFalse(); + var routes = fileRead(root & "/config/routes.cfm"); + expect(routes).notToInclude('to="registrations'); + expect(routes).notToInclude('.get(name="register"'); + expect(routes).toInclude('.get(name="login"'); + }); + + it("omits the sign-up link from the login view", () => { + var content = fileRead(fixtures.noReg.root & "/app/views/sessions/new.cfm"); + expect(content).notToInclude('route="register"'); + }); + + }); + + describe("generateAuth() — token strategy", () => { + + it("emits an API sessions controller and no browser views or registrations", () => { + var root = fixtures.token.root; + expect(fixtures.token.result.success).toBeTrue(); + expect(fileExists(root & "/app/controllers/api/Sessions.cfc")).toBeTrue(); + expect(fileExists(root & "/app/controllers/Sessions.cfc")).toBeFalse(); + expect(fileExists(root & "/app/controllers/Registrations.cfc")).toBeFalse(); + expect(fileExists(root & "/app/views/sessions/new.cfm")).toBeFalse(); + expect(fileExists(root & "/tests/specs/controllers/ApiSessionsControllerSpec.cfc")).toBeTrue(); + }); + + it("adds the apiTokenDigest column to the migration", () => { + var files = directoryList(fixtures.token.root & "/app/migrator/migrations", false, "name", "*_create_users_table.cfc"); + expect(arrayLen(files)).toBe(1); + var content = fileRead(fixtures.token.root & "/app/migrator/migrations/" & files[1]); + expect(content).toInclude('t.string(columnNames="apiTokenDigest"'); + }); + + it("stores only the SHA-256 digest and returns the plaintext token once", () => { + var model = $strippedFile(fixtures.token.root & "/app/models/User.cfc"); + expect(model).toInclude("function generateApiToken("); + expect(model).toInclude('Hash(token, "SHA-256")'); + var controller = $strippedFile(fixtures.token.root & "/app/controllers/api/Sessions.cfc"); + expect(reFind("function config\(\)\s*\{\s*super\.config\(\);", controller)).toBeGT(0); + expect(controller).toInclude("renderWith("); + }); + + it("hoists the token validator instead of inlining a closure into the constructor", () => { + var bootstrap = $stripComments(fileRead(fixtures.token.root & "/app/events/onapplicationstart.cfm")); + expect(bootstrap).toInclude("var tokenValidator = function"); + expect(bootstrap).toInclude("TokenStrategy(validator=tokenValidator)"); + expect(reFindNoCase("TokenStrategy\(\s*validator\s*=\s*function", bootstrap)).toBe(0); + }); + + it("injects api-namespaced session routes", () => { + var routes = fileRead(fixtures.token.root & "/config/routes.cfm"); + expect(routes).toInclude('.namespace("api")'); + expect(routes).toInclude("wheels:generate-auth:routes:begin"); + }); + + it("notes that the registration flag does not apply", () => { + var notes = arrayToList(fixtures.token.result.skipped, "|"); + expect(notes).toInclude("registration"); + }); + + }); + + describe("generateAuth() — jwt strategy", () => { + + it("emits an API sessions controller that mints JWTs via JwtService", () => { + var root = fixtures.jwt.root; + expect(fixtures.jwt.result.success).toBeTrue(); + expect(fileExists(root & "/app/controllers/api/Sessions.cfc")).toBeTrue(); + var controller = $strippedFile(root & "/app/controllers/api/Sessions.cfc"); + expect(controller).toInclude("wheels.auth.JwtService"); + expect(controller).toInclude("WHEELS_JWT_SECRET"); + expect(reFind("function config\(\)\s*\{\s*super\.config\(\);", controller)).toBeGT(0); + }); + + it("fails loudly at startup when WHEELS_JWT_SECRET is missing or short", () => { + var bootstrap = $stripComments(fileRead(fixtures.jwt.root & "/app/events/onapplicationstart.cfm")); + expect(bootstrap).toInclude("WHEELS_JWT_SECRET"); + expect(bootstrap).toInclude("throw("); + }); + + it("documents that JWTs have no server-side revocation", () => { + var content = fileRead(fixtures.jwt.root & "/app/controllers/api/Sessions.cfc"); + expect(content).toInclude("revocation"); + }); + + it("does not add the apiTokenDigest column", () => { + var files = directoryList(fixtures.jwt.root & "/app/migrator/migrations", false, "name", "*_create_users_table.cfc"); + var content = fileRead(fixtures.jwt.root & "/app/migrator/migrations/" & files[1]); + expect(content).notToInclude("apiTokenDigest"); + }); + + }); + + describe("generateAuth() — force and idempotency", () => { + + it("refuses to overwrite existing files without --force", () => { + var root = fixtures.session.root; + var before = fileRead(root & "/app/models/User.cfc"); + var result = fixtures.session.scaffold.generateAuth(cliVersion = "second-run"); + expect(arrayLen(result.skipped)).toBeGTE(1); + expect(fileRead(root & "/app/models/User.cfc")).toBe(before); + expect(fileRead(root & "/app/models/User.cfc")).notToInclude("second-run"); + }); + + it("re-running without --force does not duplicate the route, service, or strategy blocks", () => { + var root = fixtures.session.root; + expect($countOccurrences(fileRead(root & "/config/routes.cfm"), "wheels:generate-auth:routes:begin")).toBe(1); + expect($countOccurrences(fileRead(root & "/config/services.cfm"), "wheels:generate-auth:services:begin")).toBe(1); + expect($countOccurrences(fileRead(root & "/app/events/onapplicationstart.cfm"), "wheels:generate-auth:strategy:begin")).toBe(1); + }); + + it("--force overwrites files and replaces the injected blocks exactly once", () => { + var root = fixtures.session.root; + var result = fixtures.session.scaffold.generateAuth(force = true, cliVersion = "forced-run"); + expect(result.success).toBeTrue(); + expect(fileRead(root & "/app/models/User.cfc")).toInclude("forced-run"); + expect($countOccurrences(fileRead(root & "/config/routes.cfm"), "wheels:generate-auth:routes:begin")).toBe(1); + expect($countOccurrences(fileRead(root & "/config/services.cfm"), "wheels:generate-auth:services:begin")).toBe(1); + expect($countOccurrences(fileRead(root & "/app/events/onapplicationstart.cfm"), "wheels:generate-auth:strategy:begin")).toBe(1); + }); + + it("rejects an unknown strategy", () => { + expect(() => { + fixtures.session.scaffold.generateAuth(strategy = "basic"); + }).toThrow(); + }); + + }); + + describe("generateAuth() — custom model name", () => { + + it("respects --model for file names, table name, and route wiring", () => { + var root = testHelper.scaffoldTempProject(expandPath("/")); + try { + var scaffold = $newScaffold(root); + var result = scaffold.generateAuth(model = "Member", cliVersion = "test-version"); + expect(result.success).toBeTrue(); + expect(fileExists(root & "/app/models/Member.cfc")).toBeTrue(); + var files = directoryList(root & "/app/migrator/migrations", false, "name", "*_create_members_table.cfc"); + expect(arrayLen(files)).toBe(1); + var sessions = fileRead(root & "/app/controllers/Sessions.cfc"); + expect(sessions).toInclude('model("Member")'); + } finally { + testHelper.cleanupTempProject(root); + } + }); + + }); + + describe("wheels generate auth — Module dispatch", () => { + + it("reaches generateAuth from the generate() switch and writes the scaffold", () => { + // arg1= exercises the structured callerArgs dispatch path — the + // same handoff LuCLI produces for `wheels generate auth`. + mod.generate(arg1 = "auth"); + expect(fileExists(variables.dispatchRoot & "/app/controllers/Sessions.cfc")).toBeTrue(); + expect(fileExists(variables.dispatchRoot & "/app/models/User.cfc")).toBeTrue(); + }); + + it("throws Wheels.InvalidArguments for an unknown strategy", () => { + expect(() => { + mod.generate(arg1 = "auth", strategy = "basic"); + }).toThrow(type = "Wheels.InvalidArguments"); + }); + + }); + + } + +} diff --git a/web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/code-generation.mdx b/web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/code-generation.mdx index 2c2e7537e6..b286153f24 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/code-generation.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/code-generation.mdx @@ -1,6 +1,6 @@ --- title: "Code Generation" -description: wheels generate — models, controllers, scaffolds, migrations, routes, tests, properties, helpers, snippets, admin, and API resources, with attribute syntax and per-subcommand flags. +description: wheels generate — models, controllers, scaffolds, migrations, routes, tests, properties, helpers, snippets, admin, auth, and API resources, with attribute syntax and per-subcommand flags. type: reference sidebar: order: 2 @@ -44,6 +44,7 @@ Running `wheels generate` with no arguments prints the list of supported types a | `helper` | `h` | Helper CFC under `app/helpers/` | | `snippets` | — | Copies a named code pattern into the project | | `admin` | — | CRUD admin controller + views for an existing model (requires a running server) | +| `auth` | — | Full authentication scaffold on the `wheels.auth` primitives (session, token, or JWT strategy) | ## Attribute syntax @@ -377,6 +378,56 @@ wheels generate admin User After reload, the admin interface is available at `/admin/users`. +### `wheels generate auth` + +Generate a complete authentication scaffold on the built-in `wheels.auth` primitives (`PasswordHasher`, `Authenticator`, and the session/token/JWT strategies). + +#### Synopsis + +``` title="Synopsis" +wheels generate auth [ModelName] [--model=User] [--strategy=session|token|jwt] [--registration|--no-registration] [--force] +``` + +| Flag | Default | Description | +|---|---|---| +| `--model=` | `User` | Model (and table, pluralised) to generate. A bare positional name works too. | +| `--strategy=` | `session` | `session` (browser login), `token` (opaque API bearer tokens), or `jwt` (stateless signed tokens). | +| `--registration` / `--no-registration` | on | Include the public sign-up flow (session strategy only). | +| `--force` | off | Overwrite existing generated files and regenerate the injected config blocks in place. | + +#### Description + +The generated code is **code you own** — every file carries a stamped header, nothing is patched by framework upgrades. To pick up generator improvements later, re-run with `--force` on a clean branch and review the changes with `git diff`. The route, service, and strategy registrations are injected between `// wheels:generate-auth:*` marker comments and replaced in place on re-runs, never duplicated. The migration is never overwritten, even with `--force`. + +All strategies share the same `User` model: PBKDF2-HMAC-SHA256 hashing through the `passwordHasher` service (a transient `password` property is validated — presence on create, 12-character minimum, confirmation — then hashed into `passwordDigest` and scrubbed in a `beforeSave` callback), an `authenticate()` method with transparent rehash-on-login, and single-use SHA-256-digested password reset tokens that expire after 2 hours. + +**Session** (default) writes `Sessions`/`Passwords`/`Registrations` controllers (every `config()` starts with `super.config()`, so the base controller's CSRF protection stays active), `startFormTag`-based views, login/logout/register/password routes, and DI registrations for `passwordHasher`, `authenticator`, and `sessionStrategy` in `config/services.cfm`, with the strategy wired into the authenticator in `app/events/onapplicationstart.cfm`. + +**Token** writes `app/controllers/api/Sessions.cfc` instead (no views; the registration flag doesn't apply): `POST /api/session` exchanges credentials for an opaque bearer token returned exactly once — only its SHA-256 digest is stored — and `DELETE /api/session` revokes it. The `TokenStrategy` validator is registered at startup and resolves accounts by token digest. + +**JWT** writes an `api/Sessions.cfc` that mints tokens with `JwtService`, signed with the `WHEELS_JWT_SECRET` environment variable (at least 32 random bytes). Startup fails loudly when the secret is missing or too short. JWTs have **no server-side revocation** — an issued token stays valid until it expires; use `--strategy=token` if you need instant revocation. + +Both a model spec and a sessions-controller spec are generated under `tests/specs/` so the scaffold is covered by `wheels test` from day one. + +#### Example + +```bash +wheels generate auth +wheels migrate latest +wheels start +``` + +Writes: + +- `app/models/User.cfc` with hashing, validations, and `authenticate()` +- `app/migrator/migrations/_create_users_table.cfc` with a unique email index +- `app/controllers/Sessions.cfc`, `Passwords.cfc`, `Registrations.cfc` +- `app/views/sessions/new.cfm`, `registrations/new.cfm`, `passwords/new.cfm`, `passwords/edit.cfm` +- Marked blocks in `config/routes.cfm`, `config/services.cfm` (created if absent), and `app/events/onapplicationstart.cfm` +- `tests/specs/models/UserAuthSpec.cfc`, `tests/specs/controllers/SessionsControllerSpec.cfc` + +After migrating and restarting, `/login` and `/register` are live. See the [authentication patterns guide](../../../digging-deeper/authentication-patterns/) for how the pieces fit together and how to protect actions with a filter. + ## Common workflows ### Full CRUD resource from one command diff --git a/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx b/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx index 98156fd2f2..c8008a4e00 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx @@ -21,6 +21,26 @@ This page shows you how to wire authentication into a Wheels app using the built You should already know: Wheels controllers, filters, and the `session` scope (see [Controllers and Actions](/v4-0-0/basics/controllers-and-actions/)), plus the end-to-end login flow walked through in [Tutorial Part 6: Authentication](/v4-0-0/start-here/tutorial/06-authentication/). This page assumes you've read both and picks up where Part 6b leaves off. +## Scaffold it in one command + +Everything this page wires up by hand can be generated in one shot: + +```bash +wheels generate auth +wheels migrate latest +``` + +| Flag | Default | Description | +|---|---|---| +| `--model=` | `User` | Model (and table, pluralised) to generate. | +| `--strategy=` | `session` | `session` (browser login), `token` (opaque API bearer tokens), or `jwt` (stateless signed tokens). | +| `--registration` / `--no-registration` | on | Include the public sign-up flow (session strategy only). | +| `--force` | off | Overwrite generated files and regenerate the injected config blocks in place. | + +The session strategy emits a `User` model with PBKDF2 hashing via the `passwordHasher` service, `Sessions`/`Passwords`/`Registrations` controllers and views, a create-users migration, the route/service/strategy wiring described below, and app specs. The generated files are **code you own** — edit them freely, and re-run with `--force` plus a `git diff` review when you want to pick up generator improvements. See the [`wheels generate auth` reference](/v4-0-0/command-line-tools/wheels-commands/code-generation/#wheels-generate-auth) for the full file list. + +The rest of this page explains the primitives the scaffold sits on — read on to understand what was generated, customize it, or wire things up by hand. + ## The pieces Wheels ships four auth components under `wheels.auth.*`: From 2313810dd6235ba4f0bec84ed427e7cd3bccfa60 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 6 Jul 2026 08:44:02 -0700 Subject: [PATCH 3/4] fix(cli): make generate auth Adobe-safe and close the blank-password reset hole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review hardening for the wheels generate auth scaffold (#3155): - BLOCKER: all three bootstrap templates emitted template-level `var` into app/events/onapplicationstart.cfm. That file is $include()d from a framework function and Adobe CF rejects top-level `var` at compile time (the #3063 class), so every generated app 500'd on every request on Adobe 2018-2025. All bootstrap variables are now `local.`-scoped (valid on every engine); the guide's four init-hook snippets were teaching the same pattern and are fixed too, with a caution aside. - HIGH: submitting the reset form with a blank password burned the token, reported success, and left the old password valid (presence is onCreate-only and the hash callback skips blanks). Passwords##update now rejects blanks with a field error before clearing the token. - Revoke endpoint could never see the bearer token: request.cgi's allowlist (Global.cfc $cgiScope) omits http_authorization and request.headers never exists, so authenticate(request) always 401'd. The token controller now hands the Authorization header to the authenticator explicitly. - Login timing oracle: unknown emails skipped the PBKDF2 derivation entirely, leaking account existence. All three login controllers now run a dummy derivation when the account is missing, and their headers plus the CLI next-steps recommend wheels.middleware.RateLimiter on credential endpoints. - Generated Sessions spec called processAction("create", params) — the only parameter is includeFilters, so "create" silently disabled before-filters. Now calls processAction() bare. - Routes injection: dropped the last-.end() fallback, which could park routes after .wildcard() where they never match (anti-pattern #6); with no safe anchor the generator now skips with a manual-insert note. Scaffold rollback now runs on any failure, not just typed ScaffoldErrors. - Passwords controller header documents that reset does not invalidate live sessions; next steps call out wiring reset-email delivery. - GenerateAuthSpec grows from 31 to 43 specs: local.-scope guards for all three bootstraps, blank-reset guard ordering, timing dummy, explicit-header revoke, processAction shape, and the three routes-anchor edge paths. CLI suite: 1134 pass / 0 fail / 0 error locally (Lucee 7 + SQLite). Co-Authored-By: Claude Fable 5 Signed-off-by: Peter Amiri --- cli/lucli/Module.cfc | 5 + cli/lucli/services/Scaffold.cfc | 20 +-- cli/lucli/templates/auth/bootstrap-jwt.txt | 15 +- .../templates/auth/bootstrap-session.txt | 9 +- cli/lucli/templates/auth/bootstrap-token.txt | 13 +- .../auth/controller-api-sessions-jwt.txt | 13 ++ .../auth/controller-api-sessions-token.txt | 21 ++- .../templates/auth/controller-passwords.txt | 15 ++ .../templates/auth/controller-sessions.txt | 13 ++ .../auth/spec-sessions-controller.txt | 4 +- .../tests/specs/services/GenerateAuthSpec.cfc | 159 +++++++++++++++++- .../authentication-patterns.mdx | 44 ++--- 12 files changed, 284 insertions(+), 47 deletions(-) diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index a4ba2a1df5..7e18309f69 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -4120,11 +4120,16 @@ component extends="modules.BaseModule" { if (strategy == "session") { out(" 2. Restart or reload, then visit /login (and /register)."); out(" 3. Protect actions with a filter that calls service(""authenticator"").authenticate(request)."); + out(" 4. Wire reset-link email delivery in app/controllers/Passwords.cfc (see the TODO in create()) —"); + out(" until then no reset email is actually sent."); + out(" 5. Rate-limit POST /login in production (wheels.middleware.RateLimiter) — each attempt runs a full PBKDF2 derivation."); } else if (strategy == "jwt") { out(" 2. Set WHEELS_JWT_SECRET in .env (at least 32 random bytes) — startup fails loudly without it."); out(" 3. Restart, then POST credentials to /api/session to receive a JWT."); + out(" 4. Rate-limit POST /api/session in production (wheels.middleware.RateLimiter) — each attempt runs a full PBKDF2 derivation."); } else { out(" 2. Restart, then POST credentials to /api/session to receive a bearer token."); + out(" 3. Rate-limit POST /api/session in production (wheels.middleware.RateLimiter) — each attempt runs a full PBKDF2 derivation."); } out(" Generated code is yours to edit — re-run with --force and review `git diff` to upgrade."); } else { diff --git a/cli/lucli/services/Scaffold.cfc b/cli/lucli/services/Scaffold.cfc index 4a49e95903..4c4281d639 100644 --- a/cli/lucli/services/Scaffold.cfc +++ b/cli/lucli/services/Scaffold.cfc @@ -841,9 +841,11 @@ component { } catch (any e) { results.success = false; arrayAppend(results.errors, e.message); - if (e.type == "ScaffoldError") { - rollbackScaffold(results.rollback); - } + // Roll back on ANY failure, not just typed ScaffoldErrors — an IO + // error mid-run must not leave a half-generated scaffold behind. + // The rollback list only ever contains files THIS run created, so + // pre-existing user files are never deleted. + rollbackScaffold(results.rollback); } return results; @@ -977,16 +979,14 @@ component { // stock routes.cfm ships a commented example above the real one. anchorPos = $findCodePosition(content, ".root("); } - if (anchorPos == 0) { - var lastEnd = content.lastIndexOf(".end()"); - if (lastEnd >= 0) { - anchorPos = lastEnd + 1; - } - } + // Deliberately NO `.end()` fallback: the last `.end()` closes the + // mapper chain AFTER `.wildcard()`, so routes inserted there could + // never match (anti-pattern ##6). When neither anchor exists, make + // the user place the block instead of injecting dead routes. if (anchorPos == 0) { arrayAppend( arguments.results.skipped, - "#arguments.label#: could not find an insertion anchor (// CLI-Appends-Here, .root(), or .end()) in #arguments.relPath# — add this block manually inside the mapper() chain:" & nl & blockText + "#arguments.label#: could not find an insertion anchor (// CLI-Appends-Here or an uncommented .root()) in #arguments.relPath# — add this block manually inside the mapper() chain, before .root()/.wildcard():" & nl & blockText ); return; } diff --git a/cli/lucli/templates/auth/bootstrap-jwt.txt b/cli/lucli/templates/auth/bootstrap-jwt.txt index fd8fc09861..c0bb0b0fdd 100644 --- a/cli/lucli/templates/auth/bootstrap-jwt.txt +++ b/cli/lucli/templates/auth/bootstrap-jwt.txt @@ -1,20 +1,23 @@ // wheels:generate-auth:strategy:begin — generated by `wheels generate auth`; re-run with --force to regenerate // Wire the JWT strategy into the authenticator once per app boot. +// NOTE: `local.` scope, never template-level `var` — this file is included from +// inside a framework function and Adobe ColdFusion rejects top-level `var` at +// compile time (issue 3063), turning every request into an HTTP 500. if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { - var auth = application.wo.service("authenticator"); - if (!auth.hasStrategy("jwt")) { + local.auth = application.wo.service("authenticator"); + if (!local.auth.hasStrategy("jwt")) { // Fail loudly at startup rather than issuing brute-forceable tokens: // HMAC-SHA256 needs a secret of at least 32 bytes (RFC 7518 §3.2). - var jwtSecret = application.wo.env("WHEELS_JWT_SECRET", ""); - if (Len(jwtSecret) < 32) { + local.jwtSecret = application.wo.env("WHEELS_JWT_SECRET", ""); + if (Len(local.jwtSecret) < 32) { throw( type="Wheels.Auth.JWT.MissingSecret", message="WHEELS_JWT_SECRET is missing or shorter than 32 bytes.", extendedInfo="Generate a random secret of at least 32 bytes (e.g. `openssl rand -base64 48`) and set it in .env — never commit it to source control." ); } - var jwtService = new wheels.auth.JwtService(secretKey=jwtSecret); - auth.registerStrategy(name="jwt", strategy=new wheels.auth.JwtStrategy(jwtService=jwtService)); + local.jwtService = new wheels.auth.JwtService(secretKey=local.jwtSecret); + local.auth.registerStrategy(name="jwt", strategy=new wheels.auth.JwtStrategy(jwtService=local.jwtService)); } } // wheels:generate-auth:strategy:end diff --git a/cli/lucli/templates/auth/bootstrap-session.txt b/cli/lucli/templates/auth/bootstrap-session.txt index ac264f2b58..b685670b43 100644 --- a/cli/lucli/templates/auth/bootstrap-session.txt +++ b/cli/lucli/templates/auth/bootstrap-session.txt @@ -1,10 +1,13 @@ // wheels:generate-auth:strategy:begin — generated by `wheels generate auth`; re-run with --force to regenerate // Wire the session strategy into the authenticator once per app boot. // registerStrategy() replaces same-name entries, so warm reloads can't stack duplicates. +// NOTE: `local.` scope, never template-level `var` — this file is included from +// inside a framework function and Adobe ColdFusion rejects top-level `var` at +// compile time (issue 3063), turning every request into an HTTP 500. if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { - var auth = application.wo.service("authenticator"); - if (!auth.hasStrategy("session")) { - auth.registerStrategy(name="session", strategy=application.wo.service("sessionStrategy")); + local.auth = application.wo.service("authenticator"); + if (!local.auth.hasStrategy("session")) { + local.auth.registerStrategy(name="session", strategy=application.wo.service("sessionStrategy")); } } // wheels:generate-auth:strategy:end diff --git a/cli/lucli/templates/auth/bootstrap-token.txt b/cli/lucli/templates/auth/bootstrap-token.txt index 53ef054487..808e4bb4d2 100644 --- a/cli/lucli/templates/auth/bootstrap-token.txt +++ b/cli/lucli/templates/auth/bootstrap-token.txt @@ -1,11 +1,16 @@ // wheels:generate-auth:strategy:begin — generated by `wheels generate auth`; re-run with --force to regenerate // Wire the bearer-token strategy into the authenticator once per app boot. +// NOTE: `local.` scope, never template-level `var` — this file is included from +// inside a framework function and Adobe ColdFusion rejects top-level `var` at +// compile time (issue 3063), turning every request into an HTTP 500. if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { - var auth = application.wo.service("authenticator"); - if (!auth.hasStrategy("token")) { + local.auth = application.wo.service("authenticator"); + if (!local.auth.hasStrategy("token")) { // Hoist the validator into a variable first — an inline function // literal as a constructor named argument crashes Adobe ColdFusion. - var tokenValidator = function(required string token) { + // (`var` is fine INSIDE the closure body — the rule above only + // forbids it at template top level.) + local.tokenValidator = function(required string token) { // Look up by the token's SHA-256 digest — the raw token is never stored. var digest = LCase(Hash(arguments.token, "SHA-256")); var account = application.wo.model("{{modelName}}").where("apiTokenDigest", digest).first(); @@ -14,7 +19,7 @@ if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsIns } return false; }; - auth.registerStrategy(name="token", strategy=new wheels.auth.TokenStrategy(validator=tokenValidator)); + local.auth.registerStrategy(name="token", strategy=new wheels.auth.TokenStrategy(validator=local.tokenValidator)); } } // wheels:generate-auth:strategy:end diff --git a/cli/lucli/templates/auth/controller-api-sessions-jwt.txt b/cli/lucli/templates/auth/controller-api-sessions-jwt.txt index a7fbacdfe3..649011b6ec 100644 --- a/cli/lucli/templates/auth/controller-api-sessions-jwt.txt +++ b/cli/lucli/templates/auth/controller-api-sessions-jwt.txt @@ -16,6 +16,12 @@ * The signing secret comes from the WHEELS_JWT_SECRET environment variable * (at least 32 random bytes; see .env). App startup fails loudly when it is * missing or too short. + * + * NOTE: each login attempt costs a full PBKDF2 derivation, so throttle + * POST /api/session in production — both to slow credential stuffing and to + * keep the CPU cost bounded. Wheels ships wheels.middleware.RateLimiter: add + * an instance to `set(middleware=[...])` in config/settings.cfm or scope one + * to the /api routes in config/routes.cfm (see the middleware guide). */ component extends="Controller" { @@ -35,6 +41,13 @@ component extends="Controller" { // Injection-safe query builder — never interpolate user input into a // where string. var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (!IsObject({{modelVar}})) { + // Equalize timing for unknown emails: without this dummy PBKDF2 + // derivation, requests for nonexistent accounts return measurably + // faster than failed passwords, leaking which addresses are + // registered despite the uniform error message below. + service("passwordHasher").hash(params.password ?: ""); + } if (IsObject({{modelVar}}) && {{modelVar}}.authenticate(params.password ?: "")) { // Startup validated WHEELS_JWT_SECRET (see app/events/onapplicationstart.cfm), // so construction here cannot fail on a running app. diff --git a/cli/lucli/templates/auth/controller-api-sessions-token.txt b/cli/lucli/templates/auth/controller-api-sessions-token.txt index 40e5826a89..1cbcd0d0b6 100644 --- a/cli/lucli/templates/auth/controller-api-sessions-token.txt +++ b/cli/lucli/templates/auth/controller-api-sessions-token.txt @@ -11,6 +11,12 @@ * `Authorization: Bearer `; the TokenStrategy registered in * app/events/onapplicationstart.cfm resolves it to the account. * DELETE /api/session revokes the current token. + * + * NOTE: each login attempt costs a full PBKDF2 derivation, so throttle + * POST /api/session in production — both to slow credential stuffing and to + * keep the CPU cost bounded. Wheels ships wheels.middleware.RateLimiter: add + * an instance to `set(middleware=[...])` in config/settings.cfm or scope one + * to the /api routes in config/routes.cfm (see the middleware guide). */ component extends="Controller" { @@ -30,6 +36,13 @@ component extends="Controller" { // Injection-safe query builder — never interpolate user input into a // where string. var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (!IsObject({{modelVar}})) { + // Equalize timing for unknown emails: without this dummy PBKDF2 + // derivation, requests for nonexistent accounts return measurably + // faster than failed passwords, leaking which addresses are + // registered despite the uniform error message below. + service("passwordHasher").hash(params.password ?: ""); + } if (IsObject({{modelVar}}) && {{modelVar}}.authenticate(params.password ?: "")) { var token = {{modelVar}}.generateApiToken(); renderWith(data={token: token, tokenType: "Bearer"}, status=201); @@ -40,7 +53,13 @@ component extends="Controller" { // DELETE /api/session — revoke the presented token function delete() { - var result = service("authenticator").authenticate(request); + // Wheels' request.cgi copy is allowlisted (Global.cfc $cgiScope) and + // does NOT carry http_authorization, so hand the bearer header to the + // strategy explicitly instead of passing the raw request scope. + var headers = GetHttpRequestData(false).headers; + var result = service("authenticator").authenticate({ + cgi: {http_authorization: headers["Authorization"] ?: ""} + }); if (result.success) { var {{modelVar}} = model("{{modelName}}").findByKey(result.principal.id); if (IsObject({{modelVar}})) { diff --git a/cli/lucli/templates/auth/controller-passwords.txt b/cli/lucli/templates/auth/controller-passwords.txt index 94d20bc170..5c95f413dc 100644 --- a/cli/lucli/templates/auth/controller-passwords.txt +++ b/cli/lucli/templates/auth/controller-passwords.txt @@ -9,6 +9,12 @@ * The single-use token travels as the route key (/passwords/[token]/edit). * Only the token's SHA-256 digest is stored; it expires after 2 hours and is * cleared when the password is changed. + * + * NOTE: resetting the password does NOT invalidate sessions that are already + * logged in — the session strategy stores principals in the CFML session + * scope, and there is no server-side session registry to sweep. If you need + * "log out everywhere" on reset (e.g. after a compromise), add your own + * invalidation, such as a sessionVersion column compared in an auth filter. */ component extends="Controller" { @@ -62,6 +68,15 @@ component extends="Controller" { } {{modelVar}}.password = params.{{modelVar}}.password ?: ""; {{modelVar}}.passwordConfirmation = params.{{modelVar}}.passwordConfirmation ?: ""; + // Reject a blank password HERE: presence is only validated onCreate and + // the hash callback skips blanks, so without this guard a blank submit + // would burn the token, report success, and leave the OLD password in + // place — the worst outcome for someone resetting a compromised account. + if (!Len({{modelVar}}.password)) { + {{modelVar}}.addError(property="password", message="Password can't be blank."); + renderView(action="edit"); + return; + } // Burn the token in the same save — it only clears if validation passes. {{modelVar}}.resetTokenDigest = ""; {{modelVar}}.resetTokenExpiresAt = ""; diff --git a/cli/lucli/templates/auth/controller-sessions.txt b/cli/lucli/templates/auth/controller-sessions.txt index 18290c6447..c64e972875 100644 --- a/cli/lucli/templates/auth/controller-sessions.txt +++ b/cli/lucli/templates/auth/controller-sessions.txt @@ -9,6 +9,12 @@ * the form, POST /login creates the session, DELETE /logout destroys it. * Use `buttonTo(route="logout", method="delete", text="Log out")` in your * layout for the logout control (links can't issue DELETE). + * + * NOTE: each login attempt costs a full PBKDF2 derivation, so throttle + * POST /login in production — both to slow credential stuffing and to keep + * the CPU cost bounded. Wheels ships wheels.middleware.RateLimiter: add an + * instance to `set(middleware=[...])` in config/settings.cfm or scope one to + * the login route in config/routes.cfm (see the middleware guide). */ component extends="Controller" { @@ -30,6 +36,13 @@ component extends="Controller" { // Injection-safe query builder — never interpolate user input into a // where string (a quote in the email would rewrite the SQL). var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (!IsObject({{modelVar}})) { + // Equalize timing for unknown emails: without this dummy PBKDF2 + // derivation, requests for nonexistent accounts return measurably + // faster than failed passwords, leaking which addresses are + // registered despite the uniform error message below. + service("passwordHasher").hash(params.password ?: ""); + } if (IsObject({{modelVar}}) && {{modelVar}}.authenticate(params.password ?: "")) { service("sessionStrategy").login(principal={id: {{modelVar}}.key(), email: {{modelVar}}.email}); redirectTo(route="root", success="Welcome back."); diff --git a/cli/lucli/templates/auth/spec-sessions-controller.txt b/cli/lucli/templates/auth/spec-sessions-controller.txt index 5333cbc3c6..7f339f2130 100644 --- a/cli/lucli/templates/auth/spec-sessions-controller.txt +++ b/cli/lucli/templates/auth/spec-sessions-controller.txt @@ -37,8 +37,10 @@ component extends="wheels.WheelsTest" { password: "not-the-password-1", authenticityToken: variables.csrfToken }; + // The action comes from the params handed to controller() — + // processAction()'s only parameter is includeFilters. var sessionsController = application.wo.controller("sessions", loginParams); - sessionsController.processAction("create", loginParams); + sessionsController.processAction(); expect(sessionsController.response()).toInclude("Invalid email or password"); }); diff --git a/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc b/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc index 3346234d56..a20eefce07 100644 --- a/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc +++ b/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc @@ -197,6 +197,64 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(reFindNoCase("new\s+wheels\.auth\.[A-Za-z]+\([^)]*=\s*function", bootstrap)).toBe(0); }); + it("rejects a blank password on reset instead of burning the token (Passwords##update)", () => { + var stripped = $strippedFile(fixtures.session.root & "/app/controllers/Passwords.cfc"); + // Presence is only validated onCreate and the hash callback + // skips blanks — without this guard a blank submit clears the + // token, reports success, and keeps the old password valid. + expect(stripped).toInclude('addError(property="password"'); + var guardPos = find('addError(property="password"', stripped); + var burnPos = find('resetTokenDigest = ""', stripped); + expect(guardPos).toBeGT(0); + expect(burnPos).toBeGT(0); + expect(guardPos).toBeLT(burnPos, "the blank-password guard must run before the token is cleared"); + }); + + it("equalizes login timing with a dummy derivation when the email is unknown", () => { + var stripped = $strippedFile(fixtures.session.root & "/app/controllers/Sessions.cfc"); + expect(stripped).toInclude('service("passwordHasher").hash('); + }); + + it("emits a controller spec that calls processAction() with no positional action argument", () => { + var stripped = $strippedFile(fixtures.session.root & "/tests/specs/controllers/SessionsControllerSpec.cfc"); + // processAction()'s only parameter is includeFilters — a + // positional "create" would silently disable before-filters. + expect(stripped).toInclude("processAction()"); + expect(stripped).notToInclude('processAction("'); + }); + + }); + + describe("generateAuth() — bootstrap uses local.-scoped variables, never template-level var (##3063)", () => { + + // app/events/onapplicationstart.cfm is $include()d from a framework + // function; Adobe CF rejects top-level `var` in an included template + // at COMPILE time, turning every request into an HTTP 500. `var` + // inside the hoisted closure body is fine and stays. + it("session bootstrap", () => { + var bootstrap = $stripComments(fileRead(fixtures.session.root & "/app/events/onapplicationstart.cfm")); + expect(bootstrap).toInclude("local.auth = "); + expect(bootstrap).notToInclude("var auth"); + }); + + it("token bootstrap", () => { + var bootstrap = $stripComments(fileRead(fixtures.token.root & "/app/events/onapplicationstart.cfm")); + expect(bootstrap).toInclude("local.auth = "); + expect(bootstrap).toInclude("local.tokenValidator = "); + expect(bootstrap).notToInclude("var auth"); + expect(bootstrap).notToInclude("var tokenValidator"); + }); + + it("jwt bootstrap", () => { + var bootstrap = $stripComments(fileRead(fixtures.jwt.root & "/app/events/onapplicationstart.cfm")); + expect(bootstrap).toInclude("local.auth = "); + expect(bootstrap).toInclude("local.jwtSecret = "); + expect(bootstrap).toInclude("local.jwtService = "); + expect(bootstrap).notToInclude("var auth"); + expect(bootstrap).notToInclude("var jwtSecret"); + expect(bootstrap).notToInclude("var jwtService"); + }); + }); describe("generateAuth() — --no-registration", () => { @@ -249,11 +307,26 @@ component extends="wheels.wheelstest.system.BaseSpec" { it("hoists the token validator instead of inlining a closure into the constructor", () => { var bootstrap = $stripComments(fileRead(fixtures.token.root & "/app/events/onapplicationstart.cfm")); - expect(bootstrap).toInclude("var tokenValidator = function"); - expect(bootstrap).toInclude("TokenStrategy(validator=tokenValidator)"); + expect(bootstrap).toInclude("local.tokenValidator = function"); + expect(bootstrap).toInclude("TokenStrategy(validator=local.tokenValidator)"); expect(reFindNoCase("TokenStrategy\(\s*validator\s*=\s*function", bootstrap)).toBe(0); }); + it("equalizes login timing with a dummy derivation when the email is unknown", () => { + var stripped = $strippedFile(fixtures.token.root & "/app/controllers/api/Sessions.cfc"); + expect(stripped).toInclude('service("passwordHasher").hash('); + }); + + it("hands the Authorization header to the authenticator explicitly on revoke", () => { + // request.cgi is allowlisted (Global.cfc $cgiScope) and omits + // http_authorization — passing the raw request scope would 401 + // every revoke. + var stripped = $strippedFile(fixtures.token.root & "/app/controllers/api/Sessions.cfc"); + expect(stripped).toInclude("GetHttpRequestData"); + expect(stripped).toInclude("http_authorization"); + expect(stripped).notToInclude(".authenticate(request)"); + }); + it("injects api-namespaced session routes", () => { var routes = fileRead(fixtures.token.root & "/config/routes.cfm"); expect(routes).toInclude('.namespace("api")'); @@ -296,6 +369,11 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(content).notToInclude("apiTokenDigest"); }); + it("equalizes login timing with a dummy derivation when the email is unknown", () => { + var stripped = $strippedFile(fixtures.jwt.root & "/app/controllers/api/Sessions.cfc"); + expect(stripped).toInclude('service("passwordHasher").hash('); + }); + }); describe("generateAuth() — force and idempotency", () => { @@ -334,6 +412,83 @@ component extends="wheels.wheelstest.system.BaseSpec" { }); + describe("generateAuth() — routes anchor handling", () => { + + it("falls back to the first uncommented .root( when the CLI-Appends-Here marker is missing", () => { + var root = testHelper.scaffoldTempProject(expandPath("/")); + try { + var routesPath = root & "/config/routes.cfm"; + var nl = chr(10); + // No marker; a commented-out .root( example above the real one, + // mirroring the stock app template. + fileWrite( + routesPath, + "// routes" & nl + & "mapper()" & nl + & chr(9) & "// .root(to = ""home####index"", method = ""get"")" & nl + & chr(9) & ".wildcard()" & nl + & chr(9) & ".root(method = ""get"")" & nl + & chr(9) & ".end();" & nl + ); + var result = $newScaffold(root).generateAuth(cliVersion = "test-version"); + expect(result.success).toBeTrue(); + var written = fileRead(routesPath); + expect(written).toInclude("wheels:generate-auth:routes:begin"); + // $findCodePosition skips the commented-out .root( example and + // anchors on the real one. + expect(find("wheels:generate-auth:routes:begin", written)).toBeLT(find('.root(method = "get")', written)); + } finally { + testHelper.cleanupTempProject(root); + } + }); + + it("skips with a manual-insert note instead of injecting dead routes when no anchor exists", () => { + var root = testHelper.scaffoldTempProject(expandPath("/")); + try { + var routesPath = root & "/config/routes.cfm"; + // Hand-edited file: no CLI-Appends-Here and the only .root( is + // commented out. An .end() fallback would have parked the auth + // routes after .wildcard(), where they could never match + // (anti-pattern ##6) — refusing is the only safe move. + var scriptOpen = chr(60) & "cfscript" & chr(62); + var scriptClose = chr(60) & "/cfscript" & chr(62); + var nl = chr(10); + fileWrite( + routesPath, + scriptOpen & nl + & "mapper()" & nl + & chr(9) & "// .root(to = ""home####index"", method = ""get"")" & nl + & chr(9) & ".wildcard()" & nl + & chr(9) & ".end();" & nl + & scriptClose & nl + ); + var result = $newScaffold(root).generateAuth(cliVersion = "test-version"); + expect(result.success).toBeTrue(); + expect(fileRead(routesPath)).notToInclude("wheels:generate-auth:routes:begin"); + expect(arrayToList(result.skipped, "|")).toInclude("add this block manually"); + } finally { + testHelper.cleanupTempProject(root); + } + }); + + it("refuses to regenerate a block whose begin marker has no matching end marker", () => { + var root = testHelper.scaffoldTempProject(expandPath("/")); + try { + var scaffold = $newScaffold(root); + expect(scaffold.generateAuth(cliVersion = "test-version").success).toBeTrue(); + var routesPath = root & "/config/routes.cfm"; + fileWrite(routesPath, replace(fileRead(routesPath), "// wheels:generate-auth:routes:end", "")); + var second = scaffold.generateAuth(force = true, cliVersion = "second-run"); + expect(arrayToList(second.skipped, "|")).toInclude("begin marker without matching end marker"); + // The corrupted block is left untouched for the user to fix. + expect($countOccurrences(fileRead(routesPath), "wheels:generate-auth:routes:begin")).toBe(1); + } finally { + testHelper.cleanupTempProject(root); + } + }); + + }); + describe("generateAuth() — custom model name", () => { it("respects --model for file names, table name, and route wiring", () => { diff --git a/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx b/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx index c8008a4e00..779ff1126f 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx @@ -85,14 +85,18 @@ Wire the session strategy into the authenticator on app init. The `hasStrategy` ```cfm {test:compile} title="config/app.cfm or equivalent init hook" if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { - var auth = service("authenticator"); - var sessionStrategy = service("sessionStrategy"); - if (!auth.hasStrategy("session")) { - auth.registerStrategy(name="session", strategy=sessionStrategy); + local.auth = service("authenticator"); + local.sessionStrategy = service("sessionStrategy"); + if (!local.auth.hasStrategy("session")) { + local.auth.registerStrategy(name="session", strategy=local.sessionStrategy); } } ``` + + @@ -211,12 +215,12 @@ component extends="Model" { ```cfm {test:compile} title="config/app.cfm or equivalent init hook" if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { - var auth = service("authenticator"); + local.auth = service("authenticator"); - if (!auth.hasStrategy("token")) { + if (!local.auth.hasStrategy("token")) { // Hoist the closure into a variable first — an inline function literal as a // constructor named argument crashes Adobe ColdFusion's compiler. - var tokenValidator = function(token) { + local.tokenValidator = function(token) { // Dynamic finder binds the token value; the extra where= condition is // merged in with AND. var apiKey = model("ApiKey").findOneByToken(value=arguments.token, where="revokedAt IS NULL"); @@ -225,8 +229,8 @@ if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsIns } return false; }; - var tokenStrategy = new wheels.auth.TokenStrategy(validator=tokenValidator); - auth.registerStrategy(name="token", strategy=tokenStrategy); + local.tokenStrategy = new wheels.auth.TokenStrategy(validator=local.tokenValidator); + local.auth.registerStrategy(name="token", strategy=local.tokenStrategy); } } ``` @@ -277,16 +281,16 @@ JWTs are self-contained signed tokens — the client holds them, you verify the ```cfm {test:compile} title="config/app.cfm or equivalent init hook" if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { - var auth = service("authenticator"); + local.auth = service("authenticator"); - if (!auth.hasStrategy("jwt")) { - var jwtService = new wheels.auth.JwtService( + if (!local.auth.hasStrategy("jwt")) { + local.jwtService = new wheels.auth.JwtService( secretKey=env("JWT_SECRET"), defaultExpiry=3600, issuer="myapp" ); - var jwtStrategy = new wheels.auth.JwtStrategy(jwtService=jwtService); - auth.registerStrategy(name="jwt", strategy=jwtStrategy); + local.jwtStrategy = new wheels.auth.JwtStrategy(jwtService=local.jwtService); + local.auth.registerStrategy(name="jwt", strategy=local.jwtStrategy); } } ``` @@ -345,14 +349,14 @@ The registry pattern pays off when one app serves both HTML and JSON. Register s ```cfm {test:compile} title="config/app.cfm — register both strategies" if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { - var auth = service("authenticator"); + local.auth = service("authenticator"); - if (!auth.hasStrategy("jwt")) { - var jwtService = new wheels.auth.JwtService(secretKey=env("JWT_SECRET")); - auth.registerStrategy(name="jwt", strategy=new wheels.auth.JwtStrategy(jwtService=jwtService)); + if (!local.auth.hasStrategy("jwt")) { + local.jwtService = new wheels.auth.JwtService(secretKey=env("JWT_SECRET")); + local.auth.registerStrategy(name="jwt", strategy=new wheels.auth.JwtStrategy(jwtService=local.jwtService)); } - if (!auth.hasStrategy("session")) { - auth.registerStrategy(name="session", strategy=service("sessionStrategy")); + if (!local.auth.hasStrategy("session")) { + local.auth.registerStrategy(name="session", strategy=service("sessionStrategy")); } } ``` From 2be96af05fce2b6eb7f2e264da7765a808cc597d Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 6 Jul 2026 09:21:13 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(cli):=20make=20generated=20auth=20apps?= =?UTF-8?q?=20bootable=20=E2=80=94=20digest=20validation=20and=20namespace?= =?UTF-8?q?d=20extends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Booting generated apps end-to-end (the runtime confirmation wheels-bot asked for on all three strategies) surfaced two defects that string-level CLI specs cannot reach: 1. Every new-account creation failed validation. passwordDigest is an allowNull=false column, so Wheels auto-registers validatesPresenceOf for it — but the generated model only populates the digest in the beforeSave callback, which runs AFTER validation. Registration, programmatic creation, seeds, and the generated UserAuthSpec all failed with 'Password Digest can't be empty' (the exact trap the authentication guide documents at its beforeValidation stopgap). Fixed with property(name="passwordDigest", automaticValidations=false) — presence stays guaranteed by validatesPresenceOf(password) on create feeding the hashing callback, with the NOT NULL constraint as backstop. This keeps the validate-plaintext-then-hash ordering. 2. The token/jwt controllers live in app/controllers/api/ but extended the bare 'Controller', which cannot resolve from a subfolder — every request 500'd with 'invalid component definition, can't find component [Controller]'. Now extends app.controllers.Controller, matching the admin generator's namespaced-controller convention. Verified live against three freshly scaffolded apps (Lucee 7 + SQLite): token (201+token, 401 wrong/unknown, Bearer revoke 200 then 401), jwt (valid HS256 with sub/email/iat/exp, 401, stateless delete note), session (CSRF register 303+flash, wrong-password re-render, login 303). Three new specs pin both fixes. CLI suite: 1137 pass / 0 fail / 0 error. Co-Authored-By: Claude Fable 5 Signed-off-by: Peter Amiri --- .../auth/controller-api-sessions-jwt.txt | 5 +++- .../auth/controller-api-sessions-token.txt | 5 +++- cli/lucli/templates/auth/model.txt | 8 ++++++ .../tests/specs/services/GenerateAuthSpec.cfc | 27 +++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/cli/lucli/templates/auth/controller-api-sessions-jwt.txt b/cli/lucli/templates/auth/controller-api-sessions-jwt.txt index 649011b6ec..7ae614d964 100644 --- a/cli/lucli/templates/auth/controller-api-sessions-jwt.txt +++ b/cli/lucli/templates/auth/controller-api-sessions-jwt.txt @@ -23,7 +23,10 @@ * an instance to `set(middleware=[...])` in config/settings.cfm or scope one * to the /api routes in config/routes.cfm (see the middleware guide). */ -component extends="Controller" { +// Lives in app/controllers/api/, so it must extend the app base controller by +// its full mapping path — a bare extends="Controller" cannot resolve from a +// subfolder and fails to compile at request time. +component extends="app.controllers.Controller" { function config() { // Inherit app-wide defaults from app/controllers/Controller.cfc. diff --git a/cli/lucli/templates/auth/controller-api-sessions-token.txt b/cli/lucli/templates/auth/controller-api-sessions-token.txt index 1cbcd0d0b6..bccc11cdbd 100644 --- a/cli/lucli/templates/auth/controller-api-sessions-token.txt +++ b/cli/lucli/templates/auth/controller-api-sessions-token.txt @@ -18,7 +18,10 @@ * an instance to `set(middleware=[...])` in config/settings.cfm or scope one * to the /api routes in config/routes.cfm (see the middleware guide). */ -component extends="Controller" { +// Lives in app/controllers/api/, so it must extend the app base controller by +// its full mapping path — a bare extends="Controller" cannot resolve from a +// subfolder and fails to compile at request time. +component extends="app.controllers.Controller" { function config() { // Inherit app-wide defaults from app/controllers/Controller.cfc. diff --git a/cli/lucli/templates/auth/model.txt b/cli/lucli/templates/auth/model.txt index 1fca8cdf24..8e8e1acb1a 100644 --- a/cli/lucli/templates/auth/model.txt +++ b/cli/lucli/templates/auth/model.txt @@ -16,6 +16,14 @@ component extends="Model" { // Never allow these to be set by mass assignment (params structs). protectedProperties("passwordDigest,resetTokenDigest,resetTokenExpiresAt{{protectedApiToken}}"); + // passwordDigest is populated by the beforeSave callback AFTER validation + // runs, so Wheels' automatic NOT-NULL presence validation (added for + // allowNull=false columns) would reject every new record before the hash + // exists. Presence stays guaranteed: validatesPresenceOf(password) on + // create feeds the hashing callback, and the database NOT NULL + // constraint is the backstop. + property(name="passwordDigest", automaticValidations=false); + // Validations. The transient `password` property is only validated when // present, so updates that don't touch the password pass untouched. validatesPresenceOf(property="email"); diff --git a/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc b/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc index a20eefce07..7322d5fceb 100644 --- a/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc +++ b/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc @@ -165,6 +165,19 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(stripped).toInclude('protectedProperties('); }); + it("disables the automatic NOT-NULL presence validation on passwordDigest", () => { + // passwordDigest is only populated by the beforeSave callback, + // which runs AFTER validation — Wheels' automatic presence + // validation for the allowNull=false column would otherwise + // reject every new record ("Password Digest can't be empty"). + // Verified live: seeding/registration failed until this line + // was added (runtime verification on PR ##3291). + for (var key in ["session", "token", "jwt"]) { + var stripped = $strippedFile(fixtures[key].root & "/app/models/User.cfc"); + expect(stripped).toInclude('property(name="passwordDigest", automaticValidations=false)'); + } + }); + it("uses the injection-safe query builder rather than interpolated where strings", () => { var stripped = $strippedFile(fixtures.session.root & "/app/controllers/Sessions.cfc"); expect(stripped).toInclude('.where("email", email)'); @@ -333,6 +346,15 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(routes).toInclude("wheels:generate-auth:routes:begin"); }); + it("extends the app base controller by full mapping path (namespaced controller)", () => { + // app/controllers/api/Sessions.cfc lives in a subfolder — a bare + // extends="Controller" cannot resolve from there and fails to + // compile at request time (verified live on PR ##3291). Matches + // the admin generator's namespaced-controller convention. + var stripped = $strippedFile(fixtures.token.root & "/app/controllers/api/Sessions.cfc"); + expect(stripped).toInclude('extends="app.controllers.Controller"'); + }); + it("notes that the registration flag does not apply", () => { var notes = arrayToList(fixtures.token.result.skipped, "|"); expect(notes).toInclude("registration"); @@ -374,6 +396,11 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(stripped).toInclude('service("passwordHasher").hash('); }); + it("extends the app base controller by full mapping path (namespaced controller)", () => { + var stripped = $strippedFile(fixtures.jwt.root & "/app/controllers/api/Sessions.cfc"); + expect(stripped).toInclude('extends="app.controllers.Controller"'); + }); + }); describe("generateAuth() — force and idempotency", () => {