create-efc-app 0.9.6 → 0.10.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/dist/index.js CHANGED
@@ -10,6 +10,251 @@ import path3 from "path";
10
10
  import fs from "fs-extra";
11
11
  import path from "path";
12
12
  import crypto from "crypto";
13
+
14
+ // src/two-factor.ts
15
+ function twoFactorLibrary(ts, table, collection) {
16
+ const T = (type) => ts ? type : "";
17
+ const where = table ? "{ where, data }" : "where, data";
18
+ const result = table ? "result.count" : collection ? "result.modifiedCount" : "result";
19
+ const less = table ? "lt" : "$lt";
20
+ return `import crypto from 'node:crypto';
21
+ import { HttpError } from 'express-file-cluster';
22
+ import { enqueue } from 'express-file-cluster/tasks';
23
+
24
+ ${T(`interface SecurityAccount {
25
+ id: string;
26
+ twoFactorEnabled?: boolean;
27
+ twoFactorSecret?: string;
28
+ twoFactorLastStep?: number;
29
+ email: string; twoFactorMethod?: string;
30
+ twoFactorEmailHash?: string; twoFactorEmailExpiry?: Date; twoFactorEmailPurpose?: string;
31
+ }
32
+ type SecurityData = {
33
+ twoFactorEnabled?: boolean; twoFactorSecret?: string; twoFactorLastStep?: number;
34
+ twoFactorMethod?: string; twoFactorPendingMethod?: string;
35
+ twoFactorEmailHash?: string; twoFactorEmailExpiry?: Date; twoFactorEmailPurpose?: string;
36
+ twoFactorPendingSecret?: string; twoFactorPendingExpiry?: Date;
37
+ refreshToken?: string; refreshTokenExpiry?: Date;
38
+ password?: string; resetToken?: string; resetTokenExpiry?: Date;
39
+ };
40
+ type SecurityWhere = {
41
+ id: string; password?: string; resetToken?: string; resetTokenExpiry?: { ${table ? "gt" : "$gt"}: Date }; refreshToken?: string; twoFactorEnabled?: ${table ? "boolean" : "boolean | { $ne: true }"}; twoFactorSecret?: string;
42
+ twoFactorPendingSecret?: string; twoFactorEmailHash?: string; twoFactorPendingMethod?: string; twoFactorLastStep?: { ${less}: number };
43
+ };
44
+ interface SecurityModel {
45
+ updateMany(${table ? "args: { where: SecurityWhere; data: SecurityData }" : "where: SecurityWhere, data: SecurityData"}): Promise<${table ? "{ count: number }" : collection ? "{ modifiedCount: number }" : "number"}>;
46
+ }
47
+ `)}
48
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
49
+ export function newSecret()${T(": string")} {
50
+ const bytes = crypto.randomBytes(20);
51
+ let bits = 0, value = 0, result = '';
52
+ for (const byte of bytes) {
53
+ value = (value << 8) | byte; bits += 8;
54
+ while (bits >= 5) { bits -= 5; result += ALPHABET[(value >>> bits) & 31]; }
55
+ }
56
+ return result;
57
+ }
58
+ function secretBytes(secret${T(": string")}) {
59
+ if (!/^[A-Z2-7]+$/.test(secret)) throw new Error('Invalid TOTP secret');
60
+ let bits = 0, value = 0;
61
+ const bytes${T(": number[]")} = [];
62
+ for (const char of secret) {
63
+ value = (value << 5) | ALPHABET.indexOf(char); bits += 5;
64
+ if (bits >= 8) { bits -= 8; bytes.push((value >>> bits) & 255); }
65
+ }
66
+ return Buffer.from(bytes);
67
+ }
68
+ // RFC 6238: SHA-1, six digits, 30-second steps, one step of clock drift.
69
+ export function totp(secret${T(": string")}, step${T(": number")})${T(": string")} {
70
+ const counter = Buffer.alloc(8);
71
+ counter.writeBigUInt64BE(BigInt(step));
72
+ const digest = crypto.createHmac('sha1', secretBytes(secret)).update(counter).digest();
73
+ const offset = digest[digest.length - 1]${T("!")} & 15;
74
+ return String((digest.readUInt32BE(offset) & 0x7fffffff) % 1000000).padStart(6, '0');
75
+ }
76
+ export function codeStep(secret${T(": string")}, code${T(": unknown")}, last = -1, now = Date.now())${T(": number | undefined")} {
77
+ if (typeof code !== 'string' || !/^\\d{6}$/.test(code)) return undefined;
78
+ const step = Math.floor(now / 30000);
79
+ for (const candidate of [step, step - 1, step + 1]) {
80
+ if (candidate > last && candidate >= 0 && crypto.timingSafeEqual(Buffer.from(code), Buffer.from(totp(secret, candidate)))) return candidate;
81
+ }
82
+ return undefined;
83
+ }
84
+ function encryptionKey() {
85
+ const key = process.env.TWO_FACTOR_KEY;
86
+ if (!key || !/^[a-fA-F0-9]{64}$/.test(key)) throw new HttpError(503, 'TWO_FACTOR_KEY must be 32 random bytes encoded as 64 hex characters');
87
+ return Buffer.from(key, 'hex');
88
+ }
89
+ export function sealSecret(secret${T(": string")}, identity${T(": string")})${T(": string")} {
90
+ const iv = crypto.randomBytes(12);
91
+ const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv);
92
+ cipher.setAAD(Buffer.from(identity));
93
+ const encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
94
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url');
95
+ }
96
+ export function openSecret(encrypted${T(": string")}, identity${T(": string")})${T(": string")} {
97
+ const key = encryptionKey();
98
+ try {
99
+ const data = Buffer.from(encrypted, 'base64url');
100
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, data.subarray(0, 12));
101
+ decipher.setAAD(Buffer.from(identity));
102
+ decipher.setAuthTag(data.subarray(12, 28));
103
+ return Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]).toString('utf8');
104
+ } catch { throw new HttpError(503, 'Two-factor secret could not be decrypted'); }
105
+ }
106
+ export async function updateSecurity(model${T(": SecurityModel")}, where${T(": SecurityWhere")}, data${T(": SecurityData")})${T(": Promise<boolean>")} {
107
+ ${table ? "" : ` // Older MongoDB records may predate the defaulted enabled flag. Missing means off.
108
+ if (where.twoFactorEnabled === false) where = { ...where, twoFactorEnabled: { $ne: true } };
109
+ `} const result = await model.updateMany(${where});
110
+ return ${result} === 1;
111
+ }
112
+ export function allowedMethods()${T(": string[]")} {
113
+ const value = process.env.TWO_FACTOR_METHOD ?? 'totp';
114
+ if (!['totp', 'email', 'both'].includes(value)) throw new HttpError(503, 'Invalid TWO_FACTOR_METHOD');
115
+ return value === 'both' ? ['totp', 'email'] : [value];
116
+ }
117
+ function emailDigest(identity${T(": string")}, purpose${T(": string")}, code${T(": string")}) {
118
+ return crypto.createHmac('sha256', encryptionKey()).update(identity + ':' + purpose + ':' + code).digest('hex');
119
+ }
120
+ export async function sendEmailCode(model${T(": SecurityModel")}, account${T(": SecurityAccount")}, kind${T(": string")}, purpose${T(": string")}) {
121
+ if (!process.env.SMTP_HOST) throw new HttpError(503, 'Configure the email-client SMTP settings before using email OTP');
122
+ const code = String(crypto.randomInt(0, 1000000)).padStart(6, '0');
123
+ const twoFactorEmailHash = emailDigest(kind + ':' + account.id + ':' + account.email, purpose, code);
124
+ const updated = await updateSecurity(model, { id: account.id, twoFactorEnabled: !!account.twoFactorEnabled }, {
125
+ twoFactorEmailHash, twoFactorEmailExpiry: new Date(Date.now() + 300000), twoFactorEmailPurpose: purpose,
126
+ });
127
+ if (!updated) throw new HttpError(409, 'Account security changed; try again');
128
+ try {
129
+ await enqueue('SendEmail', { to: account.email, subject: 'Your EFC verification code', body: 'Your verification code is <b>' + code + '</b>. It expires in 5 minutes.' });
130
+ } catch (error) {
131
+ await updateSecurity(model, { id: account.id, twoFactorEmailHash }, { twoFactorEmailHash: '' });
132
+ throw error;
133
+ }
134
+ }
135
+ export async function consumeEmailCode(model${T(": SecurityModel")}, account${T(": SecurityAccount")}, kind${T(": string")}, purpose${T(": string")}, code${T(": unknown")})${T(": Promise<boolean>")} {
136
+ if (typeof code !== 'string' || !/^\\d{6}$/.test(code) || !account.twoFactorEmailHash || account.twoFactorEmailPurpose !== purpose || !account.twoFactorEmailExpiry || new Date(account.twoFactorEmailExpiry).getTime() <= Date.now()) return false;
137
+ const digest = emailDigest(kind + ':' + account.id + ':' + account.email, purpose, code);
138
+ const stored = Buffer.from(account.twoFactorEmailHash, 'hex');
139
+ const supplied = Buffer.from(digest, 'hex');
140
+ if (stored.length !== supplied.length || !crypto.timingSafeEqual(stored, supplied)) return false;
141
+ return updateSecurity(model, { id: account.id, twoFactorEmailHash: account.twoFactorEmailHash, twoFactorEnabled: !!account.twoFactorEnabled }, { twoFactorEmailHash: '', twoFactorEmailExpiry: new Date(0) });
142
+ }
143
+ export async function acceptTwoFactor(model${T(": SecurityModel")}, account${T(": SecurityAccount")}, kind${T(": string")}, code${T(": unknown")}, purpose = 'login')${T(": Promise<boolean>")} {
144
+ if (!account.twoFactorEnabled) return true;
145
+ const method = account.twoFactorMethod ?? 'totp';
146
+ if (!allowedMethods().includes(method)) return false;
147
+ if (method === 'email') {
148
+ if (code === undefined) { await sendEmailCode(model, account, kind, purpose); return false; }
149
+ return consumeEmailCode(model, account, kind, purpose, code);
150
+ }
151
+ if (!account.twoFactorSecret) return false;
152
+ const step = codeStep(openSecret(account.twoFactorSecret, kind + ':' + account.id), code, account.twoFactorLastStep ?? -1);
153
+ if (step === undefined) return false;
154
+ // Compare-and-set in the database: only one worker can accept a given code.
155
+ return updateSecurity(model, { id: account.id, twoFactorEnabled: true, twoFactorSecret: account.twoFactorSecret, twoFactorLastStep: { ${less}: step } }, { twoFactorLastStep: step });
156
+ }
157
+ `;
158
+ }
159
+ function twoFactorRoutes(ts, table, hasAdmin) {
160
+ const T = (type) => ts ? type : "";
161
+ const find = (model) => table ? `${model}.findUnique({ where: { id } })` : `${model}.findById(id)`;
162
+ const imports = `import { requireAuth, getCurrentUser, verifyPassword, revokeAllSessions } from 'express-file-cluster/auth';
163
+ import { HttpError, rateLimit } from 'express-file-cluster';
164
+ ${T("import type { Request, Response } from 'express';\n")}import { User } from '../../../model/User.js';
165
+ ${hasAdmin ? "import { Admin } from '../../../model/Admin.js';\n" : ""}import { newSecret, sealSecret, openSecret, codeStep, updateSecurity, acceptTwoFactor, allowedMethods, sendEmailCode, consumeEmailCode } from '../../../lib/two-factor.js';
166
+
167
+ export const middlewares = [requireAuth, rateLimit({ window: '15m', max: 10, name: 'two-factor-management', key: () => String(getCurrentUser()?.id ?? ''), onCacheError: 'reject' })];
168
+
169
+ async function currentAccount() {
170
+ const principal = getCurrentUser();
171
+ if (!principal || typeof principal.id !== 'string') throw new HttpError(401, 'Unauthorized');
172
+ const id = principal.id;
173
+ const kind = ${hasAdmin ? "principal.role === 'admin' ? 'admin' : 'user'" : "'user'"};
174
+ const model = ${hasAdmin ? "kind === 'admin' ? Admin : User" : "User"};
175
+ const account = ${hasAdmin ? `kind === 'admin' ? await ${find("Admin")} : await ${find("User")}` : `await ${find("User")}`};
176
+ if (!account || !account.isActive) throw new HttpError(403, 'Account unavailable');
177
+ return { account, kind, model };
178
+ }
179
+ `;
180
+ const revoke = ` await revokeAllSessions(account.id);
181
+ res.clearCookie('efc_refresh_token');
182
+ `;
183
+ const setup = `${imports}
184
+ export const GET = async (_req${T(": Request")}, res${T(": Response")}) => {
185
+ const { account } = await currentAccount();
186
+ res.set('Cache-Control', 'no-store');
187
+ res.json({ enabled: !!account.twoFactorEnabled, method: account.twoFactorMethod || null, methods: allowedMethods() });
188
+ };
189
+
190
+ export const POST = async (req${T(": Request")}, res${T(": Response")}) => {
191
+ res.set('Cache-Control', 'no-store');
192
+ const { password, code, method = 'totp' } = req.body ?? {};
193
+ if (typeof password !== 'string' || !password || (code !== undefined && (typeof code !== 'string' || !/^\\d{6}$/.test(code)))) return res.status(400).json({ error: 'password and a six-digit code (when confirming) are required' });
194
+ const { account, kind, model } = await currentAccount();
195
+ if (!(await verifyPassword(password, account.password))) return res.status(401).json({ error: 'Invalid credentials' });
196
+ if (account.twoFactorEnabled) return res.status(409).json({ error: 'Two-factor authentication is already enabled' });
197
+ const identity = kind + ':' + account.id;
198
+ if (typeof method !== 'string' || !allowedMethods().includes(method)) return res.status(400).json({ error: 'Choose an allowed two-factor method' });
199
+ if (code === undefined && method === 'email') {
200
+ const updated = await updateSecurity(model, { id: account.id, twoFactorEnabled: false }, { twoFactorPendingMethod: 'email', twoFactorPendingSecret: '', twoFactorPendingExpiry: new Date(Date.now() + 300000) });
201
+ if (!updated) return res.status(409).json({ error: 'Account changed; try again' });
202
+ await sendEmailCode(model, account, kind, 'enroll');
203
+ return res.status(202).json({ method: 'email', codeSent: true, expiresIn: 300 });
204
+ }
205
+ if (code === undefined) {
206
+ const secret = newSecret();
207
+ const encrypted = sealSecret(secret, identity);
208
+ const updated = await updateSecurity(model, { id: account.id, twoFactorEnabled: false }, { twoFactorPendingMethod: 'totp', twoFactorPendingSecret: encrypted, twoFactorPendingExpiry: new Date(Date.now() + 600000) });
209
+ if (!updated) return res.status(409).json({ error: 'Account changed; try again' });
210
+ const issuer = 'EFC';
211
+ const otpauthUrl = 'otpauth://totp/' + encodeURIComponent(issuer + ':' + account.email) + '?secret=' + secret + '&issuer=' + issuer + '&algorithm=SHA1&digits=6&period=30';
212
+ return res.json({ method: 'totp', secret, otpauthUrl, expiresIn: 600 });
213
+ }
214
+ if (account.twoFactorPendingMethod !== method || !account.twoFactorPendingExpiry || new Date(account.twoFactorPendingExpiry).getTime() <= Date.now()) return res.status(400).json({ error: 'Enrollment expired; start again' });
215
+ let step = -1;
216
+ if (method === 'email') {
217
+ if (!(await consumeEmailCode(model, account, kind, 'enroll', code))) return res.status(401).json({ error: 'Invalid or expired email code' });
218
+ } else {
219
+ if (!account.twoFactorPendingSecret) return res.status(400).json({ error: 'Start enrollment first' });
220
+ const matched = codeStep(openSecret(account.twoFactorPendingSecret, identity), code);
221
+ if (matched === undefined) return res.status(401).json({ error: 'Invalid authenticator code' });
222
+ step = matched;
223
+ }
224
+ const enabled = await updateSecurity(model, { id: account.id, twoFactorEnabled: false, twoFactorPendingMethod: method, twoFactorPendingSecret: account.twoFactorPendingSecret ?? '' }, {
225
+ twoFactorEnabled: true, twoFactorMethod: method, twoFactorSecret: method === 'totp' ? (account.twoFactorPendingSecret ?? '') : '', twoFactorLastStep: step,
226
+ twoFactorPendingMethod: '', twoFactorPendingSecret: '', twoFactorPendingExpiry: new Date(0), refreshToken: '', refreshTokenExpiry: new Date(0),
227
+ });
228
+ if (!enabled) return res.status(409).json({ error: 'Enrollment changed; start again' });
229
+ ${revoke} res.json({ message: '2FA enabled; log in again with your verification code' });
230
+ };
231
+ `;
232
+ const verify = `export { middlewares, POST } from '../login.js';
233
+ `;
234
+ const disable = `${imports}
235
+ export const POST = async (req${T(": Request")}, res${T(": Response")}) => {
236
+ const { password, code } = req.body ?? {};
237
+ if (typeof password !== 'string' || !password || (code !== undefined && (typeof code !== 'string' || !/^\\d{6}$/.test(code)))) return res.status(400).json({ error: 'password and a six-digit code are required' });
238
+ const { account, kind, model } = await currentAccount();
239
+ if (!(await verifyPassword(password, account.password))) return res.status(401).json({ error: 'Invalid credentials' });
240
+ if (!account.twoFactorEnabled) return res.status(409).json({ error: 'Two-factor authentication is not enabled' });
241
+ if (account.twoFactorMethod === 'email' && code === undefined) {
242
+ await sendEmailCode(model, account, kind, 'disable');
243
+ return res.status(202).json({ codeSent: true, expiresIn: 300 });
244
+ }
245
+ if (!(await acceptTwoFactor(model, account, kind, code, 'disable'))) return res.status(401).json({ error: 'Invalid or already used authenticator code' });
246
+ const disabled = await updateSecurity(model, { id: account.id, twoFactorEnabled: true, twoFactorSecret: account.twoFactorSecret ?? '' }, {
247
+ twoFactorEnabled: false, twoFactorMethod: '', twoFactorSecret: '', twoFactorLastStep: -1, twoFactorPendingSecret: '', twoFactorPendingMethod: '', twoFactorEmailHash: '', twoFactorEmailExpiry: new Date(0),
248
+ twoFactorPendingExpiry: new Date(0), refreshToken: '', refreshTokenExpiry: new Date(0),
249
+ });
250
+ if (!disabled) return res.status(409).json({ error: 'Account changed; try again' });
251
+ ${revoke} res.json({ message: '2FA disabled; log in again' });
252
+ };
253
+ `;
254
+ return { "2fa/setup": setup, "2fa/verify": verify, "2fa/disable": disable };
255
+ }
256
+
257
+ // src/scaffold.ts
13
258
  var NO_ADMIN_FEATURES = {
14
259
  userManagement: false,
15
260
  adminManagement: false,
@@ -46,18 +291,22 @@ function sendsMail(opts) {
46
291
  function usesTables(opts) {
47
292
  return opts.database === "postgresql" && opts.modelApi !== "defineModel";
48
293
  }
294
+ function usesCollections(opts) {
295
+ return opts.database === "mongodb" && opts.modelApi !== "defineModel";
296
+ }
49
297
  function orm(opts) {
50
298
  const t = usesTables(opts);
299
+ const c = usesCollections(opts);
51
300
  const where = (filter) => filter.replace(/\s/g, "") === "{}" ? "" : `{ where: ${filter} }`;
52
301
  const byId = (id) => id === "id" ? "{ id }" : `{ id: ${id} }`;
53
302
  return {
54
303
  findOne: (m, filter) => t ? `${m}.findFirst({ where: ${filter} })` : `${m}.findOne(${filter})`,
55
304
  findById: (m, id) => t ? `${m}.findUnique({ where: ${byId(id)} })` : `${m}.findById(${id})`,
56
305
  find: (m, filter) => t ? `${m}.findMany(${where(filter)})` : `${m}.find(${filter})`,
57
- count: (m, filter) => t ? `${m}.count(${where(filter)})` : `${m}.count(${filter})`,
306
+ count: (m, filter) => t ? `${m}.count(${where(filter)})` : c ? `${m}.countDocuments(${filter})` : `${m}.count(${filter})`,
58
307
  create: (m, data) => t ? `${m}.create({ data: ${data} })` : `${m}.create(${data})`,
59
- update: (m, id, data) => t ? `${m}.update({ where: ${byId(id)}, data: ${data} })` : `${m}.update(${id}, ${data})`,
60
- delete: (m, id) => t ? `${m}.delete({ where: ${byId(id)} })` : `${m}.delete(${id})`
308
+ update: (m, id, data) => t ? `${m}.update({ where: ${byId(id)}, data: ${data} })` : c ? `${m}.findByIdAndUpdate(${id}, ${data}, { returnDocument: 'after' })` : `${m}.update(${id}, ${data})`,
309
+ delete: (m, id) => t ? `${m}.delete({ where: ${byId(id)} })` : c ? `${m}.findByIdAndDelete(${id})` : `${m}.delete(${id})`
61
310
  };
62
311
  }
63
312
  function generatedModels(opts) {
@@ -83,7 +332,7 @@ function generatedModels(opts) {
83
332
  return m;
84
333
  }
85
334
  function modelApi(opts) {
86
- const name = usesTables(opts) ? "defineTable" : "defineModel";
335
+ const name = usesTables(opts) ? "defineTable" : usesCollections(opts) ? "defineCollection" : "defineModel";
87
336
  return { name, import: `import { ${name} } from 'express-file-cluster';` };
88
337
  }
89
338
  function refField(opts, r) {
@@ -153,7 +402,7 @@ async function scaffold(opts) {
153
402
  }
154
403
  async function writePackageJson(dest, opts) {
155
404
  const deps = {
156
- "express-file-cluster": "^0.9.6"
405
+ "express-file-cluster": "^0.10.2"
157
406
  };
158
407
  if (opts.database === "mongodb") deps["mongoose"] = "^9.7.2";
159
408
  if (opts.database === "postgresql") {
@@ -238,6 +487,9 @@ ${declaration}
238
487
  jwtExpiresIn: process.env.JWT_EXPIRES_IN,
239
488
  cookieDomain: process.env.COOKIE_DOMAIN,
240
489
  cors: corsOrigins ? { origin: corsOrigins } : true,
490
+ // Behind a proxy / load balancer (Render, Fly, Heroku, nginx): the number of hops, e.g. 1,
491
+ // so req.ip is the client and rate limits count per client.
492
+ trustProxy: process.env.TRUST_PROXY ? Number(process.env.TRUST_PROXY) : undefined,
241
493
  authStrategy: '${opts.authStrategy}',
242
494
  tasks: ${tasks},
243
495
  // Cache + session-revocation store (logout really invalidates tokens). Redis when
@@ -311,10 +563,11 @@ Optional \u2014 the same docs over MCP, with search and scaffolding tools:
311
563
  ${usesTables(opts) ? `- \`src/model/*.${ext}\` \u2014 \`defineTable\` models (PostgreSQL-native, Prisma-style queries:
312
564
  \`findMany({ where, include })\`, \`create({ data })\`, \`update({ where, data })\`). A \`ref\` is a
313
565
  foreign key; ${opts.registry ? "add back-relations to a model's `relations` when you need `include`" : "back-relations are in each model's `relations`"}. \`src/model/index.${ext}\` imports
314
- them all${opts.language === "typescript" ? " and declares `Tables` (typed `include`)" : ""} \u2014 add new models there.` : `- \`src/model/*.${ext}\` \u2014 \`defineModel\` models.`}
566
+ them all${opts.language === "typescript" ? " and declares `Tables` (typed `include`)" : ""} \u2014 add new models there.` : usesCollections(opts) ? `- \`src/model/*.${ext}\` \u2014 \`defineCollection\` models (MongoDB-native, mongoose-style queries:
567
+ \`find(filter, { projection, sort })\`, \`findByIdAndUpdate\`, typed \`pipeline()\`, \`lookup()\`).` : `- \`src/model/*.${ext}\` \u2014 \`defineModel\` models.`}
315
568
  - \`src/index.${ext}\` is the entry point; it imports \`efc.config.${ext}\` and passes it to \`ignite()\`.
316
- - Only three import paths exist: \`express-file-cluster\`, \`express-file-cluster/auth\`,
317
- \`express-file-cluster/tasks\`.
569
+ - Four import paths exist: \`express-file-cluster\`, \`express-file-cluster/auth\`,
570
+ \`express-file-cluster/tasks\`, and \`express-file-cluster/testing\` (\`createTestApp\` for route tests).
318
571
  ${opts.registry ? `- Ready-made features (auth, user/admin portals, billing, mailer\u2026) come from the registry:
319
572
  \`npx efc list\`, then \`npx efc add <item> --yes\` \u2014 check it before hand-writing auth or CRUD
320
573
  (\`${D}/cli/add.md\`). \`efc.json\` tracks what was added; commit it.
@@ -391,13 +644,16 @@ ${k}=${ex}`).join("");
391
644
  const dotenv = `PORT=3000
392
645
  NODE_ENV=development
393
646
  DATABASE_URL=${dbUrl}
394
- JWT_SECRET=${secret}${redis}
647
+ JWT_SECRET=${secret}${opts.userFeatures.accountSecurity ? "\nTWO_FACTOR_METHOD=totp" : ""}${principals(opts).user || principals(opts).admin ? `
648
+ TWO_FACTOR_KEY=${crypto.randomBytes(32).toString("hex")}` : ""}${redis}
395
649
  CORS_ORIGINS=http://localhost:3000${smtpVars}${oauthEnv}`;
396
650
  const example = `PORT=3000
397
651
  NODE_ENV=development
398
652
  DATABASE_URL=${dbExampleUrl}
399
- JWT_SECRET=<generate with: openssl rand -hex 64>${redis}
400
- CORS_ORIGINS=http://localhost:3000,https://yourapp.com${smtpExample}${oauthExample}`;
653
+ JWT_SECRET=<generate with: openssl rand -hex 64>${opts.userFeatures.accountSecurity ? "\nTWO_FACTOR_METHOD=totp" : ""}${principals(opts).user || principals(opts).admin ? "\nTWO_FACTOR_KEY=<generate with: openssl rand -hex 32>" : ""}${redis}
654
+ CORS_ORIGINS=http://localhost:3000,https://yourapp.com
655
+ # Behind a proxy / load balancer: how many hops to trust, so rate limits see client IPs
656
+ # TRUST_PROXY=1${smtpExample}${oauthExample}`;
401
657
  await fs.outputFile(path.join(dest, ".env"), dotenv);
402
658
  await fs.outputFile(path.join(dest, ".env.example"), example);
403
659
  }
@@ -523,6 +779,16 @@ export const User = ${modelApi(opts).name}('User', {
523
779
  resetTokenExpiry: { type: 'date' },
524
780
  refreshToken: { type: 'string' },
525
781
  refreshTokenExpiry: { type: 'date' },
782
+ twoFactorEnabled: { type: 'boolean', default: false },
783
+ twoFactorSecret: { type: 'string', default: '' },
784
+ twoFactorLastStep: { type: 'number', default: -1 },
785
+ twoFactorPendingSecret: { type: 'string', default: '' },
786
+ twoFactorPendingExpiry: { type: 'date' },
787
+ twoFactorMethod: { type: 'string', default: '' },
788
+ twoFactorPendingMethod: { type: 'string', default: '' },
789
+ twoFactorEmailHash: { type: 'string', default: '' },
790
+ twoFactorEmailExpiry: { type: 'date' },
791
+ twoFactorEmailPurpose: { type: 'string', default: '' },
526
792
  }${relationsOption(opts, [
527
793
  { name: "sessions", kind: "hasMany", target: "Session", foreignKey: "user" },
528
794
  { name: "notifications", kind: "hasMany", target: "Notification", foreignKey: "user" },
@@ -550,6 +816,16 @@ export const Admin = ${modelApi(opts).name}('Admin', {
550
816
  resetTokenExpiry: { type: 'date' },
551
817
  refreshToken: { type: 'string' },
552
818
  refreshTokenExpiry: { type: 'date' },
819
+ twoFactorEnabled: { type: 'boolean', default: false },
820
+ twoFactorSecret: { type: 'string', default: '' },
821
+ twoFactorLastStep: { type: 'number', default: -1 },
822
+ twoFactorPendingSecret: { type: 'string', default: '' },
823
+ twoFactorPendingExpiry: { type: 'date' },
824
+ twoFactorMethod: { type: 'string', default: '' },
825
+ twoFactorPendingMethod: { type: 'string', default: '' },
826
+ twoFactorEmailHash: { type: 'string', default: '' },
827
+ twoFactorEmailExpiry: { type: 'date' },
828
+ twoFactorEmailPurpose: { type: 'string', default: '' },
553
829
  }${relationsOption(opts, [
554
830
  { name: "assignedTickets", kind: "hasMany", target: "SupportTicket", foreignKey: "assignedTo" },
555
831
  { name: "auditLogs", kind: "hasMany", target: "AuditLog", foreignKey: "admin" },
@@ -583,7 +859,13 @@ export const meta: RouteMeta = {
583
859
  ` : "";
584
860
  const { user: hasUser, admin: hasAdmin } = principals(opts);
585
861
  const hasModels = hasUser || hasAdmin;
862
+ if (hasModels)
863
+ await fs.outputFile(
864
+ path.join(dest, "src", "lib", `two-factor.${ext}`),
865
+ twoFactorLibrary(ts, usesTables(opts), usesCollections(opts))
866
+ );
586
867
  const loginDbImports = hasModels ? `import crypto from 'node:crypto';
868
+ import { acceptTwoFactor, updateSecurity } from '../../lib/two-factor.js';
587
869
  ${hasUser ? `import { User } from '../../model/User.js';
588
870
  ` : ""}${hasAdmin ? `import { Admin } from '../../model/Admin.js';
589
871
  ` : ""}` : "";
@@ -592,8 +874,9 @@ ${hasUser ? `import { User } from '../../model/User.js';
592
874
  const match = await verifyPassword(password, admin.password);
593
875
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
594
876
  if (!admin.isActive) return res.status(403).json({ error: 'Account suspended' });
595
- const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
596
- const refreshToken = await issueRefreshToken(res, Admin, admin.id);
877
+ if (!(await acceptTwoFactor(Admin, admin, 'admin', req.body?.code))) return res.status(401).json({ error: 'Verification code required or invalid', twoFactorRequired: true, method: admin.twoFactorMethod || 'totp' });
878
+ const refreshToken = await issueRefreshToken(res, Admin, admin);
879
+ const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email, efc_auth_at: authenticatedAt });
597
880
  return res.json({ message: 'Logged in as admin', ...(token ? { token, refreshToken } : {}) });
598
881
  }
599
882
 
@@ -602,37 +885,55 @@ ${hasUser ? `import { User } from '../../model/User.js';
602
885
  const match = await verifyPassword(password, user.password);
603
886
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
604
887
  if (!user.isActive) return res.status(403).json({ error: 'Account suspended' });
605
- const token = await issueToken(res, { id: user.id, role: user.role, email: user.email });
606
- const refreshToken = await issueRefreshToken(res, User, user.id);
888
+ if (!(await acceptTwoFactor(User, user, 'user', req.body?.code))) return res.status(401).json({ error: 'Verification code required or invalid', twoFactorRequired: true, method: user.twoFactorMethod || 'totp' });
889
+ const refreshToken = await issueRefreshToken(res, User, user);
890
+ const token = await issueToken(res, { id: user.id, role: user.role, email: user.email, efc_auth_at: authenticatedAt });
607
891
  // bearer: the client stores both tokens itself; http-only: they travel as cookies.
608
892
  res.json({ message: 'Logged in', ...(token ? { token, refreshToken } : {}) });` : hasAdmin ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
609
893
  if (!admin) return res.status(401).json({ error: 'Invalid credentials' });
610
894
  const match = await verifyPassword(password, admin.password);
611
895
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
612
896
  if (!admin.isActive) return res.status(403).json({ error: 'Account suspended' });
613
- const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
614
- const refreshToken = await issueRefreshToken(res, Admin, admin.id);
897
+ if (!(await acceptTwoFactor(Admin, admin, 'admin', req.body?.code))) return res.status(401).json({ error: 'Verification code required or invalid', twoFactorRequired: true, method: admin.twoFactorMethod || 'totp' });
898
+ const refreshToken = await issueRefreshToken(res, Admin, admin);
899
+ const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email, efc_auth_at: authenticatedAt });
615
900
  // bearer: the client stores both tokens itself; http-only: they travel as cookies.
616
901
  res.json({ message: 'Logged in as admin', ...(token ? { token, refreshToken } : {}) });` : ` const user = await ${db.findOne("User", `{ email }`)};
617
902
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });
618
903
  const match = await verifyPassword(password, user.password);
619
904
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
620
905
  if (!user.isActive) return res.status(403).json({ error: 'Account suspended' });
621
- const token = await issueToken(res, { id: user.id, role: user.role, email: user.email });
622
- const refreshToken = await issueRefreshToken(res, User, user.id);
906
+ if (!(await acceptTwoFactor(User, user, 'user', req.body?.code))) return res.status(401).json({ error: 'Verification code required or invalid', twoFactorRequired: true, method: user.twoFactorMethod || 'totp' });
907
+ const refreshToken = await issueRefreshToken(res, User, user);
908
+ const token = await issueToken(res, { id: user.id, role: user.role, email: user.email, efc_auth_at: authenticatedAt });
623
909
  // bearer: the client stores both tokens itself; http-only: they travel as cookies.
624
910
  res.json({ message: 'Logged in', ...(token ? { token, refreshToken } : {}) });`;
625
911
  const loginContent = hasModels ? ts ? `import { issueToken, verifyPassword } from 'express-file-cluster/auth';
626
912
  import type { Request, Response } from 'express';
627
- ${loginDbImports}${loginMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
913
+ import { HttpError, rateLimit } from 'express-file-cluster';
914
+ ${loginDbImports}${loginMeta}// 10 attempts per 15 minutes per address and account. Keyed on the email too, so behind a
915
+ // proxy without TRUST_PROXY (every client then has the proxy's IP) it still limits one
916
+ // account at a time, never everyone.
917
+ export const middlewares = [
918
+ rateLimit({
919
+ window: '15m',
920
+ max: 10,
921
+ name: 'password-login',
922
+ onCacheError: 'reject',
923
+ key: (req) => String(req.body?.email ?? '').toLowerCase(),
924
+ }),
925
+ ];
926
+
927
+ const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
628
928
 
629
929
  async function issueRefreshToken(
630
930
  res: Response,
631
- model: ${usesTables(opts) ? "{ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown> }" : "{ update: (id: string, data: Record<string, unknown>) => Promise<unknown> }"},
632
- id: string,
931
+ model: Parameters<typeof updateSecurity>[0],
932
+ account: { id: string; password: string; twoFactorEnabled?: boolean; twoFactorSecret?: string },
633
933
  ): Promise<string> {
634
934
  const refreshToken = crypto.randomBytes(40).toString('hex');
635
- await ${db.update("model", "id", "{ refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) }")};
935
+ const updated = await updateSecurity(model, { id: account.id, password: account.password, twoFactorEnabled: !!account.twoFactorEnabled, ...(account.twoFactorEnabled ? { twoFactorSecret: account.twoFactorSecret ?? '' } : {}) }, { refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) });
936
+ if (!updated) throw new HttpError(409, 'Account security changed; log in again');
636
937
  res.cookie('efc_refresh_token', refreshToken, {
637
938
  httpOnly: true,
638
939
  secure: process.env.NODE_ENV === 'production',
@@ -643,7 +944,8 @@ async function issueRefreshToken(
643
944
  }
644
945
 
645
946
  export const POST = async (req: Request, res: Response) => {
646
- const { email, password } = req.body;
947
+ const authenticatedAt = Date.now();
948
+ const { email, password } = req.body ?? {};
647
949
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
648
950
  if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
649
951
  return res.status(400).json({ error: 'email and password are required' });
@@ -652,11 +954,26 @@ export const POST = async (req: Request, res: Response) => {
652
954
  ${loginBody}
653
955
  };
654
956
  ` : `import { issueToken, verifyPassword } from 'express-file-cluster/auth';
655
- ${loginDbImports}${loginMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
957
+ import { HttpError, rateLimit } from 'express-file-cluster';
958
+ ${loginDbImports}${loginMeta}// 10 attempts per 15 minutes per address and account. Keyed on the email too, so behind a
959
+ // proxy without TRUST_PROXY (every client then has the proxy's IP) it still limits one
960
+ // account at a time, never everyone.
961
+ export const middlewares = [
962
+ rateLimit({
963
+ window: '15m',
964
+ max: 10,
965
+ name: 'password-login',
966
+ onCacheError: 'reject',
967
+ key: (req) => String(req.body?.email ?? '').toLowerCase(),
968
+ }),
969
+ ];
970
+
971
+ const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
656
972
 
657
- async function issueRefreshToken(res, model, id) {
973
+ async function issueRefreshToken(res, model, account) {
658
974
  const refreshToken = crypto.randomBytes(40).toString('hex');
659
- await ${db.update("model", "id", "{ refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) }")};
975
+ const updated = await updateSecurity(model, { id: account.id, password: account.password, twoFactorEnabled: !!account.twoFactorEnabled, ...(account.twoFactorEnabled ? { twoFactorSecret: account.twoFactorSecret ?? '' } : {}) }, { refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) });
976
+ if (!updated) throw new HttpError(409, 'Account security changed; log in again');
660
977
  res.cookie('efc_refresh_token', refreshToken, {
661
978
  httpOnly: true,
662
979
  secure: process.env.NODE_ENV === 'production',
@@ -667,7 +984,8 @@ async function issueRefreshToken(res, model, id) {
667
984
  }
668
985
 
669
986
  export const POST = async (req, res) => {
670
- const { email, password } = req.body;
987
+ const authenticatedAt = Date.now();
988
+ const { email, password } = req.body ?? {};
671
989
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
672
990
  if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
673
991
  return res.status(400).json({ error: 'email and password are required' });
@@ -706,18 +1024,30 @@ export const meta: RouteMeta = {
706
1024
  };
707
1025
 
708
1026
  ` : "";
709
- const logoutContent = ts ? `import { revokeSession } from 'express-file-cluster/auth';
1027
+ const logoutDbImports = hasModels ? `import { updateSecurity } from '../../lib/two-factor.js';
1028
+ ${hasUser ? "import { User } from '../../model/User.js';\n" : ""}${hasAdmin ? "import { Admin } from '../../model/Admin.js';\n" : ""}` : "";
1029
+ const logoutInvalidate = hasModels ? ` const principal = getCurrentUser();
1030
+ if (!principal || typeof principal.id !== 'string') return res.status(401).json({ error: 'Unauthorized' });
1031
+ const id = principal.id;
1032
+ ${hasUser ? ` const user = ${hasAdmin ? "principal.role === 'admin' ? null : " : ""}await ${db.findById("User", "id")};
1033
+ if (user?.refreshToken) await updateSecurity(User, { id, refreshToken: user.refreshToken }, { refreshToken: '', refreshTokenExpiry: new Date(0) });` : ""}
1034
+ ${hasAdmin ? ` const admin = ${hasUser ? "principal.role === 'admin' ? " : ""}await ${db.findById("Admin", "id")}${hasUser ? " : null" : ""};
1035
+ if (admin?.refreshToken) await updateSecurity(Admin, { id, refreshToken: admin.refreshToken }, { refreshToken: '', refreshTokenExpiry: new Date(0) });` : ""}
1036
+ ` : "";
1037
+ const logoutAuthImports = hasModels ? "requireAuth, getCurrentUser, revokeSession" : "revokeSession";
1038
+ const logoutMiddlewares = hasModels ? "export const middlewares = [requireAuth];\n\n" : "";
1039
+ const logoutContent = ts ? `import { ${logoutAuthImports} } from 'express-file-cluster/auth';
710
1040
  import type { Request, Response } from 'express';
711
- ${logoutMeta}export const POST = async (req: Request, res: Response) => {
1041
+ ${logoutDbImports}${logoutMeta}${logoutMiddlewares}export const POST = async (req: Request, res: Response) => {
712
1042
  // Rejects this token from now on (not just the cookie) \u2014 see \`cache\` in efc.config.
713
- await revokeSession(req, res);
1043
+ ${logoutInvalidate} await revokeSession(req, res);
714
1044
  res.clearCookie('efc_refresh_token');
715
1045
  res.json({ message: 'Logged out successfully' });
716
1046
  };
717
- ` : `import { revokeSession } from 'express-file-cluster/auth';
718
- ${logoutMeta}export const POST = async (req, res) => {
1047
+ ` : `import { ${logoutAuthImports} } from 'express-file-cluster/auth';
1048
+ ${logoutDbImports}${logoutMeta}${logoutMiddlewares}export const POST = async (req, res) => {
719
1049
  // Rejects this token from now on (not just the cookie) \u2014 see \`cache\` in efc.config.
720
- await revokeSession(req, res);
1050
+ ${logoutInvalidate} await revokeSession(req, res);
721
1051
  res.clearCookie('efc_refresh_token');
722
1052
  res.json({ message: 'Logged out successfully' });
723
1053
  };
@@ -771,7 +1101,7 @@ ${registerDbImports}${registerMeta}export const POST = async (req: Request, res:
771
1101
  const verifyToken = crypto.randomBytes(32).toString('hex');
772
1102
  const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
773
1103
  ${sendVerifyEmail}
774
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1104
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = user;
775
1105
  res.status(201).json({ message: 'Account created successfully', user: safe });
776
1106
  };
777
1107
  ` : `${registerDbImports}${registerMeta}export const POST = async (req, res) => {
@@ -786,7 +1116,7 @@ ${sendVerifyEmail}
786
1116
  const verifyToken = crypto.randomBytes(32).toString('hex');
787
1117
  const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
788
1118
  ${sendVerifyEmail}
789
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1119
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = user;
790
1120
  res.status(201).json({ message: 'Account created successfully', user: safe });
791
1121
  };
792
1122
  `;
@@ -938,7 +1268,7 @@ export const GET = async (req: Request, res: Response) => {
938
1268
  ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
939
1269
  const limit = Math.min(100, Number(req.query.limit) || 20);
940
1270
  const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
941
- const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...u }) => u);
1271
+ const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...u }) => u);
942
1272
  res.json({ users, total, page, limit });
943
1273
  };
944
1274
 
@@ -952,7 +1282,7 @@ ${roleGuard} const { name, email, password, role } = req.body;
952
1282
  if (existing) return res.status(409).json({ error: 'Email already in use' });
953
1283
  const hashed = await hashPassword(password);
954
1284
  const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
955
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1285
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = user;
956
1286
  res.status(201).json({ message: 'User created', user: safe });
957
1287
  };
958
1288
  ` : `import { hashPassword, requireAuth } from 'express-file-cluster/auth';
@@ -961,7 +1291,7 @@ export const GET = async (req, res) => {
961
1291
  ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
962
1292
  const limit = Math.min(100, Number(req.query.limit) || 20);
963
1293
  const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
964
- const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...u }) => u);
1294
+ const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...u }) => u);
965
1295
  res.json({ users, total, page, limit });
966
1296
  };
967
1297
 
@@ -975,7 +1305,7 @@ ${roleGuard} const { name, email, password, role } = req.body;
975
1305
  if (existing) return res.status(409).json({ error: 'Email already in use' });
976
1306
  const hashed = await hashPassword(password);
977
1307
  const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
978
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1308
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = user;
979
1309
  res.status(201).json({ message: 'User created', user: safe });
980
1310
  };
981
1311
  `;
@@ -1031,7 +1361,7 @@ export const GET = async (req: Request, res: Response) => {
1031
1361
  ${roleGuard} const { id } = req.params;
1032
1362
  const user = await ${db.findById("User", `id`)};
1033
1363
  if (!user) return res.status(404).json({ error: 'User not found' });
1034
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1364
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = user;
1035
1365
  res.json({ user: safe });
1036
1366
  };
1037
1367
 
@@ -1043,7 +1373,7 @@ ${roleGuard} const { id } = req.params;
1043
1373
  // The role lives in the user's JWTs: end them, or a demoted/suspended user keeps access
1044
1374
  // until they expire. (/auth/refresh also refuses inactive accounts.)
1045
1375
  if (role !== undefined || isActive === false) await revokeAllSessions(id);
1046
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
1376
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = updated;
1047
1377
  res.json({ message: 'User updated', user: safe });
1048
1378
  };
1049
1379
 
@@ -1061,7 +1391,7 @@ export const GET = async (req, res) => {
1061
1391
  ${roleGuard} const { id } = req.params;
1062
1392
  const user = await ${db.findById("User", `id`)};
1063
1393
  if (!user) return res.status(404).json({ error: 'User not found' });
1064
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1394
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = user;
1065
1395
  res.json({ user: safe });
1066
1396
  };
1067
1397
 
@@ -1073,7 +1403,7 @@ ${roleGuard} const { id } = req.params;
1073
1403
  // The role lives in the user's JWTs: end them, or a demoted/suspended user keeps access
1074
1404
  // until they expire. (/auth/refresh also refuses inactive accounts.)
1075
1405
  if (role !== undefined || isActive === false) await revokeAllSessions(id);
1076
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
1406
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = updated;
1077
1407
  res.json({ message: 'User updated', user: safe });
1078
1408
  };
1079
1409
 
@@ -1134,16 +1464,20 @@ export const GET = async (req: Request, res: Response) => {
1134
1464
  const { id } = (req as any).user;
1135
1465
  const user = await ${db.findById("User", `id`)};
1136
1466
  if (!user) return res.status(404).json({ error: 'User not found' });
1137
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1467
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = user;
1138
1468
  res.json({ user: safe });
1139
1469
  };
1140
1470
 
1141
1471
  export const PUT = async (req: Request, res: Response) => {
1142
1472
  const { id } = (req as any).user;
1143
- const { name, email } = req.body;
1473
+ const { name, email } = req.body ?? {};
1474
+ if ((name !== undefined && typeof name !== 'string') || (email !== undefined && typeof email !== 'string')) return res.status(400).json({ error: 'name and email must be strings' });
1475
+ const existing = await ${db.findById("User", `id`)};
1476
+ if (!existing) return res.status(404).json({ error: 'User not found' });
1477
+ if (email !== undefined && email !== existing.email && existing.twoFactorEnabled && existing.twoFactorMethod === 'email') return res.status(409).json({ error: 'Disable email 2FA before changing the email address' });
1144
1478
  const updated = await ${db.update("User", `id`, `{ name, email }`)};
1145
1479
  if (!updated) return res.status(404).json({ error: 'User not found' });
1146
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
1480
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = updated;
1147
1481
  res.json({ message: 'Profile updated', user: safe });
1148
1482
  };
1149
1483
  ` : `import { requireAuth } from 'express-file-cluster/auth';
@@ -1152,16 +1486,20 @@ export const GET = async (req, res) => {
1152
1486
  const { id } = req.user;
1153
1487
  const user = await ${db.findById("User", `id`)};
1154
1488
  if (!user) return res.status(404).json({ error: 'User not found' });
1155
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1489
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = user;
1156
1490
  res.json({ user: safe });
1157
1491
  };
1158
1492
 
1159
1493
  export const PUT = async (req, res) => {
1160
1494
  const { id } = req.user;
1161
- const { name, email } = req.body;
1495
+ const { name, email } = req.body ?? {};
1496
+ if ((name !== undefined && typeof name !== 'string') || (email !== undefined && typeof email !== 'string')) return res.status(400).json({ error: 'name and email must be strings' });
1497
+ const existing = await ${db.findById("User", `id`)};
1498
+ if (!existing) return res.status(404).json({ error: 'User not found' });
1499
+ if (email !== undefined && email !== existing.email && existing.twoFactorEnabled && existing.twoFactorMethod === 'email') return res.status(409).json({ error: 'Disable email 2FA before changing the email address' });
1162
1500
  const updated = await ${db.update("User", `id`, `{ name, email }`)};
1163
1501
  if (!updated) return res.status(404).json({ error: 'User not found' });
1164
- const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
1502
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, twoFactorSecret: _tfs, twoFactorPendingSecret: _tfp, twoFactorPendingExpiry: _tfpe, twoFactorLastStep: _tfl, twoFactorEmailHash: _tfeh, twoFactorEmailExpiry: _tfee, twoFactorEmailPurpose: _tfep, twoFactorPendingMethod: _tfpm, ...safe } = updated;
1165
1503
  res.json({ message: 'Profile updated', user: safe });
1166
1504
  };
1167
1505
  `;
@@ -1449,14 +1787,18 @@ ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
1449
1787
  const admin = user ? null : await ${db.findOne("Admin", `{ refreshToken: token }`)};
1450
1788
  const account = user || admin;` : ` const user = await ${db.findOne("User", `{ refreshToken: token }`)};
1451
1789
  const account = user;`;
1452
- const refreshPersist = hasAdmin ? ` if (user) await ${db.update("User", `user.id`, `{ refreshToken: newRefreshToken, refreshTokenExpiry }`)};
1453
- else if (admin) await ${db.update("Admin", `admin.id`, `{ refreshToken: newRefreshToken, refreshTokenExpiry }`)};` : ` await ${db.update("User", `user.id`, `{ refreshToken: newRefreshToken, refreshTokenExpiry }`)};`;
1790
+ const refreshPersist = hasAdmin ? ` const model = user ? User : Admin;
1791
+ const rotated = await updateSecurity(model, { id: account.id, refreshToken: token, twoFactorEnabled: !!account.twoFactorEnabled }, { refreshToken: newRefreshToken, refreshTokenExpiry });
1792
+ if (!rotated) return res.status(401).json({ error: 'Refresh token expired or already used' });` : ` const rotated = await updateSecurity(User, { id: account.id, refreshToken: token, twoFactorEnabled: !!account.twoFactorEnabled }, { refreshToken: newRefreshToken, refreshTokenExpiry });
1793
+ if (!rotated) return res.status(401).json({ error: 'Refresh token expired or already used' });`;
1454
1794
  const refreshContent = ts ? `import { issueToken } from 'express-file-cluster/auth';
1455
1795
  import type { Request, Response } from 'express';
1456
1796
  import crypto from 'node:crypto';
1797
+ import { updateSecurity } from '../../lib/two-factor.js';
1457
1798
  ${refreshModelImports}${refreshMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
1458
1799
 
1459
1800
  export const POST = async (req: Request, res: Response) => {
1801
+ const authenticatedAt = Date.now();
1460
1802
  const token = req.cookies?.['efc_refresh_token'] || req.body?.refreshToken;
1461
1803
  if (!token) return res.status(401).json({ error: 'Refresh token required' });
1462
1804
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
@@ -1480,7 +1822,7 @@ ${refreshPersist}
1480
1822
  maxAge: REFRESH_TOKEN_TTL_MS,
1481
1823
  });
1482
1824
 
1483
- const accessToken = await issueToken(res, { id: account.id, role: account.role, email: account.email });
1825
+ const accessToken = await issueToken(res, { id: account.id, role: account.role, email: account.email, efc_auth_at: authenticatedAt });
1484
1826
  // bearer: the client stores both tokens itself; http-only: they travel as cookies.
1485
1827
  res.json({
1486
1828
  message: 'Token refreshed',
@@ -1489,9 +1831,11 @@ ${refreshPersist}
1489
1831
  };
1490
1832
  ` : `import { issueToken } from 'express-file-cluster/auth';
1491
1833
  import crypto from 'node:crypto';
1834
+ import { updateSecurity } from '../../lib/two-factor.js';
1492
1835
  ${refreshModelImports}${refreshMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
1493
1836
 
1494
1837
  export const POST = async (req, res) => {
1838
+ const authenticatedAt = Date.now();
1495
1839
  const token = req.cookies?.efc_refresh_token || req.body?.refreshToken;
1496
1840
  if (!token) return res.status(401).json({ error: 'Refresh token required' });
1497
1841
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
@@ -1515,7 +1859,7 @@ ${refreshPersist}
1515
1859
  maxAge: REFRESH_TOKEN_TTL_MS,
1516
1860
  });
1517
1861
 
1518
- const accessToken = await issueToken(res, { id: account.id, role: account.role, email: account.email });
1862
+ const accessToken = await issueToken(res, { id: account.id, role: account.role, email: account.email, efc_auth_at: authenticatedAt });
1519
1863
  // bearer: the client stores both tokens itself; http-only: they travel as cookies.
1520
1864
  res.json({
1521
1865
  message: 'Token refreshed',
@@ -1612,7 +1956,13 @@ ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
1612
1956
  else if (admin) await ${db.update("Admin", `admin.id`, `{ resetToken, resetTokenExpiry }`)};` : ` await ${db.update("User", `user.id`, `{ resetToken, resetTokenExpiry }`)};`;
1613
1957
  const fpContent = ts ? `import type { Request, Response } from 'express';
1614
1958
  import crypto from 'node:crypto';
1615
- ${mailerTaskImport}${fpModelImports}${fpMeta}const RESET_TOKEN_TTL_MS = 1000 * 60 * 60; // 1 hour
1959
+ import { rateLimit } from 'express-file-cluster';
1960
+ ${mailerTaskImport}${fpModelImports}${fpMeta}// 5 reset emails per hour per address \u2014 keeps the endpoint from mail-bombing someone.
1961
+ export const middlewares = [
1962
+ rateLimit({ window: '1h', max: 5, key: (req) => String(req.body?.email ?? '').toLowerCase() }),
1963
+ ];
1964
+
1965
+ const RESET_TOKEN_TTL_MS = 1000 * 60 * 60; // 1 hour
1616
1966
 
1617
1967
  export const POST = async (req: Request, res: Response) => {
1618
1968
  const { email } = req.body;
@@ -1632,7 +1982,13 @@ ${sendResetEmail} }
1632
1982
  res.json({ message: 'Reset email sent' });
1633
1983
  };
1634
1984
  ` : `import crypto from 'node:crypto';
1635
- ${mailerTaskImport}${fpModelImports}${fpMeta}const RESET_TOKEN_TTL_MS = 1000 * 60 * 60; // 1 hour
1985
+ import { rateLimit } from 'express-file-cluster';
1986
+ ${mailerTaskImport}${fpModelImports}${fpMeta}// 5 reset emails per hour per address \u2014 keeps the endpoint from mail-bombing someone.
1987
+ export const middlewares = [
1988
+ rateLimit({ window: '1h', max: 5, key: (req) => String(req.body?.email ?? '').toLowerCase() }),
1989
+ ];
1990
+
1991
+ const RESET_TOKEN_TTL_MS = 1000 * 60 * 60; // 1 hour
1636
1992
 
1637
1993
  export const POST = async (req, res) => {
1638
1994
  const { email } = req.body;
@@ -1667,12 +2023,14 @@ ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
1667
2023
  const admin = user ? null : await ${db.findOne("Admin", `{ resetToken: token }`)};
1668
2024
  const account = user || admin;` : ` const user = await ${db.findOne("User", `{ resetToken: token }`)};
1669
2025
  const account = user;`;
1670
- const rpPersist = hasAdmin ? ` if (user) await ${db.update("User", `user.id`, `{ password: hashed, resetToken: '' }`)};
1671
- else if (admin) await ${db.update("Admin", `admin.id`, `{ password: hashed, resetToken: '' }`)};` : ` await ${db.update("User", `user.id`, `{ password: hashed, resetToken: '' }`)};`;
2026
+ const resetData = `{ password: hashed, resetToken: '', resetTokenExpiry: new Date(0), refreshToken: '', refreshTokenExpiry: new Date(0), twoFactorPendingSecret: '', twoFactorPendingMethod: '', twoFactorPendingExpiry: new Date(0), twoFactorEmailHash: '', twoFactorEmailExpiry: new Date(0) }`;
2027
+ const resetWhere = `{ id: account.id, resetToken: token, resetTokenExpiry: { ${usesTables(opts) ? "gt" : "$gt"}: new Date() } }`;
2028
+ const rpPersist = hasAdmin ? ` const consumed = await updateSecurity(user ? User : Admin, ${resetWhere}, ${resetData});` : ` const consumed = await updateSecurity(User, ${resetWhere}, ${resetData});`;
1672
2029
  const rpContent = ts ? `import type { Request, Response } from 'express';
1673
2030
  import { hashPassword, revokeAllSessions } from 'express-file-cluster/auth';
2031
+ import { updateSecurity } from '../../lib/two-factor.js';
1674
2032
  ${rpModelImports}${rpMeta}export const POST = async (req: Request, res: Response) => {
1675
- const { token, password } = req.body;
2033
+ const { token, password } = req.body ?? {};
1676
2034
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1677
2035
  if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
1678
2036
  return res.status(400).json({ error: 'token and password are required' });
@@ -1686,14 +2044,16 @@ ${rpLookup}
1686
2044
 
1687
2045
  const hashed = await hashPassword(password);
1688
2046
  ${rpPersist}
2047
+ if (!consumed) return res.status(400).json({ error: 'Invalid, expired or already used reset token' });
1689
2048
  // Tokens issued with the old password stop working everywhere.
1690
2049
  await revokeAllSessions(account.id);
1691
2050
 
1692
2051
  res.json({ message: 'Password reset successfully' });
1693
2052
  };
1694
2053
  ` : `import { hashPassword, revokeAllSessions } from 'express-file-cluster/auth';
2054
+ import { updateSecurity } from '../../lib/two-factor.js';
1695
2055
  ${rpModelImports}${rpMeta}export const POST = async (req, res) => {
1696
- const { token, password } = req.body;
2056
+ const { token, password } = req.body ?? {};
1697
2057
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1698
2058
  if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
1699
2059
  return res.status(400).json({ error: 'token and password are required' });
@@ -1707,6 +2067,7 @@ ${rpLookup}
1707
2067
 
1708
2068
  const hashed = await hashPassword(password);
1709
2069
  ${rpPersist}
2070
+ if (!consumed) return res.status(400).json({ error: 'Invalid, expired or already used reset token' });
1710
2071
  // Tokens issued with the old password stop working everywhere.
1711
2072
  await revokeAllSessions(account.id);
1712
2073
 
@@ -1723,165 +2084,77 @@ ${rpPersist}
1723
2084
  response: `{ message: 'Password changed successfully' }`
1724
2085
  }
1725
2086
  });
1726
- const cpContent = ts ? `${RA}import type { Request, Response } from 'express';
1727
- ${cpMeta}${mwUser2}
1728
- export const POST = async (req: Request, res: Response) => {
1729
- const { oldPassword, newPassword } = req.body;
1730
- if (!oldPassword || !newPassword) return res.status(400).json({ error: 'oldPassword and newPassword are required' });
1731
- // TODO: verify old password, hash and update new password, then sign out other devices:
1732
- // await revokeAllSessions(req.user.id) (import from 'express-file-cluster/auth')
1733
- res.json({ message: 'Password changed successfully' });
1734
- };
1735
- ` : `${RA}${cpMeta}${mwUser2}
1736
- export const POST = async (req, res) => {
1737
- const { oldPassword, newPassword } = req.body;
1738
- if (!oldPassword || !newPassword) return res.status(400).json({ error: 'oldPassword and newPassword are required' });
1739
- // TODO: verify old password, hash and update new password, then sign out other devices:
1740
- // await revokeAllSessions(req.user.id) (import from 'express-file-cluster/auth')
2087
+ const cpImports = `import { requireAuth, getCurrentUser, hashPassword, verifyPassword, revokeAllSessions } from 'express-file-cluster/auth';
2088
+ import { rateLimit } from 'express-file-cluster';
2089
+ import { User } from '../../model/User.js';
2090
+ ${hasAdmin ? "import { Admin } from '../../model/Admin.js';\n" : ""}`;
2091
+ const cpLookup = hasAdmin ? ` const isAdmin = principal.role === 'admin';
2092
+ const account = isAdmin ? await ${db.findById("Admin", "id")} : await ${db.findById("User", "id")};` : ` const account = await ${db.findById("User", "id")};`;
2093
+ const passwordData = `{ password: hashed, refreshToken: '', refreshTokenExpiry: new Date(0), resetToken: '', resetTokenExpiry: new Date(0) }`;
2094
+ const cpPersist = hasAdmin ? ` const updated = isAdmin ? await ${db.update("Admin", "id", passwordData)} : await ${db.update("User", "id", passwordData)};` : ` const updated = await ${db.update("User", "id", passwordData)};`;
2095
+ const cpContent = `${cpImports}${ts ? "import type { Request, Response } from 'express';\n" : ""}${cpMeta}
2096
+ export const middlewares = [
2097
+ ${opts.rbac ? "requireAuth('user', 'admin')" : "requireAuth"},
2098
+ rateLimit({ window: '15m', max: 10, name: 'change-password', key: () => String(getCurrentUser()?.id ?? '') }),
2099
+ ];
2100
+
2101
+ export const POST = async (req${ts ? ": Request" : ""}, res${ts ? ": Response" : ""}) => {
2102
+ const { oldPassword, newPassword } = req.body ?? {};
2103
+ if (typeof oldPassword !== 'string' || typeof newPassword !== 'string' || !oldPassword || !newPassword) {
2104
+ return res.status(400).json({ error: 'oldPassword and newPassword are required' });
2105
+ }
2106
+ const principal = getCurrentUser();
2107
+ if (!principal || typeof principal.id !== 'string' || !principal.id) return res.status(401).json({ error: 'Unauthorized' });
2108
+ const id = principal.id;
2109
+ ${cpLookup}
2110
+ if (!account || !(await verifyPassword(oldPassword, account.password))) {
2111
+ return res.status(401).json({ error: 'Invalid credentials' });
2112
+ }
2113
+ if (!account.isActive) return res.status(403).json({ error: 'Account suspended' });
2114
+ const hashed = await hashPassword(newPassword);
2115
+ ${cpPersist}
2116
+ if (!updated) return res.status(404).json({ error: 'Account not found' });
2117
+ await revokeAllSessions(id);
2118
+ res.clearCookie('efc_refresh_token');
1741
2119
  res.json({ message: 'Password changed successfully' });
1742
2120
  };
1743
2121
  `;
1744
2122
  await fs.outputFile(path.join(dest, "src", "api", "auth", `change-password.${ext}`), cpContent);
1745
- const tfaSetupMeta = mkMeta(opts, {
1746
- GET: {
1747
- description: "Generate a TOTP secret and QR code to set up 2FA.",
1748
- response: `{ qrCode: 'otpauth://totp/...', secret: 'BASE32SECRET' }`
1749
- },
1750
- POST: {
1751
- description: "Confirm a TOTP code to enable 2FA for the authenticated user.",
1752
- request: `{ code: '123456' }`,
1753
- response: `{ message: '2FA enabled' }`
1754
- }
1755
- });
1756
- const tfaSetupContent = ts ? `${RA}import type { Request, Response } from 'express';
1757
- ${tfaSetupMeta}${mwUser3}
1758
- export const GET = async (req: Request, res: Response) => {
1759
- // TODO: generate TOTP secret and return QR code URL
1760
- res.json({ qrCode: 'otpauth://totp/...', secret: 'BASE32SECRET' });
1761
- };
1762
-
1763
- export const POST = async (req: Request, res: Response) => {
1764
- const { code } = req.body;
1765
- if (!code) return res.status(400).json({ error: 'code is required' });
1766
- // TODO: verify TOTP code and enable 2FA
1767
- res.json({ message: '2FA enabled' });
1768
- };
1769
- ` : `${RA}${tfaSetupMeta}${mwUser3}
1770
- export const GET = async (req, res) => {
1771
- // TODO: generate TOTP secret and return QR code URL
1772
- res.json({ qrCode: 'otpauth://totp/...', secret: 'BASE32SECRET' });
1773
- };
1774
-
1775
- export const POST = async (req, res) => {
1776
- const { code } = req.body;
1777
- if (!code) return res.status(400).json({ error: 'code is required' });
1778
- // TODO: verify TOTP code and enable 2FA
1779
- res.json({ message: '2FA enabled' });
1780
- };
1781
- `;
1782
- await fs.outputFile(
1783
- path.join(dest, "src", "api", "auth", "2fa", `setup.${ext}`),
1784
- tfaSetupContent
1785
- );
1786
- const tfaVerifyMeta = mkMeta(opts, {
1787
- POST: {
1788
- description: "Verify a TOTP code during login.",
1789
- request: `{ code: '123456' }`,
1790
- response: `{ message: '2FA verified' }`
1791
- }
1792
- });
1793
- const tfaVerifyContent = ts ? `${RA}import type { Request, Response } from 'express';
1794
- ${tfaVerifyMeta}export const POST = async (req: Request, res: Response) => {
1795
- const { code } = req.body;
1796
- if (!code) return res.status(400).json({ error: 'code is required' });
1797
- // TODO: verify TOTP code
1798
- res.json({ message: '2FA verified' });
1799
- };
1800
- ` : `${RA}${tfaVerifyMeta}export const POST = async (req, res) => {
1801
- const { code } = req.body;
1802
- if (!code) return res.status(400).json({ error: 'code is required' });
1803
- // TODO: verify TOTP code
1804
- res.json({ message: '2FA verified' });
1805
- };
1806
- `;
1807
- await fs.outputFile(
1808
- path.join(dest, "src", "api", "auth", "2fa", `verify.${ext}`),
1809
- tfaVerifyContent
1810
- );
1811
- const tfaDisableMeta = mkMeta(opts, {
1812
- POST: {
1813
- description: "Disable 2FA for the authenticated user.",
1814
- request: `{ code: '123456' }`,
1815
- response: `{ message: '2FA disabled' }`
1816
- }
1817
- });
1818
- const tfaDisableContent = ts ? `${RA}import type { Request, Response } from 'express';
1819
- ${tfaDisableMeta}${mwUser3}
1820
- export const POST = async (req: Request, res: Response) => {
1821
- const { code } = req.body;
1822
- if (!code) return res.status(400).json({ error: 'code is required' });
1823
- // TODO: verify code and disable 2FA
1824
- res.json({ message: '2FA disabled' });
1825
- };
1826
- ` : `${RA}${tfaDisableMeta}${mwUser3}
1827
- export const POST = async (req, res) => {
1828
- const { code } = req.body;
1829
- if (!code) return res.status(400).json({ error: 'code is required' });
1830
- // TODO: verify code and disable 2FA
1831
- res.json({ message: '2FA disabled' });
1832
- };
1833
- `;
1834
- await fs.outputFile(
1835
- path.join(dest, "src", "api", "auth", "2fa", `disable.${ext}`),
1836
- tfaDisableContent
1837
- );
1838
- const sessListMeta = mkMeta(opts, {
1839
- GET: {
1840
- description: "List all active sessions for the authenticated user.",
1841
- response: `{ sessions: [] }`
1842
- }
1843
- });
1844
- const sessListContent = ts ? `${RA}import type { Request, Response } from 'express';
1845
- ${sessListMeta}${mwUser3}
1846
- export const GET = async (req: Request, res: Response) => {
1847
- // TODO: fetch sessions for req.user.id
1848
- res.json({ sessions: [] });
1849
- };
1850
- ` : `${RA}${sessListMeta}${mwUser3}
1851
- export const GET = async (req, res) => {
1852
- // TODO: fetch sessions for req.user.id
1853
- res.json({ sessions: [] });
1854
- };
1855
- `;
1856
- await fs.outputFile(
1857
- path.join(dest, "src", "api", "auth", "sessions", `index.${ext}`),
1858
- sessListContent
1859
- );
1860
- const sessRevokeMeta = mkMeta(opts, {
1861
- DELETE: {
1862
- description: "Revoke a single active session by ID.",
1863
- params: `{ id: 'sess_01HXZ' }`,
1864
- response: `{ message: 'Session sess_01HXZ revoked' }`
1865
- }
1866
- });
1867
- const sessRevokeContent = ts ? `${RA}import type { Request, Response } from 'express';
1868
- ${sessRevokeMeta}${mwUser3}
1869
- export const DELETE = async (req: Request, res: Response) => {
1870
- const { id } = req.params;
1871
- // TODO: revoke session by id
1872
- res.json({ message: \`Session \${id} revoked\` });
1873
- };
1874
- ` : `${RA}${sessRevokeMeta}${mwUser3}
1875
- export const DELETE = async (req, res) => {
1876
- const { id } = req.params;
1877
- // TODO: revoke session by id
1878
- res.json({ message: \`Session \${id} revoked\` });
1879
- };
2123
+ for (const [file, content] of Object.entries(twoFactorRoutes(ts, usesTables(opts), hasAdmin))) {
2124
+ await fs.outputFile(path.join(dest, "src", "api", "auth", `${file}.${ext}`), content);
2125
+ }
2126
+ for (const endpoint of [
2127
+ { file: "sessions/index", methods: ["GET"], feature: "Device session management" },
2128
+ { file: "sessions/[id]", methods: ["DELETE"], feature: "Device session management" }
2129
+ ]) {
2130
+ const message = `${endpoint.feature} is not implemented. Connect an implementation before enabling this feature.`;
2131
+ const metadata = mkMeta(
2132
+ opts,
2133
+ Object.fromEntries(
2134
+ endpoint.methods.map((method) => [
2135
+ method,
2136
+ {
2137
+ description: `${endpoint.feature} extension point (not implemented).`,
2138
+ status: 501,
2139
+ response: JSON.stringify({ error: message, statusCode: 501 })
2140
+ }
2141
+ ])
2142
+ )
2143
+ );
2144
+ const handlers = endpoint.methods.map(
2145
+ (method) => `export const ${method} = (_req${ts ? ": Request" : ""}, res${ts ? ": Response" : ""}) => {
2146
+ res.status(501).json({ error: ${JSON.stringify(message)}, statusCode: 501 });
2147
+ };`
2148
+ ).join("\n\n");
2149
+ const content = `${RA}${ts ? "import type { Request, Response } from 'express';\n" : ""}${metadata}${mwUser3}
2150
+ // Extension point: replace this handler only after wiring and testing the feature.
2151
+ ${handlers}
1880
2152
  `;
1881
- await fs.outputFile(
1882
- path.join(dest, "src", "api", "auth", "sessions", `[id].${ext}`),
1883
- sessRevokeContent
1884
- );
2153
+ await fs.outputFile(
2154
+ path.join(dest, "src", "api", "auth", `${endpoint.file}.${ext}`),
2155
+ content
2156
+ );
2157
+ }
1885
2158
  }
1886
2159
  }
1887
2160
  async function writeUserExtendedRoutes(dest, opts) {
@@ -3832,6 +4105,7 @@ export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuth
3832
4105
  }
3833
4106
  }
3834
4107
  if (!user.isActive) throw new HttpError(403, 'Account suspended');
4108
+ if (user.twoFactorEnabled) throw new HttpError(403, 'Use password login with an authenticator code for this account');
3835
4109
  // http-only: sets the cookie, returns undefined. bearer: returns the token.
3836
4110
  return issueToken(res, { id: user.id, role: user.role, email: user.email });
3837
4111
  }
@@ -4097,6 +4371,12 @@ var VARIANTS = ["typescript", "javascript"].flatMap((language) => {
4097
4371
  database: "mongodb",
4098
4372
  modelApi: "defineModel"
4099
4373
  },
4374
+ {
4375
+ key: `${lang}-mongo-collection`,
4376
+ language,
4377
+ database: "mongodb",
4378
+ modelApi: "defineCollection"
4379
+ },
4100
4380
  {
4101
4381
  key: `${lang}-pg-model`,
4102
4382
  language,
@@ -4171,9 +4451,31 @@ var ITEMS = [
4171
4451
  {
4172
4452
  name: "account-security",
4173
4453
  title: "Account security",
4174
- description: "change password, 2FA, sessions, token refresh",
4454
+ description: "password changes, TOTP or email OTP, token refresh; device-session extension points",
4175
4455
  group: "user",
4176
4456
  registryDependencies: ["auth"],
4457
+ prompts: [
4458
+ {
4459
+ name: "TWO_FACTOR_METHOD",
4460
+ type: "select",
4461
+ message: "Two-factor authentication methods:",
4462
+ default: "totp",
4463
+ options: [
4464
+ { value: "totp", label: "Authenticator app (TOTP)" },
4465
+ {
4466
+ value: "email",
4467
+ label: "Email OTP",
4468
+ hint: "requires the email-client SMTP integration"
4469
+ },
4470
+ {
4471
+ value: "both",
4472
+ label: "Both \u2014 users choose",
4473
+ hint: "requires the email-client SMTP integration"
4474
+ }
4475
+ ],
4476
+ note: "Email OTP requires efc add email-client and working SMTP settings. Each user enrolls one allowed method."
4477
+ }
4478
+ ],
4177
4479
  enable: user("accountSecurity")
4178
4480
  },
4179
4481
  {
@@ -4512,7 +4814,7 @@ function registryOptions(v, items = [], extra = {}) {
4512
4814
  projectName: "efc-app",
4513
4815
  language: v.language,
4514
4816
  database: v.database,
4515
- ...v.database === "postgresql" && { modelApi: v.modelApi },
4817
+ modelApi: v.modelApi,
4516
4818
  authStrategy: "http-only",
4517
4819
  cluster: true,
4518
4820
  tasks: true,
@@ -4555,7 +4857,7 @@ async function writeManifest(dest, opts, items) {
4555
4857
  registry: (pkg.dependencies["express-file-cluster"] ?? "").replace(/^[^\d]*/, ""),
4556
4858
  language: opts.language,
4557
4859
  database: opts.database,
4558
- modelApi: opts.database === "postgresql" ? opts.modelApi ?? "defineTable" : "defineModel",
4860
+ modelApi: opts.database === "postgresql" ? opts.modelApi ?? "defineTable" : opts.modelApi === "defineModel" ? "defineModel" : "defineCollection",
4559
4861
  tasks: opts.tasks ? opts.taskBackend ?? "bullmq" : null,
4560
4862
  items: [...withDependencies(items)].sort(),
4561
4863
  files: Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)))
@@ -4651,22 +4953,34 @@ async function main() {
4651
4953
  ]
4652
4954
  });
4653
4955
  if (p.isCancel(database)) cancel2();
4654
- let modelApi2;
4655
- if (database === "postgresql") {
4656
- const api = await p.select({
4657
- message: "Models:",
4658
- options: [
4659
- {
4660
- value: "defineTable",
4661
- label: "defineTable",
4662
- hint: "PostgreSQL-native \u2014 foreign keys, relations, include, Prisma-style queries"
4663
- },
4664
- { value: "defineModel", label: "defineModel", hint: "portable \u2014 the same API on MongoDB" }
4665
- ]
4666
- });
4667
- if (p.isCancel(api)) cancel2();
4668
- modelApi2 = api;
4669
- }
4956
+ const api = await p.select({
4957
+ message: "Models:",
4958
+ options: database === "postgresql" ? [
4959
+ {
4960
+ value: "defineTable",
4961
+ label: "defineTable",
4962
+ hint: "PostgreSQL-native \u2014 foreign keys, relations, include, Prisma-style queries"
4963
+ },
4964
+ {
4965
+ value: "defineModel",
4966
+ label: "defineModel",
4967
+ hint: "portable \u2014 the same API on MongoDB"
4968
+ }
4969
+ ] : [
4970
+ {
4971
+ value: "defineCollection",
4972
+ label: "defineCollection",
4973
+ hint: "MongoDB-native \u2014 mongoose-style queries, typed pipelines, BSON types"
4974
+ },
4975
+ {
4976
+ value: "defineModel",
4977
+ label: "defineModel",
4978
+ hint: "portable \u2014 the same API on PostgreSQL"
4979
+ }
4980
+ ]
4981
+ });
4982
+ if (p.isCancel(api)) cancel2();
4983
+ const modelApi2 = api;
4670
4984
  const taskBackend = await p.select({
4671
4985
  message: "Task queue backend:",
4672
4986
  options: database === "postgresql" ? [
@@ -4709,8 +5023,20 @@ async function main() {
4709
5023
  required: false
4710
5024
  });
4711
5025
  if (p.isCancel(picked)) cancel2();
4712
- const items = withDependencies(picked);
5026
+ let items = withDependencies(picked);
5027
+ const twoFactorMethod = items.includes("account-security") ? await p.select({
5028
+ message: "Two-factor authentication methods:",
5029
+ options: [
5030
+ { value: "totp", label: "Authenticator app (TOTP)" },
5031
+ { value: "email", label: "Email OTP" },
5032
+ { value: "both", label: "Both \u2014 users choose" }
5033
+ ]
5034
+ }) : void 0;
5035
+ if (p.isCancel(twoFactorMethod)) cancel2();
5036
+ if (twoFactorMethod && twoFactorMethod !== "totp")
5037
+ items = withDependencies([...items, "email-client"]);
4713
5038
  const answers = auth === "none" ? {} : { authStrategy: auth };
5039
+ if (twoFactorMethod) answers.TWO_FACTOR_METHOD = twoFactorMethod;
4714
5040
  const prompts = itemPrompts(items);
4715
5041
  for (const q of prompts) {
4716
5042
  if (q.name in answers || !promptApplies(q, answers)) continue;