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/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/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 25704db14b..7e18309f69 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,95 @@ 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)."); + 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 { + out("Auth generation failed:", "red"); + for (var err in results.errors) { + out(" #err#", "red"); + } + } + + return ""; + } + /** * List all available snippet patterns */ @@ -7581,7 +7675,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..4c4281d639 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); + // 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; + } + // ── 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("); + } + // 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 or an uncommented .root()) in #arguments.relPath# — add this block manually inside the mapper() chain, before .root()/.wildcard():" & 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..c0bb0b0fdd --- /dev/null +++ b/cli/lucli/templates/auth/bootstrap-jwt.txt @@ -0,0 +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")) { + 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). + 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." + ); + } + 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 new file mode 100644 index 0000000000..b685670b43 --- /dev/null +++ b/cli/lucli/templates/auth/bootstrap-session.txt @@ -0,0 +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")) { + 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 new file mode 100644 index 0000000000..808e4bb4d2 --- /dev/null +++ b/cli/lucli/templates/auth/bootstrap-token.txt @@ -0,0 +1,25 @@ +// 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")) { + 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` 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(); + if (IsObject(account)) { + return {id: account.key(), email: account.email}; + } + return false; + }; + 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 new file mode 100644 index 0000000000..7ae614d964 --- /dev/null +++ b/cli/lucli/templates/auth/controller-api-sessions-jwt.txt @@ -0,0 +1,72 @@ +/** + * 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. + * + * 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). + */ +// 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. + 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}})) { + // 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. + 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..bccc11cdbd --- /dev/null +++ b/cli/lucli/templates/auth/controller-api-sessions-token.txt @@ -0,0 +1,77 @@ +/** + * 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. + * + * 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). + */ +// 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. + 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}})) { + // 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); + } else { + renderWith(data={error: "Invalid email or password."}, status=401); + } + } + + // DELETE /api/session — revoke the presented token + function delete() { + // 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}})) { + {{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..5c95f413dc --- /dev/null +++ b/cli/lucli/templates/auth/controller-passwords.txt @@ -0,0 +1,111 @@ +/** + * 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. + * + * 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" { + + 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 ?: ""; + // 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 = ""; + 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..c64e972875 --- /dev/null +++ b/cli/lucli/templates/auth/controller-sessions.txt @@ -0,0 +1,61 @@ +/** + * 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). + * + * 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" { + + 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}})) { + // 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."); + } 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..8e8e1acb1a --- /dev/null +++ b/cli/lucli/templates/auth/model.txt @@ -0,0 +1,100 @@ +/** + * {{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}}"); + + // 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"); + 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..7f339f2130 --- /dev/null +++ b/cli/lucli/templates/auth/spec-sessions-controller.txt @@ -0,0 +1,51 @@ +/** + * 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 + }; + // The action comes from the params handed to controller() — + // processAction()'s only parameter is includeFilters. + var sessionsController = application.wo.controller("sessions", loginParams); + sessionsController.processAction(); + 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..7322d5fceb --- /dev/null +++ b/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc @@ -0,0 +1,559 @@ +/** + * 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("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)'); + 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); + }); + + 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", () => { + + 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("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")'); + 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"); + }); + + }); + + 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"); + }); + + 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('); + }); + + 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", () => { + + 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() — 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", () => { + 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/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(); + }); + + }); + + }); + + } + +} 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..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 @@ -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.*`: @@ -65,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); } } ``` + + @@ -191,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"); @@ -205,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); } } ``` @@ -257,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); } } ``` @@ -325,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")); } } ```