create-efc-app 0.10.0 → 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,
@@ -157,7 +402,7 @@ async function scaffold(opts) {
157
402
  }
158
403
  async function writePackageJson(dest, opts) {
159
404
  const deps = {
160
- "express-file-cluster": "^0.10.0"
405
+ "express-file-cluster": "^0.10.2"
161
406
  };
162
407
  if (opts.database === "mongodb") deps["mongoose"] = "^9.7.2";
163
408
  if (opts.database === "postgresql") {
@@ -321,8 +566,8 @@ ${usesTables(opts) ? `- \`src/model/*.${ext}\` \u2014 \`defineTable\` models (Po
321
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:
322
567
  \`find(filter, { projection, sort })\`, \`findByIdAndUpdate\`, typed \`pipeline()\`, \`lookup()\`).` : `- \`src/model/*.${ext}\` \u2014 \`defineModel\` models.`}
323
568
  - \`src/index.${ext}\` is the entry point; it imports \`efc.config.${ext}\` and passes it to \`ignite()\`.
324
- - Only three import paths exist: \`express-file-cluster\`, \`express-file-cluster/auth\`,
325
- \`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).
326
571
  ${opts.registry ? `- Ready-made features (auth, user/admin portals, billing, mailer\u2026) come from the registry:
327
572
  \`npx efc list\`, then \`npx efc add <item> --yes\` \u2014 check it before hand-writing auth or CRUD
328
573
  (\`${D}/cli/add.md\`). \`efc.json\` tracks what was added; commit it.
@@ -399,12 +644,13 @@ ${k}=${ex}`).join("");
399
644
  const dotenv = `PORT=3000
400
645
  NODE_ENV=development
401
646
  DATABASE_URL=${dbUrl}
402
- 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}
403
649
  CORS_ORIGINS=http://localhost:3000${smtpVars}${oauthEnv}`;
404
650
  const example = `PORT=3000
405
651
  NODE_ENV=development
406
652
  DATABASE_URL=${dbExampleUrl}
407
- JWT_SECRET=<generate with: openssl rand -hex 64>${redis}
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}
408
654
  CORS_ORIGINS=http://localhost:3000,https://yourapp.com
409
655
  # Behind a proxy / load balancer: how many hops to trust, so rate limits see client IPs
410
656
  # TRUST_PROXY=1${smtpExample}${oauthExample}`;
@@ -533,6 +779,16 @@ export const User = ${modelApi(opts).name}('User', {
533
779
  resetTokenExpiry: { type: 'date' },
534
780
  refreshToken: { type: 'string' },
535
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: '' },
536
792
  }${relationsOption(opts, [
537
793
  { name: "sessions", kind: "hasMany", target: "Session", foreignKey: "user" },
538
794
  { name: "notifications", kind: "hasMany", target: "Notification", foreignKey: "user" },
@@ -560,6 +816,16 @@ export const Admin = ${modelApi(opts).name}('Admin', {
560
816
  resetTokenExpiry: { type: 'date' },
561
817
  refreshToken: { type: 'string' },
562
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: '' },
563
829
  }${relationsOption(opts, [
564
830
  { name: "assignedTickets", kind: "hasMany", target: "SupportTicket", foreignKey: "assignedTo" },
565
831
  { name: "auditLogs", kind: "hasMany", target: "AuditLog", foreignKey: "admin" },
@@ -593,7 +859,13 @@ export const meta: RouteMeta = {
593
859
  ` : "";
594
860
  const { user: hasUser, admin: hasAdmin } = principals(opts);
595
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
+ );
596
867
  const loginDbImports = hasModels ? `import crypto from 'node:crypto';
868
+ import { acceptTwoFactor, updateSecurity } from '../../lib/two-factor.js';
597
869
  ${hasUser ? `import { User } from '../../model/User.js';
598
870
  ` : ""}${hasAdmin ? `import { Admin } from '../../model/Admin.js';
599
871
  ` : ""}` : "";
@@ -602,8 +874,9 @@ ${hasUser ? `import { User } from '../../model/User.js';
602
874
  const match = await verifyPassword(password, admin.password);
603
875
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
604
876
  if (!admin.isActive) return res.status(403).json({ error: 'Account suspended' });
605
- const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
606
- 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 });
607
880
  return res.json({ message: 'Logged in as admin', ...(token ? { token, refreshToken } : {}) });
608
881
  }
609
882
 
@@ -612,29 +885,32 @@ ${hasUser ? `import { User } from '../../model/User.js';
612
885
  const match = await verifyPassword(password, user.password);
613
886
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
614
887
  if (!user.isActive) return res.status(403).json({ error: 'Account suspended' });
615
- const token = await issueToken(res, { id: user.id, role: user.role, email: user.email });
616
- 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 });
617
891
  // bearer: the client stores both tokens itself; http-only: they travel as cookies.
618
892
  res.json({ message: 'Logged in', ...(token ? { token, refreshToken } : {}) });` : hasAdmin ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
619
893
  if (!admin) return res.status(401).json({ error: 'Invalid credentials' });
620
894
  const match = await verifyPassword(password, admin.password);
621
895
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
622
896
  if (!admin.isActive) return res.status(403).json({ error: 'Account suspended' });
623
- const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
624
- 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 });
625
900
  // bearer: the client stores both tokens itself; http-only: they travel as cookies.
626
901
  res.json({ message: 'Logged in as admin', ...(token ? { token, refreshToken } : {}) });` : ` const user = await ${db.findOne("User", `{ email }`)};
627
902
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });
628
903
  const match = await verifyPassword(password, user.password);
629
904
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
630
905
  if (!user.isActive) return res.status(403).json({ error: 'Account suspended' });
631
- const token = await issueToken(res, { id: user.id, role: user.role, email: user.email });
632
- 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 });
633
909
  // bearer: the client stores both tokens itself; http-only: they travel as cookies.
