Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3155-generate-auth.added.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/3155-password-hasher.added.md
Original file line number Diff line number Diff line change
@@ -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=<iterations>$<base64(salt)>$<base64(derivedKey)>`). `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).
97 changes: 96 additions & 1 deletion cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 "";
}

Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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":
Expand Down
Loading
Loading