@nitronjs/framework 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/create.js +6 -3
- package/cli/njs.js +26 -23
- package/lib/Auth/Auth.js +9 -4
- package/lib/Auth/Mfa.js +518 -0
- package/lib/Build/FileAnalyzer.js +32 -4
- package/lib/Build/Manager.js +22 -7
- package/lib/Build/PropUsageAnalyzer.js +123 -12
- package/lib/Console/Commands/BuildCommand.js +2 -2
- package/lib/Console/Commands/DevCommand.js +18 -9
- package/lib/Console/Commands/MakeCommand.js +2 -2
- package/lib/Console/Commands/MigrateCommand.js +1 -1
- package/lib/Console/Commands/MigrateFreshCommand.js +1 -1
- package/lib/Console/Commands/MigrateRollbackCommand.js +1 -1
- package/lib/Console/Commands/MigrateStatusCommand.js +1 -1
- package/lib/Console/Commands/SeedCommand.js +1 -1
- package/lib/Console/Commands/StartCommand.js +2 -1
- package/lib/Console/Commands/StorageLinkCommand.js +21 -32
- package/lib/Console/Stubs/rsc-consumer.tsx +17 -1
- package/lib/Console/Stubs/vendor-dev.tsx +31 -0
- package/lib/Console/Stubs/vendor.tsx +31 -0
- package/lib/Date/DateTime.js +12 -10
- package/lib/Http/Server.js +3 -2
- package/lib/Mail/Mail.js +22 -19
- package/lib/Runtime/Entry.js +1 -1
- package/lib/View/Client/spa.js +196 -112
- package/lib/View/FlightRenderer.js +5 -1
- package/lib/View/View.js +49 -1
- package/lib/index.js +1 -0
- package/package.json +3 -1
package/cli/create.js
CHANGED
|
@@ -251,7 +251,8 @@ export default async function create(projectName, options = {}) {
|
|
|
251
251
|
log(`${COLORS.red}Error: ${validation.error}${COLORS.reset}`);
|
|
252
252
|
console.log();
|
|
253
253
|
log(`${COLORS.dim}Run 'npx @nitronjs/framework --help' for usage information${COLORS.reset}`);
|
|
254
|
-
process.
|
|
254
|
+
process.exitCode = 1;
|
|
255
|
+
return;
|
|
255
256
|
}
|
|
256
257
|
|
|
257
258
|
const projectPath = path.resolve(process.cwd(), projectName);
|
|
@@ -260,13 +261,15 @@ export default async function create(projectName, options = {}) {
|
|
|
260
261
|
log(`${COLORS.red}Error: Directory "${projectName}" already exists${COLORS.reset}`);
|
|
261
262
|
console.log();
|
|
262
263
|
log(`${COLORS.dim}Please choose a different project name or remove the existing directory${COLORS.reset}`);
|
|
263
|
-
process.
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
return;
|
|
264
266
|
}
|
|
265
267
|
|
|
266
268
|
if (!fs.existsSync(SKELETON_DIR)) {
|
|
267
269
|
log(`${COLORS.red}Error: Skeleton directory not found${COLORS.reset}`);
|
|
268
270
|
log(`${COLORS.dim}This may indicate a corrupted installation. Try reinstalling NitronJS.${COLORS.reset}`);
|
|
269
|
-
process.
|
|
271
|
+
process.exitCode = 1;
|
|
272
|
+
return;
|
|
270
273
|
}
|
|
271
274
|
|
|
272
275
|
log(`${COLORS.bold}Creating project: ${COLORS.cyan}${projectName}${COLORS.reset}`);
|
package/cli/njs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const COLORS = {
|
|
4
4
|
reset: "\x1b[0m",
|
|
@@ -61,25 +61,27 @@ const args = process.argv.slice(2);
|
|
|
61
61
|
|
|
62
62
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
63
63
|
printHelp();
|
|
64
|
-
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const command = args[0];
|
|
67
|
+
const additionalArgs = args.slice(1);
|
|
68
|
+
|
|
69
|
+
const KNOWN_COMMANDS = [
|
|
70
|
+
"dev", "start", "build",
|
|
71
|
+
"migrate", "migrate:rollback", "migrate:status", "migrate:fresh", "seed",
|
|
72
|
+
"storage:link",
|
|
73
|
+
"make:controller", "make:middleware", "make:model", "make:migration", "make:seeder"
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const isProjectName = command &&
|
|
77
|
+
!command.startsWith("-") &&
|
|
78
|
+
!KNOWN_COMMANDS.includes(command) &&
|
|
79
|
+
!command.startsWith("make:");
|
|
80
|
+
|
|
81
|
+
run(command, additionalArgs, isProjectName);
|
|
65
82
|
}
|
|
66
83
|
|
|
67
|
-
|
|
68
|
-
const additionalArgs = args.slice(1);
|
|
69
|
-
|
|
70
|
-
const KNOWN_COMMANDS = [
|
|
71
|
-
"dev", "start", "build",
|
|
72
|
-
"migrate", "migrate:rollback", "migrate:status", "migrate:fresh", "seed",
|
|
73
|
-
"storage:link",
|
|
74
|
-
"make:controller", "make:middleware", "make:model", "make:migration", "make:seeder"
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
const isProjectName = command &&
|
|
78
|
-
!command.startsWith("-") &&
|
|
79
|
-
!KNOWN_COMMANDS.includes(command) &&
|
|
80
|
-
!command.startsWith("make:");
|
|
81
|
-
|
|
82
|
-
async function run() {
|
|
84
|
+
async function run(command, additionalArgs, isProjectName) {
|
|
83
85
|
try {
|
|
84
86
|
if (isProjectName) {
|
|
85
87
|
const { default: create } = await import("./create.js");
|
|
@@ -166,7 +168,8 @@ async function run() {
|
|
|
166
168
|
console.error(`${COLORS.red}Error: Name is required${COLORS.reset}`);
|
|
167
169
|
console.log(`${COLORS.dim}Usage: njs ${command} <Name>${COLORS.reset}`);
|
|
168
170
|
console.log(`${COLORS.dim}Example: njs ${command} ${type === "controller" ? "HomeController" : type === "middleware" ? "AuthMiddleware" : type === "model" ? "User" : type === "migration" ? "users" : "UserSeeder"}${COLORS.reset}`);
|
|
169
|
-
process.
|
|
171
|
+
process.exitCode = 1;
|
|
172
|
+
return;
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
const { default: Make } = await import("../lib/Console/Commands/MakeCommand.js");
|
|
@@ -177,7 +180,8 @@ async function run() {
|
|
|
177
180
|
default:
|
|
178
181
|
console.error(`${COLORS.red}Error: Unknown command '${command}'${COLORS.reset}`);
|
|
179
182
|
console.log(`${COLORS.dim}Run 'njs --help' for available commands${COLORS.reset}`);
|
|
180
|
-
process.
|
|
183
|
+
process.exitCode = 1;
|
|
184
|
+
return;
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
if (exitCode !== null) {
|
|
@@ -185,7 +189,8 @@ async function run() {
|
|
|
185
189
|
await checkForUpdates();
|
|
186
190
|
process.exit(exitCode);
|
|
187
191
|
}
|
|
188
|
-
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
189
194
|
console.error(`${COLORS.red}Error: ${error.message}${COLORS.reset}`);
|
|
190
195
|
if (process.env.DEBUG) {
|
|
191
196
|
console.error(error.stack);
|
|
@@ -193,5 +198,3 @@ async function run() {
|
|
|
193
198
|
process.exit(1);
|
|
194
199
|
}
|
|
195
200
|
}
|
|
196
|
-
|
|
197
|
-
run();
|
package/lib/Auth/Auth.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Hash from "../Hashing/Hash.js";
|
|
2
2
|
import Config from "../Core/Config.js";
|
|
3
|
+
import Mfa from "./Mfa.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Authentication manager for handling user login, logout, and session management.
|
|
@@ -15,21 +16,25 @@ class Auth {
|
|
|
15
16
|
server.decorateRequest("auth", null);
|
|
16
17
|
|
|
17
18
|
server.addHook("onRequest", async (req, res) => {
|
|
19
|
+
req._response = res;
|
|
20
|
+
|
|
18
21
|
req.auth = {
|
|
19
22
|
guard: (guardName = null) => ({
|
|
20
23
|
attempt: (credentials) => Auth.attempt(req, credentials, guardName),
|
|
21
24
|
logout: () => Auth.logout(req, guardName),
|
|
22
25
|
user: () => Auth.user(req, guardName),
|
|
23
26
|
check: () => Auth.check(req, guardName),
|
|
24
|
-
home: () => Auth.home(
|
|
25
|
-
redirect: () => Auth.redirect(
|
|
27
|
+
home: () => Auth.home(req._response, guardName),
|
|
28
|
+
redirect: () => Auth.redirect(req._response, guardName),
|
|
29
|
+
mfa: new Mfa(req, guardName)
|
|
26
30
|
}),
|
|
27
31
|
attempt: (credentials) => Auth.attempt(req, credentials, null),
|
|
28
32
|
logout: () => Auth.logout(req, null),
|
|
29
33
|
user: () => Auth.user(req, null),
|
|
30
34
|
check: () => Auth.check(req, null),
|
|
31
|
-
home: () => Auth.home(
|
|
32
|
-
redirect: () => Auth.redirect(
|
|
35
|
+
home: () => Auth.home(req._response, null),
|
|
36
|
+
redirect: () => Auth.redirect(req._response, null),
|
|
37
|
+
mfa: new Mfa(req, null)
|
|
33
38
|
};
|
|
34
39
|
});
|
|
35
40
|
}
|
package/lib/Auth/Mfa.js
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { TOTP, Secret } from "otpauth";
|
|
3
|
+
import QRCode from "qrcode";
|
|
4
|
+
import AES from "../Encryption/Encryption.js";
|
|
5
|
+
import Config from "../Core/Config.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Multi-Factor Authentication (MFA) manager for NitronJS.
|
|
9
|
+
* Provides TOTP-based two-factor authentication with recovery codes.
|
|
10
|
+
*
|
|
11
|
+
* This class is instantiated per-request via the Auth system.
|
|
12
|
+
* Access it through `req.auth.mfa` or `req.auth.guard("admin").mfa`.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // Generate QR code for MFA setup
|
|
16
|
+
* const { qrCode, secret } = await req.auth.mfa.generate();
|
|
17
|
+
*
|
|
18
|
+
* // Confirm MFA setup with a code from authenticator app
|
|
19
|
+
* const result = await req.auth.mfa.confirmSetup(req.body.code);
|
|
20
|
+
*
|
|
21
|
+
* // Verify MFA code during login
|
|
22
|
+
* const verified = await req.auth.mfa.verify(req.body.code);
|
|
23
|
+
*/
|
|
24
|
+
class Mfa {
|
|
25
|
+
/**
|
|
26
|
+
* Creates a new MFA instance for the current request and guard.
|
|
27
|
+
* This is called automatically by the Auth system — you don't need to create it manually.
|
|
28
|
+
*
|
|
29
|
+
* @param {import("fastify").FastifyRequest} req - Fastify request object
|
|
30
|
+
* @param {string|null} guardName - Guard name (e.g., "admin", "user") or null for default
|
|
31
|
+
*/
|
|
32
|
+
constructor(req, guardName = null) {
|
|
33
|
+
const authConfig = Config.all("auth");
|
|
34
|
+
|
|
35
|
+
this._req = req;
|
|
36
|
+
this._guard = guardName || authConfig.defaults.guard;
|
|
37
|
+
this._guardConfig = authConfig.guards[this._guard];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generates a new TOTP secret and QR code for MFA setup.
|
|
42
|
+
* The secret is temporarily stored in the session until confirmed with `confirmSetup()`.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} [options] - Optional settings for QR code generation
|
|
45
|
+
* @param {string} [options.issuer] - App name shown in authenticator (e.g., "My App"). Defaults to APP_NAME env variable.
|
|
46
|
+
* @param {string} [options.label] - User label shown in authenticator (e.g., email or username). Defaults to the guard identifier field.
|
|
47
|
+
* @returns {Promise<{qrCode: string, secret: string, otpauthUri: string}>}
|
|
48
|
+
* - `qrCode` — Base64 data URL PNG image, ready for `<img src="...">`
|
|
49
|
+
* - `secret` — Plain text secret (for manual entry in authenticator apps)
|
|
50
|
+
* - `otpauthUri` — otpauth:// URI used to generate the QR code
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* // Basic usage (uses APP_NAME env and guard identifier)
|
|
54
|
+
* const { qrCode, secret } = await req.auth.mfa.generate();
|
|
55
|
+
*
|
|
56
|
+
* // With custom issuer and label
|
|
57
|
+
* const { qrCode, secret } = await req.auth.mfa.generate({
|
|
58
|
+
* issuer: "My App Name",
|
|
59
|
+
* label: user.email
|
|
60
|
+
* });
|
|
61
|
+
*/
|
|
62
|
+
async generate(options = {}) {
|
|
63
|
+
const user = await this._getUser();
|
|
64
|
+
const appName = options.issuer || process.env.APP_NAME || "NitronJS App";
|
|
65
|
+
const identifier = this._guardConfig.identifier;
|
|
66
|
+
const label = options.label || user[identifier] || "user";
|
|
67
|
+
|
|
68
|
+
// Create a new random TOTP secret
|
|
69
|
+
const totpSecret = new Secret();
|
|
70
|
+
|
|
71
|
+
// Create the TOTP instance with app name and user label
|
|
72
|
+
const totp = new TOTP({
|
|
73
|
+
issuer: appName,
|
|
74
|
+
label: label,
|
|
75
|
+
algorithm: "SHA1",
|
|
76
|
+
digits: 6,
|
|
77
|
+
period: 30,
|
|
78
|
+
secret: totpSecret
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Generate the otpauth:// URI for QR code
|
|
82
|
+
const otpauthUri = totp.toString();
|
|
83
|
+
|
|
84
|
+
// Generate QR code as base64 data URL (PNG image)
|
|
85
|
+
const qrCode = await QRCode.toDataURL(otpauthUri, {
|
|
86
|
+
width: 256,
|
|
87
|
+
margin: 2
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Store the secret temporarily in session until user confirms with a valid code
|
|
91
|
+
const secretBase32 = totpSecret.base32;
|
|
92
|
+
|
|
93
|
+
this._req.session.set(`mfa_setup_secret_${this._guard}`, secretBase32);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
qrCode,
|
|
97
|
+
secret: secretBase32,
|
|
98
|
+
otpauthUri
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Confirms the MFA setup by verifying a TOTP code against the temporary secret.
|
|
104
|
+
* On success, encrypts and saves the secret to the user's `mfa` column,
|
|
105
|
+
* generates recovery codes, and clears the temporary session data.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} code - 6-digit TOTP code from the authenticator app
|
|
108
|
+
* @returns {Promise<{success: boolean, recoveryCodes?: string[]}>}
|
|
109
|
+
* - On success: `{ success: true, recoveryCodes: ["XXXX-XXXX", ...] }` (8 codes)
|
|
110
|
+
* - On failure: `{ success: false }`
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* const result = await req.auth.mfa.confirmSetup(req.body.code);
|
|
114
|
+
*
|
|
115
|
+
* if (result.success) {
|
|
116
|
+
* return res.view("mfa/recovery-codes", { codes: result.recoveryCodes });
|
|
117
|
+
* }
|
|
118
|
+
*
|
|
119
|
+
* req.session.flash("error", "Invalid code, please try again");
|
|
120
|
+
* return res.redirect(route("admin.mfa.setup"));
|
|
121
|
+
*/
|
|
122
|
+
async confirmSetup(code) {
|
|
123
|
+
// Get the temporary secret that was stored during generate()
|
|
124
|
+
const tempSecret = this._req.session.get(`mfa_setup_secret_${this._guard}`);
|
|
125
|
+
|
|
126
|
+
if (!tempSecret) {
|
|
127
|
+
return { success: false };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Verify the code against the temporary secret
|
|
131
|
+
const isValid = verifyTotpCode(tempSecret, code);
|
|
132
|
+
|
|
133
|
+
if (!isValid) {
|
|
134
|
+
return { success: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Code is valid — save MFA data to the database
|
|
138
|
+
const user = await this._getUser();
|
|
139
|
+
|
|
140
|
+
// Generate 8 recovery codes in XXXX-XXXX format
|
|
141
|
+
const plainRecoveryCodes = generateRecoveryCodes(8);
|
|
142
|
+
|
|
143
|
+
// Hash each recovery code with HMAC-SHA256 for secure storage
|
|
144
|
+
const hashedRecoveryCodes = plainRecoveryCodes.map(
|
|
145
|
+
recoveryCode => hashRecoveryCode(recoveryCode)
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Build the MFA data object to store in the user's mfa column
|
|
149
|
+
const mfaData = {
|
|
150
|
+
secret: AES.encrypt(tempSecret),
|
|
151
|
+
recovery_codes: hashedRecoveryCodes,
|
|
152
|
+
confirmed_at: new Date().toISOString()
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Save to user model and clear temporary session data
|
|
156
|
+
user.mfa = JSON.stringify(mfaData);
|
|
157
|
+
await user.save();
|
|
158
|
+
|
|
159
|
+
this._req.session.set(`mfa_setup_secret_${this._guard}`, null);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
success: true,
|
|
163
|
+
recoveryCodes: plainRecoveryCodes
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Verifies a TOTP code during login.
|
|
169
|
+
* Reads the encrypted secret from the user's `mfa` column and validates the code.
|
|
170
|
+
* Allows ±1 time step (±30 seconds) for clock drift tolerance.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} code - 6-digit TOTP code from the authenticator app
|
|
173
|
+
* @returns {Promise<boolean>} True if the code is valid
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* const verified = await req.auth.mfa.verify(req.body.code);
|
|
177
|
+
*
|
|
178
|
+
* if (verified) {
|
|
179
|
+
* req.auth.mfa.clearPending();
|
|
180
|
+
* return res.redirect(route("admin.dashboard"));
|
|
181
|
+
* }
|
|
182
|
+
*
|
|
183
|
+
* req.session.flash("error", "Invalid code");
|
|
184
|
+
* return res.redirect(route("admin.mfa.verify"));
|
|
185
|
+
*/
|
|
186
|
+
async verify(code) {
|
|
187
|
+
const mfaData = await this._getMfaData();
|
|
188
|
+
|
|
189
|
+
if (!mfaData || !mfaData.confirmed_at) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Decrypt the stored TOTP secret
|
|
194
|
+
const decryptedSecret = AES.decrypt(mfaData.secret);
|
|
195
|
+
|
|
196
|
+
if (!decryptedSecret) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return verifyTotpCode(decryptedSecret, code);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Verifies a recovery code during login (when user lost access to authenticator app).
|
|
205
|
+
* Recovery codes are single-use — once verified, the code is removed from the database.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} code - Recovery code in "XXXX-XXXX" format
|
|
208
|
+
* @returns {Promise<boolean>} True if the recovery code is valid
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* const verified = await req.auth.mfa.verifyRecoveryCode(req.body.recovery_code);
|
|
212
|
+
*
|
|
213
|
+
* if (verified) {
|
|
214
|
+
* req.auth.mfa.clearPending();
|
|
215
|
+
* return res.redirect(route("admin.dashboard"));
|
|
216
|
+
* }
|
|
217
|
+
*/
|
|
218
|
+
async verifyRecoveryCode(code) {
|
|
219
|
+
// Get user and parse MFA data in one query (avoid double DB call)
|
|
220
|
+
const user = await this._getUser();
|
|
221
|
+
const rawMfa = user.mfa;
|
|
222
|
+
|
|
223
|
+
if (!rawMfa) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const mfaData = typeof rawMfa === "object" ? rawMfa : JSON.parse(rawMfa);
|
|
228
|
+
|
|
229
|
+
if (!mfaData.recovery_codes || mfaData.recovery_codes.length === 0) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Normalize the input: uppercase, trim whitespace
|
|
234
|
+
const normalizedCode = code.toUpperCase().trim();
|
|
235
|
+
const hashedInput = hashRecoveryCode(normalizedCode);
|
|
236
|
+
|
|
237
|
+
// Find the matching recovery code using timing-safe comparison
|
|
238
|
+
let matchIndex = -1;
|
|
239
|
+
|
|
240
|
+
for (let i = 0; i < mfaData.recovery_codes.length; i++) {
|
|
241
|
+
const storedHash = mfaData.recovery_codes[i];
|
|
242
|
+
|
|
243
|
+
if (timingSafeCompare(hashedInput, storedHash)) {
|
|
244
|
+
matchIndex = i;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (matchIndex === -1) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Remove the used recovery code from the array (single-use)
|
|
254
|
+
mfaData.recovery_codes.splice(matchIndex, 1);
|
|
255
|
+
|
|
256
|
+
// Save the updated MFA data back to the database
|
|
257
|
+
user.mfa = JSON.stringify(mfaData);
|
|
258
|
+
await user.save();
|
|
259
|
+
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Disables MFA for the current user.
|
|
265
|
+
* Clears all MFA data (secret, recovery codes) from the database and session.
|
|
266
|
+
*
|
|
267
|
+
* Important: This method does NOT verify the user's password.
|
|
268
|
+
* You should handle confirmation (password check, etc.) in your controller before calling this.
|
|
269
|
+
*
|
|
270
|
+
* @returns {Promise<void>}
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* // In your controller — verify password first, then disable
|
|
274
|
+
* const user = await req.auth.user();
|
|
275
|
+
* const passwordValid = await Hash.check(req.body.password, user.password);
|
|
276
|
+
*
|
|
277
|
+
* if (!passwordValid) {
|
|
278
|
+
* req.session.flash("error", "Invalid password");
|
|
279
|
+
* return res.redirect(route("admin.settings"));
|
|
280
|
+
* }
|
|
281
|
+
*
|
|
282
|
+
* await req.auth.mfa.disable();
|
|
283
|
+
*/
|
|
284
|
+
async disable() {
|
|
285
|
+
const user = await this._getUser();
|
|
286
|
+
|
|
287
|
+
user.mfa = null;
|
|
288
|
+
await user.save();
|
|
289
|
+
|
|
290
|
+
// Clear any pending MFA state from the session
|
|
291
|
+
this._req.session.set(`mfa_pending_${this._guard}`, null);
|
|
292
|
+
this._req.session.set(`mfa_setup_secret_${this._guard}`, null);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Checks if the current user has MFA enabled (setup completed and confirmed).
|
|
297
|
+
*
|
|
298
|
+
* @returns {Promise<boolean>} True if MFA is enabled for this user
|
|
299
|
+
*
|
|
300
|
+
* @example
|
|
301
|
+
* const hasMfa = await req.auth.mfa.enabled();
|
|
302
|
+
*
|
|
303
|
+
* if (hasMfa) {
|
|
304
|
+
* req.auth.mfa.setPending();
|
|
305
|
+
* return res.redirect(route("admin.mfa.verify"));
|
|
306
|
+
* }
|
|
307
|
+
*/
|
|
308
|
+
async enabled() {
|
|
309
|
+
const mfaData = await this._getMfaData();
|
|
310
|
+
|
|
311
|
+
if (!mfaData || !mfaData.confirmed_at) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Checks if the current session has a pending MFA verification.
|
|
320
|
+
* Use this in your middleware to block access until MFA is verified.
|
|
321
|
+
*
|
|
322
|
+
* @returns {boolean} True if MFA verification is pending
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* // In your MFA middleware:
|
|
326
|
+
* if (req.auth.mfa.isPending()) {
|
|
327
|
+
* return res.redirect(route("admin.mfa.verify"));
|
|
328
|
+
* }
|
|
329
|
+
*/
|
|
330
|
+
isPending() {
|
|
331
|
+
return this._req.session.get(`mfa_pending_${this._guard}`) === true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Marks the current session as needing MFA verification.
|
|
336
|
+
* Call this after a successful `attempt()` when the user has MFA enabled.
|
|
337
|
+
*
|
|
338
|
+
* @returns {void}
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* const success = await req.auth.attempt(credentials);
|
|
342
|
+
*
|
|
343
|
+
* if (success && await req.auth.mfa.enabled()) {
|
|
344
|
+
* req.auth.mfa.setPending();
|
|
345
|
+
* return res.redirect(route("admin.mfa.verify"));
|
|
346
|
+
* }
|
|
347
|
+
*/
|
|
348
|
+
setPending() {
|
|
349
|
+
this._req.session.set(`mfa_pending_${this._guard}`, true);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Clears the MFA pending state from the session.
|
|
354
|
+
* Call this after a successful `verify()` or `verifyRecoveryCode()`.
|
|
355
|
+
*
|
|
356
|
+
* @returns {void}
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* const verified = await req.auth.mfa.verify(req.body.code);
|
|
360
|
+
*
|
|
361
|
+
* if (verified) {
|
|
362
|
+
* req.auth.mfa.clearPending();
|
|
363
|
+
* return res.redirect(route("admin.dashboard"));
|
|
364
|
+
* }
|
|
365
|
+
*/
|
|
366
|
+
clearPending() {
|
|
367
|
+
this._req.session.set(`mfa_pending_${this._guard}`, null);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Gets the current authenticated user for this guard.
|
|
372
|
+
* Internal helper used by other MFA methods.
|
|
373
|
+
*
|
|
374
|
+
* @returns {Promise<Object>} User model instance
|
|
375
|
+
* @throws {Error} If no user is authenticated
|
|
376
|
+
* @private
|
|
377
|
+
*/
|
|
378
|
+
async _getUser() {
|
|
379
|
+
const authConfig = Config.all("auth");
|
|
380
|
+
const config = authConfig.guards[this._guard];
|
|
381
|
+
const userId = this._req.session.get(`auth_${this._guard}`);
|
|
382
|
+
|
|
383
|
+
if (!userId || !config) {
|
|
384
|
+
throw new Error("No authenticated user found for MFA operation");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return await config.provider.find(userId);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Reads and parses the MFA JSON data from the user's mfa column.
|
|
392
|
+
* Returns null if the user has no MFA data.
|
|
393
|
+
*
|
|
394
|
+
* @returns {Promise<Object|null>} Parsed MFA data or null
|
|
395
|
+
* @private
|
|
396
|
+
*/
|
|
397
|
+
async _getMfaData() {
|
|
398
|
+
try {
|
|
399
|
+
const user = await this._getUser();
|
|
400
|
+
const rawMfa = user.mfa;
|
|
401
|
+
|
|
402
|
+
if (!rawMfa) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// If it's already an object (some DB drivers auto-parse JSON), use it directly
|
|
407
|
+
if (typeof rawMfa === "object") {
|
|
408
|
+
return rawMfa;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return JSON.parse(rawMfa);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ============================================================
|
|
420
|
+
// Helper functions (outside the class, module-level)
|
|
421
|
+
// ============================================================
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Verifies a TOTP code against a base32 secret.
|
|
425
|
+
* Allows ±1 time step (±30 seconds) for clock drift tolerance.
|
|
426
|
+
*
|
|
427
|
+
* @param {string} secretBase32 - TOTP secret in base32 format
|
|
428
|
+
* @param {string} code - 6-digit code to verify
|
|
429
|
+
* @returns {boolean} True if the code is valid
|
|
430
|
+
*/
|
|
431
|
+
function verifyTotpCode(secretBase32, code) {
|
|
432
|
+
const totp = new TOTP({
|
|
433
|
+
algorithm: "SHA1",
|
|
434
|
+
digits: 6,
|
|
435
|
+
period: 30,
|
|
436
|
+
secret: Secret.fromBase32(secretBase32)
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// validate() returns the time step difference (0, -1, +1) if valid, or null if invalid
|
|
440
|
+
// window: 1 means we accept codes from 30 seconds ago and 30 seconds in the future
|
|
441
|
+
const result = totp.validate({
|
|
442
|
+
token: code,
|
|
443
|
+
window: 1
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
return result !== null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Generates an array of random recovery codes in "XXXX-XXXX" format.
|
|
451
|
+
* Uses a safe character set that excludes ambiguous characters (0/O, 1/I/L).
|
|
452
|
+
*
|
|
453
|
+
* @param {number} count - Number of recovery codes to generate
|
|
454
|
+
* @returns {string[]} Array of plain text recovery codes
|
|
455
|
+
*/
|
|
456
|
+
function generateRecoveryCodes(count) {
|
|
457
|
+
// Safe characters — no 0/O, 1/I/L confusion
|
|
458
|
+
const charset = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
|
459
|
+
const codes = [];
|
|
460
|
+
|
|
461
|
+
for (let i = 0; i < count; i++) {
|
|
462
|
+
let code = "";
|
|
463
|
+
|
|
464
|
+
// Generate 8 random characters (4 + dash + 4)
|
|
465
|
+
const randomBytes = crypto.randomBytes(8);
|
|
466
|
+
|
|
467
|
+
for (let j = 0; j < 8; j++) {
|
|
468
|
+
const index = randomBytes[j] % charset.length;
|
|
469
|
+
|
|
470
|
+
code += charset[index];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Format as XXXX-XXXX
|
|
474
|
+
codes.push(code.slice(0, 4) + "-" + code.slice(4, 8));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return codes;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Hashes a recovery code using HMAC-SHA256 with APP_KEY.
|
|
482
|
+
* This is a one-way hash — the original code cannot be recovered.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} code - Plain text recovery code
|
|
485
|
+
* @returns {string} Hex-encoded HMAC-SHA256 hash
|
|
486
|
+
*/
|
|
487
|
+
function hashRecoveryCode(code) {
|
|
488
|
+
return crypto
|
|
489
|
+
.createHmac("sha256", process.env.APP_KEY)
|
|
490
|
+
.update(code)
|
|
491
|
+
.digest("hex");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Performs a timing-safe comparison of two hex strings.
|
|
496
|
+
* Prevents timing attacks when comparing recovery code hashes.
|
|
497
|
+
*
|
|
498
|
+
* @param {string} a - First hex string
|
|
499
|
+
* @param {string} b - Second hex string
|
|
500
|
+
* @returns {boolean} True if the strings are equal
|
|
501
|
+
*/
|
|
502
|
+
function timingSafeCompare(a, b) {
|
|
503
|
+
if (a.length !== b.length) {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
return crypto.timingSafeEqual(
|
|
509
|
+
Buffer.from(a, "hex"),
|
|
510
|
+
Buffer.from(b, "hex")
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export default Mfa;
|