634
910
  res.json({ message: 'Logged in', ...(token ? { token, refreshToken } : {}) });`;
635
911
  const loginContent = hasModels ? ts ? `import { issueToken, verifyPassword } from 'express-file-cluster/auth';
636
912
  import type { Request, Response } from 'express';
637
- import { rateLimit } from 'express-file-cluster';
913
+ import { HttpError, rateLimit } from 'express-file-cluster';
638
914
  ${loginDbImports}${loginMeta}// 10 attempts per 15 minutes per address and account. Keyed on the email too, so behind a
639
915
  // proxy without TRUST_PROXY (every client then has the proxy's IP) it still limits one
640
916
  // account at a time, never everyone.
@@ -642,7 +918,9 @@ export const middlewares = [
642
918
  rateLimit({
643
919
  window: '15m',
644
920
  max: 10,
645
- key: (req) => \`\${req.ip}:\${String(req.body?.email ?? '').toLowerCase()}\`,
921
+ name: 'password-login',
922
+ onCacheError: 'reject',
923
+ key: (req) => String(req.body?.email ?? '').toLowerCase(),
646
924
  }),
647
925
  ];
648
926
 
@@ -650,11 +928,12 @@ const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
650
928
 
651
929
  async function issueRefreshToken(
652
930
  res: Response,
653
- model: ${usesTables(opts) ? "{ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown> }" : usesCollections(opts) ? "{ findByIdAndUpdate: (id: string, data: Record<string, unknown>, options: { returnDocument: 'after' }) => Promise<unknown> }" : "{ update: (id: string, data: Record<string, unknown>) => Promise<unknown> }"},
654
- id: string,
931
+ model: Parameters<typeof updateSecurity>[0],
932
+ account: { id: string; password: string; twoFactorEnabled?: boolean; twoFactorSecret?: string },
655
933
  ): Promise<string> {
656
934
  const refreshToken = crypto.randomBytes(40).toString('hex');
657
- 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');
658
937
  res.cookie('efc_refresh_token', refreshToken, {
659
938
  httpOnly: true,
660
939
  secure: process.env.NODE_ENV === 'production',
@@ -665,7 +944,8 @@ async function issueRefreshToken(
665
944
  }
666
945
 
667
946
  export const POST = async (req: Request, res: Response) => {
668
- const { email, password } = req.body;
947
+ const authenticatedAt = Date.now();
948
+ const { email, password } = req.body ?? {};
669
949
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
670
950
  if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
671
951
  return res.status(400).json({ error: 'email and password are required' });
@@ -674,7 +954,7 @@ export const POST = async (req: Request, res: Response) => {
674
954
  ${loginBody}
675
955
  };
676
956
  ` : `import { issueToken, verifyPassword } from 'express-file-cluster/auth';
677
- import { rateLimit } from 'express-file-cluster';
957
+ import { HttpError, rateLimit } from 'express-file-cluster';
678
958
  ${loginDbImports}${loginMeta}// 10 attempts per 15 minutes per address and account. Keyed on the email too, so behind a
679
959
  // proxy without TRUST_PROXY (every client then has the proxy's IP) it still limits one
680
960
  // account at a time, never everyone.
@@ -682,15 +962,18 @@ export const middlewares = [
682
962
  rateLimit({
683
963
  window: '15m',
684
964
  max: 10,
685
- key: (req) => \`\${req.ip}:\${String(req.body?.email ?? '').toLowerCase()}\`,
965
+ name: 'password-login',
966
+ onCacheError: 'reject',
967
+ key: (req) => String(req.body?.email ?? '').toLowerCase(),
686
968
  }),
687
969
  ];
688
970
 
689
971
  const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
690
972
 
691
- async function issueRefreshToken(res, model, id) {
973
+ async function issueRefreshToken(res, model, account) {
692
974
  const refreshToken = crypto.randomBytes(40).toString('hex');
693
- 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');
694
977
  res.cookie('efc_refresh_token', refreshToken, {
695
978
  httpOnly: true,
696
979
  secure: process.env.NODE_ENV === 'production',
@@ -701,7 +984,8 @@ async function issueRefreshToken(res, model, id) {
701
984
  }
702
985
 
703
986
  export const POST = async (req, res) => {
704
- const { email, password } = req.body;
987
+ const authenticatedAt = Date.now();
988
+ const { email, password } = req.body ?? {};
705
989
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
706
990
  if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
707
991
  return res.status(400).json({ error: 'email and password are required' });
@@ -740,18 +1024,30 @@ export const meta: RouteMeta = {
740
1024
  };
741
1025
 
742
1026
  ` : "";
743
- 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';
744
1040
  import type { Request, Response } from 'express';
745
- ${logoutMeta}export const POST = async (req: Request, res: Response) => {
1041
+ ${logoutDbImports}${logoutMeta}${logoutMiddlewares}export const POST = async (req: Request, res: Response) => {
746
1042
  // Rejects this token from now on (not just the cookie) \u2014 see \`cache\` in efc.config.
747
- await revokeSession(req, res);
1043
+ ${logoutInvalidate} await revokeSession(req, res);
748
1044
  res.clearCookie('efc_refresh_token');
749
1045
  res.json({ message: 'Logged out successfully' });
750
1046
  };
751
- ` : `import { revokeSession } from 'express-file-cluster/auth';
752
- ${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) => {
753
1049
  // Rejects this token from now on (not just the cookie) \u2014 see \`cache\` in efc.config.
754
- await revokeSession(req, res);
1050
+ ${logoutInvalidate} await revokeSession(req, res);
755
1051
  res.clearCookie('efc_refresh_token');
756
1052
  res.json({ message: 'Logged out successfully' });
757
1053
  };
@@ -805,7 +1101,7 @@ ${registerDbImports}${registerMeta}export const POST = async (req: Request, res:
805
1101
  const verifyToken = crypto.randomBytes(32).toString('hex');
806
1102
  const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
807
1103
  ${sendVerifyEmail}
808
- 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;
809
1105
  res.status(201).json({ message: 'Account created successfully', user: safe });
810
1106
  };
811
1107
  ` : `${registerDbImports}${registerMeta}export const POST = async (req, res) => {
@@ -820,7 +1116,7 @@ ${sendVerifyEmail}
820
1116
  const verifyToken = crypto.randomBytes(32).toString('hex');
821
1117
  const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
822
1118
  ${sendVerifyEmail}
823
- 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;
824
1120
  res.status(201).json({ message: 'Account created successfully', user: safe });
825
1121
  };
826
1122
  `;
@@ -972,7 +1268,7 @@ export const GET = async (req: Request, res: Response) => {
972
1268
  ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
973
1269
  const limit = Math.min(100, Number(req.query.limit) || 20);
974
1270
  const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
975
- 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);
976
1272
  res.json({ users, total, page, limit });
977
1273
  };
978
1274
 
@@ -986,7 +1282,7 @@ ${roleGuard} const { name, email, password, role } = req.body;
986
1282
  if (existing) return res.status(409).json({ error: 'Email already in use' });
987
1283
  const hashed = await hashPassword(password);
988
1284
  const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
989
- 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;
990
1286
  res.status(201).json({ message: 'User created', user: safe });
991
1287
  };
992
1288
  ` : `import { hashPassword, requireAuth } from 'express-file-cluster/auth';
@@ -995,7 +1291,7 @@ export const GET = async (req, res) => {
995
1291
  ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
996
1292
  const limit = Math.min(100, Number(req.query.limit) || 20);
997
1293
  const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
998
- 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);
999
1295
  res.json({ users, total, page, limit });
1000
1296
  };
1001
1297
 
@@ -1009,7 +1305,7 @@ ${roleGuard} const { name, email, password, role } = req.body;
1009
1305
  if (existing) return res.status(409).json({ error: 'Email already in use' });
1010
1306
  const hashed = await hashPassword(password);
1011
1307
  const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
1012
- 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;
1013
1309
  res.status(201).json({ message: 'User created', user: safe });
1014
1310
  };
1015
1311
  `;
@@ -1065,7 +1361,7 @@ export const GET = async (req: Request, res: Response) => {
1065
1361
  ${roleGuard} const { id } = req.params;
1066
1362
  const user = await ${db.findById("User", `id`)};
1067
1363
  if (!user) return res.status(404).json({ error: 'User not found' });
1068
- 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;
1069
1365
  res.json({ user: safe });
1070
1366
  };
1071
1367
 
@@ -1077,7 +1373,7 @@ ${roleGuard} const { id } = req.params;
1077
1373
  // The role lives in the user's JWTs: end them, or a demoted/suspended user keeps access
1078
1374
  // until they expire. (/auth/refresh also refuses inactive accounts.)
1079
1375
  if (role !== undefined || isActive === false) await revokeAllSessions(id);
1080
- 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;
1081
1377
  res.json({ message: 'User updated', user: safe });
1082
1378
  };
1083
1379
 
@@ -1095,7 +1391,7 @@ export const GET = async (req, res) => {
1095
1391
  ${roleGuard} const { id } = req.params;
1096
1392
  const user = await ${db.findById("User", `id`)};
1097
1393
  if (!user) return res.status(404).json({ error: 'User not found' });
1098
- 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;
1099
1395
  res.json({ user: safe });
1100
1396
  };
1101
1397
 
@@ -1107,7 +1403,7 @@ ${roleGuard} const { id } = req.params;
1107
1403
  // The role lives in the user's JWTs: end them, or a demoted/suspended user keeps access
1108
1404
  // until they expire. (/auth/refresh also refuses inactive accounts.)
1109
1405
  if (role !== undefined || isActive === false) await revokeAllSessions(id);
1110
- 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;
1111
1407
  res.json({ message: 'User updated', user: safe });
1112
1408
  };
1113
1409
 
@@ -1168,16 +1464,20 @@ export const GET = async (req: Request, res: Response) => {
1168
1464
  const { id } = (req as any).user;
1169
1465
  const user = await ${db.findById("User", `id`)};
1170
1466
  if (!user) return res.status(404).json({ error: 'User not found' });
1171
- 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;
1172
1468
  res.json({ user: safe });
1173
1469
  };
1174
1470
 
1175
1471
  export const PUT = async (req: Request, res: Response) => {
1176
1472
  const { id } = (req as any).user;
1177
- 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' });
1178
1478
  const updated = await ${db.update("User", `id`, `{ name, email }`)};
1179
1479
  if (!updated) return res.status(404).json({ error: 'User not found' });
1180
- 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;
1181
1481
  res.json({ message: 'Profile updated', user: safe });
1182
1482
  };
1183
1483
  ` : `import { requireAuth } from 'express-file-cluster/auth';
@@ -1186,16 +1486,20 @@ export const GET = async (req, res) => {
1186
1486
  const { id } = req.user;
1187
1487
  const user = await ${db.findById("User", `id`)};
1188
1488
  if (!user) return res.status(404).json({ error: 'User not found' });
1189
- 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;
1190
1490
  res.json({ user: safe });
1191
1491
  };
1192
1492
 
1193
1493
  export const PUT = async (req, res) => {
1194
1494
  const { id } = req.user;
1195
- 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' });
1196
1500
  const updated = await ${db.update("User", `id`, `{ name, email }`)};
1197
1501
  if (!updated) return res.status(404).json({ error: 'User not found' });
1198
- 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;
1199
1503
  res.json({ message: 'Profile updated', user: safe });
1200
1504
  };
1201
1505
  `;
@@ -1483,14 +1787,18 @@ ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
1483
1787
  const admin = user ? null : await ${db.findOne("Admin", `{ refreshToken: token }`)};
1484
1788
  const account = user || admin;` : ` const user = await ${db.findOne("User", `{ refreshToken: token }`)};
1485
1789
  const account = user;`;
1486
- const refreshPersist = hasAdmin ? ` if (user) await ${db.update("User", `user.id`, `{ refreshToken: newRefreshToken, refreshTokenExpiry }`)};
1487
- 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' });`;
1488
1794
  const refreshContent = ts ? `import { issueToken } from 'express-file-cluster/auth';
1489
1795
  import type { Request, Response } from 'express';
1490
1796
  import crypto from 'node:crypto';
1797
+ import { updateSecurity } from '../../lib/two-factor.js';
1491
1798
  ${refreshModelImports}${refreshMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
1492
1799
 
1493
1800
  export const POST = async (req: Request, res: Response) => {
1801
+ const authenticatedAt = Date.now();
1494
1802
  const token = req.cookies?.['efc_refresh_token'] || req.body?.refreshToken;
1495
1803
  if (!token) return res.status(401).json({ error: 'Refresh token required' });
1496
1804
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
@@ -1514,7 +1822,7 @@ ${refreshPersist}
1514
1822
  maxAge: REFRESH_TOKEN_TTL_MS,
1515
1823
  });
1516
1824
 
1517
- 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 });
1518
1826
  // bearer: the client stores both tokens itself; http-only: they travel as cookies.
1519
1827
  res.json({
1520
1828
  message: 'Token refreshed',
@@ -1523,9 +1831,11 @@ ${refreshPersist}
1523
1831
  };
1524
1832
  ` : `import { issueToken } from 'express-file-cluster/auth';
1525
1833
  import crypto from 'node:crypto';
1834
+ import { updateSecurity } from '../../lib/two-factor.js';
1526
1835
  ${refreshModelImports}${refreshMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
1527
1836
 
1528
1837
  export const POST = async (req, res) => {
1838
+ const authenticatedAt = Date.now();
1529
1839
  const token = req.cookies?.efc_refresh_token || req.body?.refreshToken;
1530
1840
  if (!token) return res.status(401).json({ error: 'Refresh token required' });
1531
1841
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
@@ -1549,7 +1859,7 @@ ${refreshPersist}
1549
1859
  maxAge: REFRESH_TOKEN_TTL_MS,
1550
1860
  });
1551
1861
 
1552
- 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 });
1553
1863
  // bearer: the client stores both tokens itself; http-only: they travel as cookies.
1554
1864
  res.json({
1555
1865
  message: 'Token refreshed',
@@ -1713,12 +2023,14 @@ ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
1713
2023
  const admin = user ? null : await ${db.findOne("Admin", `{ resetToken: token }`)};
1714
2024
  const account = user || admin;` : ` const user = await ${db.findOne("User", `{ resetToken: token }`)};
1715
2025
  const account = user;`;
1716
- const rpPersist = hasAdmin ? ` if (user) await ${db.update("User", `user.id`, `{ password: hashed, resetToken: '' }`)};
1717
- 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});`;
1718
2029
  const rpContent = ts ? `import type { Request, Response } from 'express';
1719
2030
  import { hashPassword, revokeAllSessions } from 'express-file-cluster/auth';
2031
+ import { updateSecurity } from '../../lib/two-factor.js';
1720
2032
  ${rpModelImports}${rpMeta}export const POST = async (req: Request, res: Response) => {
1721
- const { token, password } = req.body;
2033
+ const { token, password } = req.body ?? {};
1722
2034
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1723
2035
  if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
1724
2036
  return res.status(400).json({ error: 'token and password are required' });
@@ -1732,14 +2044,16 @@ ${rpLookup}
1732
2044
 
1733
2045
  const hashed = await hashPassword(password);
1734
2046
  ${rpPersist}
2047
+ if (!consumed) return res.status(400).json({ error: 'Invalid, expired or already used reset token' });
1735
2048
  // Tokens issued with the old password stop working everywhere.
1736
2049
  await revokeAllSessions(account.id);
1737
2050
 
1738
2051
  res.json({ message: 'Password reset successfully' });
1739
2052
  };
1740
2053
  ` : `import { hashPassword, revokeAllSessions } from 'express-file-cluster/auth';
2054
+ import { updateSecurity } from '../../lib/two-factor.js';
1741
2055
  ${rpModelImports}${rpMeta}export const POST = async (req, res) => {
1742
- const { token, password } = req.body;
2056
+ const { token, password } = req.body ?? {};
1743
2057
  // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1744
2058
  if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
1745
2059
  return res.status(400).json({ error: 'token and password are required' });
@@ -1753,6 +2067,7 @@ ${rpLookup}
1753
2067
 
1754
2068
  const hashed = await hashPassword(password);
1755
2069
  ${rpPersist}
2070
+ if (!consumed) return res.status(400).json({ error: 'Invalid, expired or already used reset token' });
1756
2071
  // Tokens issued with the old password stop working everywhere.
1757
2072
  await revokeAllSessions(account.id);
1758
2073
 
@@ -1769,165 +2084,77 @@ ${rpPersist}
1769
2084
  response: `{ message: 'Password changed successfully' }`
1770
2085
  }
1771
2086
  });
1772
- const cpContent = ts ? `${RA}import type { Request, Response } from 'express';
1773
- ${cpMeta}${mwUser2}
1774
- export const POST = async (req: Request, res: Response) => {
1775
- const { oldPassword, newPassword } = req.body;
1776
- if (!oldPassword || !newPassword) return res.status(400).json({ error: 'oldPassword and newPassword are required' });
1777
- // TODO: verify old password, hash and update new password, then sign out other devices:
1778
- // await revokeAllSessions(req.user.id) (import from 'express-file-cluster/auth')
1779
- res.json({ message: 'Password changed successfully' });
1780
- };
1781
- ` : `${RA}${cpMeta}${mwUser2}
1782
- export const POST = async (req, res) => {
1783
- const { oldPassword, newPassword } = req.body;
1784
- if (!oldPassword || !newPassword) return res.status(400).json({ error: 'oldPassword and newPassword are required' });
1785
- // TODO: verify old password, hash and update new password, then sign out other devices:
1786
- // 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');
1787
2119
  res.json({ message: 'Password changed successfully' });
1788
2120
  };
1789
2121
  `;
1790
2122
  await fs.outputFile(path.join(dest, "src", "api", "auth", `change-password.${ext}`), cpContent);
1791
- const tfaSetupMeta = mkMeta(opts, {
1792
- GET: {
1793
- description: "Generate a TOTP secret and QR code to set up 2FA.",
1794
- response: `{ qrCode: 'otpauth://totp/...', secret: 'BASE32SECRET' }`
1795
- },
1796
- POST: {
1797
- description: "Confirm a TOTP code to enable 2FA for the authenticated user.",
1798
- request: `{ code: '123456' }`,
1799
- response: `{ message: '2FA enabled' }`
1800
- }
1801
- });
1802
- const tfaSetupContent = ts ? `${RA}import type { Request, Response } from 'express';
1803
- ${tfaSetupMeta}${mwUser3}
1804
- export const GET = async (req: Request, res: Response) => {
1805
- // TODO: generate TOTP secret and return QR code URL
1806
- res.json({ qrCode: 'otpauth://totp/...', secret: 'BASE32SECRET' });
1807
- };
1808
-
1809
- export const POST = async (req: Request, res: Response) => {
1810
- const { code } = req.body;
1811
- if (!code) return res.status(400).json({ error: 'code is required' });
1812
- // TODO: verify TOTP code and enable 2FA
1813
- res.json({ message: '2FA enabled' });
1814
- };
1815
- ` : `${RA}${tfaSetupMeta}${mwUser3}
1816
- export const GET = async (req, res) => {
1817
- // TODO: generate TOTP secret and return QR code URL
1818
- res.json({ qrCode: 'otpauth://totp/...', secret: 'BASE32SECRET' });
1819
- };
1820
-
1821
- export const POST = async (req, res) => {
1822
- const { code } = req.body;
1823
- if (!code) return res.status(400).json({ error: 'code is required' });
1824
- // TODO: verify TOTP code and enable 2FA
1825
- res.json({ message: '2FA enabled' });
1826
- };
1827
- `;
1828
- await fs.outputFile(
1829
- path.join(dest, "src", "api", "auth", "2fa", `setup.${ext}`),
1830
- tfaSetupContent
1831
- );
1832
- const tfaVerifyMeta = mkMeta(opts, {
1833
- POST: {
1834
- description: "Verify a TOTP code during login.",
1835
- request: `{ code: '123456' }`,
1836
- response: `{ message: '2FA verified' }`
1837
- }
1838
- });
1839
- const tfaVerifyContent = ts ? `${RA}import type { Request, Response } from 'express';
1840
- ${tfaVerifyMeta}export const POST = async (req: Request, res: Response) => {
1841
- const { code } = req.body;
1842
- if (!code) return res.status(400).json({ error: 'code is required' });
1843
- // TODO: verify TOTP code
1844
- res.json({ message: '2FA verified' });
1845
- };
1846
- ` : `${RA}${tfaVerifyMeta}export const POST = async (req, res) => {
1847
- const { code } = req.body;
1848
- if (!code) return res.status(400).json({ error: 'code is required' });
1849
- // TODO: verify TOTP code
1850
- res.json({ message: '2FA verified' });
1851
- };
1852
- `;
1853
- await fs.outputFile(
1854
- path.join(dest, "src", "api", "auth", "2fa", `verify.${ext}`),
1855
- tfaVerifyContent
1856
- );
1857
- const tfaDisableMeta = mkMeta(opts, {
1858
- POST: {
1859
- description: "Disable 2FA for the authenticated user.",
1860
- request: `{ code: '123456' }`,
1861
- response: `{ message: '2FA disabled' }`
1862
- }
1863
- });
1864
- const tfaDisableContent = ts ? `${RA}import type { Request, Response } from 'express';
1865
- ${tfaDisableMeta}${mwUser3}
1866
- export const POST = async (req: Request, res: Response) => {
1867
- const { code } = req.body;
1868
- if (!code) return res.status(400).json({ error: 'code is required' });
1869
- // TODO: verify code and disable 2FA
1870
- res.json({ message: '2FA disabled' });
1871
- };
1872
- ` : `${RA}${tfaDisableMeta}${mwUser3}
1873
- export const POST = async (req, res) => {
1874
- const { code } = req.body;
1875
- if (!code) return res.status(400).json({ error: 'code is required' });
1876
- // TODO: verify code and disable 2FA
1877
- res.json({ message: '2FA disabled' });
1878
- };
1879
- `;
1880
- await fs.outputFile(
1881
- path.join(dest, "src", "api", "auth", "2fa", `disable.${ext}`),
1882
- tfaDisableContent
1883
- );
1884
- const sessListMeta = mkMeta(opts, {
1885
- GET: {
1886
- description: "List all active sessions for the authenticated user.",
1887
- response: `{ sessions: [] }`
1888
- }
1889
- });
1890
- const sessListContent = ts ? `${RA}import type { Request, Response } from 'express';
1891
- ${sessListMeta}${mwUser3}
1892
- export const GET = async (req: Request, res: Response) => {
1893
- // TODO: fetch sessions for req.user.id
1894
- res.json({ sessions: [] });
1895
- };
1896
- ` : `${RA}${sessListMeta}${mwUser3}
1897
- export const GET = async (req, res) => {
1898
- // TODO: fetch sessions for req.user.id
1899
- res.json({ sessions: [] });
1900
- };
1901
- `;
1902
- await fs.outputFile(
1903
- path.join(dest, "src", "api", "auth", "sessions", `index.${ext}`),
1904
- sessListContent
1905
- );
1906
- const sessRevokeMeta = mkMeta(opts, {
1907
- DELETE: {
1908
- description: "Revoke a single active session by ID.",
1909
- params: `{ id: 'sess_01HXZ' }`,
1910
- response: `{ message: 'Session sess_01HXZ revoked' }`
1911
- }
1912
- });
1913
- const sessRevokeContent = ts ? `${RA}import type { Request, Response } from 'express';
1914
- ${sessRevokeMeta}${mwUser3}
1915
- export const DELETE = async (req: Request, res: Response) => {
1916
- const { id } = req.params;
1917
- // TODO: revoke session by id
1918
- res.json({ message: \`Session \${id} revoked\` });
1919
- };
1920
- ` : `${RA}${sessRevokeMeta}${mwUser3}
1921
- export const DELETE = async (req, res) => {
1922
- const { id } = req.params;
1923
- // TODO: revoke session by id
1924
- res.json({ message: \`Session \${id} revoked\` });
1925
- };
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}
1926
2152
  `;
1927
- await fs.outputFile(
1928
- path.join(dest, "src", "api", "auth", "sessions", `[id].${ext}`),
1929
- sessRevokeContent
1930
- );
2153
+ await fs.outputFile(
2154
+ path.join(dest, "src", "api", "auth", `${endpoint.file}.${ext}`),
2155
+ content
2156
+ );
2157
+ }
1931
2158
  }
1932
2159
  }
1933
2160
  async function writeUserExtendedRoutes(dest, opts) {
@@ -3878,6 +4105,7 @@ export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuth
3878
4105
  }
3879
4106
  }
3880
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');
3881
4109
  // http-only: sets the cookie, returns undefined. bearer: returns the token.
3882
4110
  return issueToken(res, { id: user.id, role: user.role, email: user.email });
3883
4111
  }
