Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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