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 +560 -234
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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 \`
|
|
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
|
-
-
|
|
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}${
|
|
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
|
|
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
|
-
|
|
596
|
-
const refreshToken = await issueRefreshToken(res, Admin, admin
|
|
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
|
-
|
|
606
|
-
const refreshToken = await issueRefreshToken(res, User, user
|
|
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
|
-
|
|
614
|
-
const refreshToken = await issueRefreshToken(res, Admin, admin
|
|
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
|
-
|
|
622
|
-
const refreshToken = await issueRefreshToken(res, User, user
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
973
|
+
async function issueRefreshToken(res, model, account) {
|
|
658
974
|
const refreshToken = crypto.randomBytes(40).toString('hex');
|
|
659
|
-
await
|
|
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
|
|
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
|
|
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 {
|
|
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 ? `
|
|
1453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1671
|
-
|
|
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
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
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
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
},
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
};
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
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
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
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
|
-
|
|
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;
|