@@ -4223,9 +4451,31 @@ var ITEMS = [
4223
4451
  {
4224
4452
  name: "account-security",
4225
4453
  title: "Account security",
4226
- description: "change password, 2FA, sessions, token refresh",
4454
+ description: "password changes, TOTP or email OTP, token refresh; device-session extension points",
4227
4455
  group: "user",
4228
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
+ ],
4229
4479
  enable: user("accountSecurity")
4230
4480
  },
4231
4481
  {
@@ -4711,7 +4961,11 @@ async function main() {
4711
4961
  label: "defineTable",
4712
4962
  hint: "PostgreSQL-native \u2014 foreign keys, relations, include, Prisma-style queries"
4713
4963
  },
4714
- { value: "defineModel", label: "defineModel", hint: "portable \u2014 the same API on MongoDB" }
4964
+ {
4965
+ value: "defineModel",
4966
+ label: "defineModel",
4967
+ hint: "portable \u2014 the same API on MongoDB"
4968
+ }
4715
4969
  ] : [
4716
4970
  {
4717
4971
  value: "defineCollection",
@@ -4769,8 +5023,20 @@ async function main() {
4769
5023
  required: false
4770
5024
  });
4771
5025
  if (p.isCancel(picked)) cancel2();
4772
- 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"]);
4773
5038
  const answers = auth === "none" ? {} : { authStrategy: auth };
5039
+ if (twoFactorMethod) answers.TWO_FACTOR_METHOD = twoFactorMethod;
4774
5040
  const prompts = itemPrompts(items);
4775
5041
  for (const q of prompts) {
4776
5042
  if (q.name in answers || !promptApplies(q, answers)) continue;