@fonderie/auth 7.23.0 → 7.24.0
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/brain/outcomes.md +16 -0
- package/brain/signatures.md +4 -6
- package/dist/index.cjs +516 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -4
- package/dist/index.d.ts +21 -4
- package/dist/index.js +510 -55
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +13 -2
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js +13 -2
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/022_account_deletion_requests.sql +17 -0
- package/dist/{session-DZ7QOw1J.d.cts → session-D-KcJlRQ.d.cts} +22 -4
- package/dist/{session-DZ7QOw1J.d.ts → session-D-KcJlRQ.d.ts} +22 -4
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -172,13 +172,16 @@ var schemas_exports = {};
|
|
|
172
172
|
__export(schemas_exports, {
|
|
173
173
|
appleNativeSchema: () => appleNativeSchema,
|
|
174
174
|
changePasswordSchema: () => changePasswordSchema,
|
|
175
|
+
confirmDeletionSchema: () => confirmDeletionSchema,
|
|
175
176
|
forgotPasswordSchema: () => forgotPasswordSchema,
|
|
176
177
|
googleNativeSchema: () => googleNativeSchema,
|
|
177
178
|
loginSchema: () => loginSchema,
|
|
178
179
|
mfaTokenSchema: () => mfaTokenSchema,
|
|
179
180
|
refreshSchema: () => refreshSchema,
|
|
180
181
|
registerSchema: () => registerSchema,
|
|
182
|
+
requestDeletionSchema: () => requestDeletionSchema,
|
|
181
183
|
resetPasswordSchema: () => resetPasswordSchema,
|
|
184
|
+
restoreAccountSchema: () => restoreAccountSchema,
|
|
182
185
|
updateEmailSchema: () => updateEmailSchema,
|
|
183
186
|
updatePhoneSchema: () => updatePhoneSchema,
|
|
184
187
|
updatePreferencesSchema: () => updatePreferencesSchema,
|
|
@@ -258,6 +261,10 @@ var changePasswordSchema = z.object({
|
|
|
258
261
|
newPassword: password
|
|
259
262
|
});
|
|
260
263
|
var mfaTokenSchema = z.object({ token: z.string().trim().min(6).max(64) });
|
|
264
|
+
var secondFactor = z.string().trim().min(6).max(16).optional();
|
|
265
|
+
var requestDeletionSchema = z.object({ channel: z.enum(["email", "sms"]) });
|
|
266
|
+
var confirmDeletionSchema = z.object({ code: sixDigitPin, mfaCode: secondFactor });
|
|
267
|
+
var restoreAccountSchema = z.object({ restoreToken: z.string().min(20).max(4096), mfaCode: secondFactor });
|
|
261
268
|
|
|
262
269
|
// src/services/cookies.ts
|
|
263
270
|
function secureAttr(config) {
|
|
@@ -323,13 +330,23 @@ var MESSAGE_KEYS = {
|
|
|
323
330
|
// arrives already verified by the provider and has nothing to confirm.
|
|
324
331
|
oauthRegistration: "oauth-registration",
|
|
325
332
|
oauthLinked: "oauth-linked",
|
|
326
|
-
oauthUnlinked: "oauth-unlinked"
|
|
333
|
+
oauthUnlinked: "oauth-unlinked",
|
|
334
|
+
// Account deletion (docs/ACCOUNT-DELETION-DESIGN.md). Each goes to the ONE
|
|
335
|
+
// verified channel the person chose — route these types to both 'email'
|
|
336
|
+
// and 'sms' in courier: auth fills in only the chosen address, and the
|
|
337
|
+
// other channel skips. SMS sends each template's text.
|
|
338
|
+
accountDeletionCode: "account-deletion-code",
|
|
339
|
+
accountDeletionScheduled: "account-deletion-scheduled",
|
|
340
|
+
accountRestored: "account-restored"
|
|
327
341
|
};
|
|
328
342
|
var EVENT_KEYS = {
|
|
329
343
|
userRegistered: "fonderie.user.registered",
|
|
330
344
|
userDeleted: "fonderie.user.deleted",
|
|
331
345
|
// Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.
|
|
332
346
|
userPurged: "fonderie.user.purged",
|
|
347
|
+
// An archived account was kept: { userId }. Undo what deletion paused
|
|
348
|
+
// (billing resumes a subscription set to end at the period's end).
|
|
349
|
+
userRestored: "fonderie.user.restored",
|
|
333
350
|
emailVerified: "fonderie.user.email_verified",
|
|
334
351
|
passwordChanged: "fonderie.user.password_changed",
|
|
335
352
|
// Sessions the server revoked: { userId, sids (null = all), reason }. Reaches
|
|
@@ -771,6 +788,14 @@ var UserModel = class {
|
|
|
771
788
|
);
|
|
772
789
|
return row ?? null;
|
|
773
790
|
}
|
|
791
|
+
/** An archived account by id (restore). */
|
|
792
|
+
async findArchivedById(id) {
|
|
793
|
+
const [row] = await this.store.query(
|
|
794
|
+
`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NOT NULL`,
|
|
795
|
+
[id]
|
|
796
|
+
);
|
|
797
|
+
return row ?? null;
|
|
798
|
+
}
|
|
774
799
|
/** As findArchivedByEmail, for a phone number. */
|
|
775
800
|
async findArchivedByPhone(phone2) {
|
|
776
801
|
const [row] = await this.store.query(
|
|
@@ -1322,10 +1347,17 @@ var BackupCodeModel = class {
|
|
|
1322
1347
|
);
|
|
1323
1348
|
return rows.map((r) => ({ id: r.id, codeHash: r.code_hash }));
|
|
1324
1349
|
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Spend a backup code — once. Conditional on still being unused, so two
|
|
1352
|
+
* requests racing with the same code cannot both sign in: the second finds
|
|
1353
|
+
* it spent (false).
|
|
1354
|
+
*/
|
|
1325
1355
|
async consume(id) {
|
|
1326
|
-
await this.store.query(
|
|
1327
|
-
id
|
|
1328
|
-
|
|
1356
|
+
const rows = await this.store.query(
|
|
1357
|
+
`UPDATE fonderie_mfa_backup_codes SET used_at = now() WHERE id = $1 AND used_at IS NULL RETURNING id`,
|
|
1358
|
+
[id]
|
|
1359
|
+
);
|
|
1360
|
+
return rows.length > 0;
|
|
1329
1361
|
}
|
|
1330
1362
|
async deleteByUser(userId) {
|
|
1331
1363
|
await this.store.query(`DELETE FROM fonderie_mfa_backup_codes WHERE user_id = $1`, [userId]);
|
|
@@ -1465,6 +1497,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
1465
1497
|
},
|
|
1466
1498
|
// ── 2. Verify (setup confirm · TOTP login · backup code login) ──
|
|
1467
1499
|
verify: async (ctx) => {
|
|
1500
|
+
if (ctx.user.deletedAt) return setApiResponse2(HTTP2.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
|
|
1468
1501
|
const body = ctx.meta["body"];
|
|
1469
1502
|
const token = body?.["token"];
|
|
1470
1503
|
const isLoginCompletion = ctx.user.mfaPending === true;
|
|
@@ -1519,7 +1552,10 @@ function mfaController(store, config, issuer, bus) {
|
|
|
1519
1552
|
if (isLoginCompletion) await failedMfa();
|
|
1520
1553
|
return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
|
|
1521
1554
|
}
|
|
1522
|
-
await backupCodes.consume(matched.id)
|
|
1555
|
+
if (!await backupCodes.consume(matched.id)) {
|
|
1556
|
+
if (isLoginCompletion) await failedMfa();
|
|
1557
|
+
return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
|
|
1558
|
+
}
|
|
1523
1559
|
} else {
|
|
1524
1560
|
if (!ctx.user.mfaPending) {
|
|
1525
1561
|
return setApiResponse2(
|
|
@@ -1646,16 +1682,39 @@ init_password();
|
|
|
1646
1682
|
|
|
1647
1683
|
// src/services/pending-deletion.ts
|
|
1648
1684
|
import { setApiResponse as setApiResponse3, HTTP as HTTP3 } from "@fonderie/core";
|
|
1685
|
+
|
|
1686
|
+
// src/services/restore-token.ts
|
|
1687
|
+
import jwt2 from "jsonwebtoken";
|
|
1688
|
+
var RESTORE_TTL = "10m";
|
|
1689
|
+
function issueRestoreToken(userId, deletedAt, loginMethod, config) {
|
|
1690
|
+
return jwt2.sign(
|
|
1691
|
+
{ sub: userId, type: "restore", loginMethod, archivedAt: deletedAt.toISOString() },
|
|
1692
|
+
config.jwtSecret,
|
|
1693
|
+
{ expiresIn: RESTORE_TTL, keyid: keyIdOf(config.jwtSecret) }
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1696
|
+
function verifyRestoreToken(token, config) {
|
|
1697
|
+
const payload = verifyToken(token, config);
|
|
1698
|
+
if (!payload || payload.type !== "restore" || typeof payload.sub !== "string" || typeof payload.archivedAt !== "string") return null;
|
|
1699
|
+
return payload;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// src/services/pending-deletion.ts
|
|
1649
1703
|
function deletionDate(deletedAt, config) {
|
|
1650
1704
|
return new Date(deletedAt.getTime() + deletionGracePeriodDays(config) * 24 * 60 * 60 * 1e3);
|
|
1651
1705
|
}
|
|
1652
|
-
function pendingDeletionResponse(
|
|
1653
|
-
const deleteOn = deletionDate(deletedAt, config);
|
|
1706
|
+
function pendingDeletionResponse(user, loginMethod, config) {
|
|
1707
|
+
const deleteOn = deletionDate(user.deletedAt, config);
|
|
1654
1708
|
return setApiResponse3(
|
|
1655
1709
|
HTTP3.FORBIDDEN,
|
|
1656
1710
|
"ACCOUNT_PENDING_DELETION",
|
|
1657
1711
|
`This account is scheduled for deletion on ${deleteOn.toISOString().slice(0, 10)}.`,
|
|
1658
|
-
{
|
|
1712
|
+
{
|
|
1713
|
+
requestedAt: user.deletedAt.toISOString(),
|
|
1714
|
+
deleteOn: deleteOn.toISOString(),
|
|
1715
|
+
restoreToken: issueRestoreToken(user.id, user.deletedAt, loginMethod, config),
|
|
1716
|
+
mfaRequired: user.mfaEnabled === true
|
|
1717
|
+
}
|
|
1659
1718
|
);
|
|
1660
1719
|
}
|
|
1661
1720
|
function archivedAddressResponse() {
|
|
@@ -1666,6 +1725,27 @@ function archivedAddressResponse() {
|
|
|
1666
1725
|
);
|
|
1667
1726
|
}
|
|
1668
1727
|
|
|
1728
|
+
// src/services/second-factor.ts
|
|
1729
|
+
init_password();
|
|
1730
|
+
async function verifySecondFactor(store, config, userId, code) {
|
|
1731
|
+
if (typeof code !== "string") return false;
|
|
1732
|
+
const value = code.trim();
|
|
1733
|
+
if (/^\d{6}$/.test(value)) {
|
|
1734
|
+
const stored = await new UserModel(store).getMfaSecret(userId);
|
|
1735
|
+
if (!stored) return false;
|
|
1736
|
+
return verifyTotpToken(value, makeMfaCipher(config.mfaSecretKey).decrypt(stored));
|
|
1737
|
+
}
|
|
1738
|
+
if (/^[A-Z0-9]{8}$/i.test(value)) {
|
|
1739
|
+
const backupCodes = new BackupCodeModel(store);
|
|
1740
|
+
for (const row of await backupCodes.findUnused(userId)) {
|
|
1741
|
+
if (await verifyPassword(value.toUpperCase(), row.codeHash)) {
|
|
1742
|
+
return backupCodes.consume(row.id);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return false;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1669
1749
|
// src/models/password-reset.model.ts
|
|
1670
1750
|
import { createHash as createHash3 } from "crypto";
|
|
1671
1751
|
var hashSecret = (value) => createHash3("sha256").update(value).digest("hex");
|
|
@@ -2006,7 +2086,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2006
2086
|
failureReason: valid2 ? "pending_deletion" : "bad_password",
|
|
2007
2087
|
...meta
|
|
2008
2088
|
});
|
|
2009
|
-
if (valid2) return pendingDeletionResponse(archived.deletedAt, config);
|
|
2089
|
+
if (valid2) return pendingDeletionResponse({ ...archived, deletedAt: archived.deletedAt }, "email", config);
|
|
2010
2090
|
return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
2011
2091
|
}
|
|
2012
2092
|
}
|
|
@@ -2084,7 +2164,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2084
2164
|
}
|
|
2085
2165
|
const phone2 = body?.["phone"];
|
|
2086
2166
|
if (isValidPhone(phone2)) {
|
|
2087
|
-
const user = await users.findByPhone(normalizePhone(phone2));
|
|
2167
|
+
const user = await users.findByPhone(normalizePhone(phone2)) ?? await users.findArchivedByPhone(normalizePhone(phone2));
|
|
2088
2168
|
if (!user) {
|
|
2089
2169
|
return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
2090
2170
|
}
|
|
@@ -2120,6 +2200,65 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2120
2200
|
"Provide email + password or a valid phone number"
|
|
2121
2201
|
);
|
|
2122
2202
|
},
|
|
2203
|
+
/**
|
|
2204
|
+
* "Keep my account": the restoreToken from a sign-in to an archived account
|
|
2205
|
+
* (proof already given: password, Google / Apple, phone code), plus a
|
|
2206
|
+
* second factor when two-factor is on. Un-archives in one statement —
|
|
2207
|
+
* only the SAME archive the proof was for — then signs the person in.
|
|
2208
|
+
*/
|
|
2209
|
+
restoreAccount: async (ctx) => {
|
|
2210
|
+
const body = ctx.meta["body"];
|
|
2211
|
+
const proof = verifyRestoreToken(String(body?.["restoreToken"] ?? ""), config);
|
|
2212
|
+
const invalid = () => setApiResponse4(HTTP4.UNAUTHORIZED, "RESTORE_TOKEN_INVALID", "Sign in again to keep your account.");
|
|
2213
|
+
if (!proof) return invalid();
|
|
2214
|
+
const archived = await users.findArchivedById(proof.sub);
|
|
2215
|
+
if (!archived?.deletedAt || archived.deletedAt.toISOString() !== proof.archivedAt) return invalid();
|
|
2216
|
+
if (archived.suspended) {
|
|
2217
|
+
return setApiResponse4(HTTP4.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
|
|
2218
|
+
}
|
|
2219
|
+
if (archived.mfaEnabled && !await verifySecondFactor(store, config, archived.id, body?.["mfaCode"])) {
|
|
2220
|
+
return setApiResponse4(HTTP4.UNAUTHORIZED, "MFA_REQUIRED", "Enter a code from your authenticator app (or a backup code) to keep your account.");
|
|
2221
|
+
}
|
|
2222
|
+
const [restored] = await store.query(
|
|
2223
|
+
`UPDATE fonderie_users
|
|
2224
|
+
SET deleted_at = NULL, deletion_channel = NULL, deletion_reminded_at = NULL, updated_at = now()
|
|
2225
|
+
-- The SAME archive the proof was for. Postgres keeps microseconds,
|
|
2226
|
+
-- the proof (a JS date) milliseconds: compare at that precision.
|
|
2227
|
+
WHERE id = $1 AND date_trunc('milliseconds', deleted_at) = date_trunc('milliseconds', $2::timestamptz)
|
|
2228
|
+
RETURNING (SELECT deletion_channel FROM fonderie_users WHERE id = $1) AS channel`,
|
|
2229
|
+
[archived.id, proof.archivedAt]
|
|
2230
|
+
);
|
|
2231
|
+
if (!restored) return invalid();
|
|
2232
|
+
const meta = requestMeta(ctx);
|
|
2233
|
+
const method = proof.loginMethod === "google" ? "oauth-google" : proof.loginMethod === "apple" ? "oauth-apple" : proof.loginMethod === "phone" ? "phone" : "password";
|
|
2234
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(archived.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
2235
|
+
loginMethod: proof.loginMethod,
|
|
2236
|
+
phoneVerified: proof.loginMethod === "phone"
|
|
2237
|
+
});
|
|
2238
|
+
await sessions.create(archived.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
2239
|
+
await loginEvents.recordSafe({ userId: archived.id, emailAttempted: archived.email, method, outcome: "success", ...meta });
|
|
2240
|
+
const reqId = ctx.meta["requestId"];
|
|
2241
|
+
await background3(bus?.emit(EVENT_KEYS.userRestored, { userId: archived.id }, reqId !== void 0 ? { requestId: reqId } : void 0));
|
|
2242
|
+
const viaSms = restored.channel === "sms" || !archived.email && !!archived.phone;
|
|
2243
|
+
const address = viaSms ? archived.phone : archived.email;
|
|
2244
|
+
if (address) {
|
|
2245
|
+
await background3(bus?.emit(NOTIFICATION_EVENT2, {
|
|
2246
|
+
type: MESSAGE_KEYS.accountRestored,
|
|
2247
|
+
locale: archived.locale,
|
|
2248
|
+
data: {},
|
|
2249
|
+
recipient: viaSms ? { email: null, phone: address, deviceToken: null } : { email: address, phone: null, deviceToken: null }
|
|
2250
|
+
}));
|
|
2251
|
+
}
|
|
2252
|
+
const user = await users.findById(archived.id);
|
|
2253
|
+
return Response.json(
|
|
2254
|
+
{
|
|
2255
|
+
reason: "ACCOUNT_RESTORED",
|
|
2256
|
+
explanation: "Your account is active again.",
|
|
2257
|
+
result: { tokens: { access: accessToken, refresh: refreshToken }, user: user ? toUserDTO(user) : null }
|
|
2258
|
+
},
|
|
2259
|
+
{ status: 200, headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config)) }
|
|
2260
|
+
);
|
|
2261
|
+
},
|
|
2123
2262
|
logout: async (ctx) => {
|
|
2124
2263
|
const sid = ctx.user?.sid;
|
|
2125
2264
|
if (sid) {
|
|
@@ -2258,8 +2397,10 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2258
2397
|
);
|
|
2259
2398
|
}
|
|
2260
2399
|
let row;
|
|
2400
|
+
let presented;
|
|
2261
2401
|
if (typeof rawToken === "string" && rawToken.trim().length >= 32) {
|
|
2262
|
-
|
|
2402
|
+
presented = rawToken.trim();
|
|
2403
|
+
row = await passwordReset.findByToken(presented);
|
|
2263
2404
|
} else {
|
|
2264
2405
|
if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
|
|
2265
2406
|
return setApiResponse4(
|
|
@@ -2268,7 +2409,8 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2268
2409
|
"a 6-digit pin or a reset token is required"
|
|
2269
2410
|
);
|
|
2270
2411
|
}
|
|
2271
|
-
|
|
2412
|
+
presented = raw.trim();
|
|
2413
|
+
row = await passwordReset.findByPin(presented);
|
|
2272
2414
|
}
|
|
2273
2415
|
if (!row || /* @__PURE__ */ new Date() > row.expiresAt) {
|
|
2274
2416
|
return setApiResponse4(
|
|
@@ -2279,17 +2421,22 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2279
2421
|
}
|
|
2280
2422
|
const passwordHash = await hashPassword(password2);
|
|
2281
2423
|
const [changed] = await store.query(
|
|
2282
|
-
`WITH
|
|
2424
|
+
`WITH spent AS (
|
|
2425
|
+
-- The code is spent HERE, and only what this statement spent
|
|
2426
|
+
-- changes a password: two resets racing with one code \u2014 the
|
|
2427
|
+
-- second finds it gone and changes nothing.
|
|
2428
|
+
DELETE FROM fonderie_password_resets
|
|
2429
|
+
WHERE user_id = $2 AND (pin = $3 OR token = $3) AND expires_at > now()
|
|
2430
|
+
RETURNING user_id
|
|
2431
|
+
), changed AS (
|
|
2283
2432
|
UPDATE fonderie_users SET password_hash = $1, updated_at = now()
|
|
2284
|
-
WHERE id
|
|
2433
|
+
WHERE id IN (SELECT user_id FROM spent) AND deleted_at IS NULL
|
|
2285
2434
|
RETURNING id
|
|
2286
|
-
), spent AS (
|
|
2287
|
-
DELETE FROM fonderie_password_resets WHERE user_id = $2
|
|
2288
2435
|
), revoked AS (
|
|
2289
2436
|
DELETE FROM fonderie_sessions WHERE user_id IN (SELECT id FROM changed)
|
|
2290
2437
|
)
|
|
2291
2438
|
SELECT id FROM changed`,
|
|
2292
|
-
[passwordHash, row.userId]
|
|
2439
|
+
[passwordHash, row.userId, hashSecret(presented)]
|
|
2293
2440
|
);
|
|
2294
2441
|
if (!changed) {
|
|
2295
2442
|
return setApiResponse4(
|
|
@@ -2351,6 +2498,17 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2351
2498
|
);
|
|
2352
2499
|
}
|
|
2353
2500
|
await phoneVerif.deleteByUser(ctx.user.id);
|
|
2501
|
+
if (ctx.user.deletedAt) {
|
|
2502
|
+
await loginEvents.recordSafe({
|
|
2503
|
+
userId: ctx.user.id,
|
|
2504
|
+
emailAttempted: null,
|
|
2505
|
+
method: "phone",
|
|
2506
|
+
outcome: "failed",
|
|
2507
|
+
failureReason: "pending_deletion",
|
|
2508
|
+
...phoneMeta
|
|
2509
|
+
});
|
|
2510
|
+
return pendingDeletionResponse({ ...ctx.user, deletedAt: ctx.user.deletedAt }, "phone", config);
|
|
2511
|
+
}
|
|
2354
2512
|
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
|
|
2355
2513
|
loginMethod: "phone",
|
|
2356
2514
|
phoneVerified: true
|
|
@@ -2496,7 +2654,7 @@ function authController(store, config, bus, locales = defineLocales()) {
|
|
|
2496
2654
|
|
|
2497
2655
|
// src/controllers/user.controller.ts
|
|
2498
2656
|
import { randomInt as randomInt2 } from "crypto";
|
|
2499
|
-
import { setApiResponse as setApiResponse5, HTTP as HTTP5, dateOrEmpty as dateOrEmpty2, background as background4 } from "@fonderie/core";
|
|
2657
|
+
import { setApiResponse as setApiResponse5, HTTP as HTTP5, dateOrEmpty as dateOrEmpty2, background as background4, COURIER_FORMAT_KEY } from "@fonderie/core";
|
|
2500
2658
|
import { NOTIFICATION_EVENT as NOTIFICATION_EVENT3 } from "@fonderie/events";
|
|
2501
2659
|
|
|
2502
2660
|
// src/dtos/login-activity.ts
|
|
@@ -2534,7 +2692,64 @@ function toSessionDTO(row, currentSid) {
|
|
|
2534
2692
|
};
|
|
2535
2693
|
}
|
|
2536
2694
|
|
|
2695
|
+
// src/models/account-deletion.model.ts
|
|
2696
|
+
import { createHash as createHash4 } from "crypto";
|
|
2697
|
+
var hashCode = (code) => createHash4("sha256").update(code).digest("hex");
|
|
2698
|
+
var DELETION_CODE_MAX_ATTEMPTS = 5;
|
|
2699
|
+
var AccountDeletionModel = class {
|
|
2700
|
+
constructor(store) {
|
|
2701
|
+
this.store = store;
|
|
2702
|
+
}
|
|
2703
|
+
store;
|
|
2704
|
+
/** Replace any pending code (a new request supersedes the old one). */
|
|
2705
|
+
async saveCode(userId, code, channel, expiresAt) {
|
|
2706
|
+
await this.store.query(
|
|
2707
|
+
`INSERT INTO fonderie_account_deletion_codes (user_id, code_hash, channel, attempts, expires_at, created_at)
|
|
2708
|
+
VALUES ($1, $2, $3, 0, $4, now())
|
|
2709
|
+
ON CONFLICT (user_id) DO UPDATE
|
|
2710
|
+
SET code_hash = $2, channel = $3, attempts = 0, expires_at = $4, created_at = now()`,
|
|
2711
|
+
[userId, hashCode(code), channel, expiresAt]
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
async lastSentAt(userId) {
|
|
2715
|
+
const [row] = await this.store.query(
|
|
2716
|
+
`SELECT created_at AS "createdAt" FROM fonderie_account_deletion_codes WHERE user_id = $1`,
|
|
2717
|
+
[userId]
|
|
2718
|
+
);
|
|
2719
|
+
return row ? new Date(row.createdAt) : null;
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* Check a code in ONE statement: a wrong code spends an attempt, a right
|
|
2723
|
+
* one is consumed (deleted) — so a code is good once, and a guesser gets
|
|
2724
|
+
* five tries in total however many requests they race.
|
|
2725
|
+
*/
|
|
2726
|
+
async checkCode(userId, code) {
|
|
2727
|
+
const [row] = await this.store.query(
|
|
2728
|
+
`WITH current AS (
|
|
2729
|
+
SELECT user_id, code_hash = $2 AS matched, expires_at <= now() AS expired, attempts, channel
|
|
2730
|
+
FROM fonderie_account_deletion_codes WHERE user_id = $1
|
|
2731
|
+
FOR UPDATE
|
|
2732
|
+
), spent AS (
|
|
2733
|
+
DELETE FROM fonderie_account_deletion_codes d USING current c
|
|
2734
|
+
WHERE d.user_id = c.user_id AND c.matched AND NOT c.expired AND c.attempts < $3
|
|
2735
|
+
), missed AS (
|
|
2736
|
+
UPDATE fonderie_account_deletion_codes d SET attempts = d.attempts + 1
|
|
2737
|
+
FROM current c
|
|
2738
|
+
WHERE d.user_id = c.user_id AND NOT (c.matched AND NOT c.expired AND c.attempts < $3)
|
|
2739
|
+
)
|
|
2740
|
+
SELECT matched, expired, attempts, channel FROM current`,
|
|
2741
|
+
[userId, hashCode(code), DELETION_CODE_MAX_ATTEMPTS]
|
|
2742
|
+
);
|
|
2743
|
+
if (!row) return { result: "none" };
|
|
2744
|
+
if (row.attempts >= DELETION_CODE_MAX_ATTEMPTS) return { result: "exhausted" };
|
|
2745
|
+
if (row.expired) return { result: "expired" };
|
|
2746
|
+
return { result: row.matched ? "ok" : "invalid", channel: row.channel };
|
|
2747
|
+
}
|
|
2748
|
+
};
|
|
2749
|
+
|
|
2537
2750
|
// src/controllers/user.controller.ts
|
|
2751
|
+
var DELETION_CODE_TTL_MS = 15 * 60 * 1e3;
|
|
2752
|
+
var DELETION_CODE_COOLDOWN_MS = 60 * 1e3;
|
|
2538
2753
|
function normalizePhone2(phone2) {
|
|
2539
2754
|
return phone2.trim().replace(/[\s()\-\.]/g, "");
|
|
2540
2755
|
}
|
|
@@ -2547,6 +2762,55 @@ function userController(store, config, bus) {
|
|
|
2547
2762
|
const loginEvents = new LoginEventModel(store, config.location);
|
|
2548
2763
|
const emailVerif = new EmailVerificationModel(store);
|
|
2549
2764
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
2765
|
+
const deletions = new AccountDeletionModel(store);
|
|
2766
|
+
const deletionBlocked = async (userId) => {
|
|
2767
|
+
for (const blocker of config.accountDeletion?.blockers ?? []) {
|
|
2768
|
+
const refusal = await blocker(userId);
|
|
2769
|
+
if (refusal) return setApiResponse5(HTTP5.CONFLICT, refusal.reason, refusal.explanation, refusal.details);
|
|
2770
|
+
}
|
|
2771
|
+
return null;
|
|
2772
|
+
};
|
|
2773
|
+
const archive = async (userId, channel) => {
|
|
2774
|
+
const [row] = await store.query(
|
|
2775
|
+
`WITH archived AS (
|
|
2776
|
+
UPDATE fonderie_users
|
|
2777
|
+
SET deleted_at = now(), updated_at = now(), deletion_channel = $2, deletion_reminded_at = NULL
|
|
2778
|
+
WHERE id = $1 AND deleted_at IS NULL
|
|
2779
|
+
RETURNING id, deleted_at
|
|
2780
|
+
), sessions_ended AS (
|
|
2781
|
+
DELETE FROM fonderie_sessions WHERE user_id = $1
|
|
2782
|
+
), resets AS (
|
|
2783
|
+
DELETE FROM fonderie_password_resets WHERE user_id = $1
|
|
2784
|
+
), emails AS (
|
|
2785
|
+
DELETE FROM fonderie_email_verifications WHERE user_id = $1
|
|
2786
|
+
), phones AS (
|
|
2787
|
+
DELETE FROM fonderie_phone_verifications WHERE user_id = $1
|
|
2788
|
+
), codes AS (
|
|
2789
|
+
DELETE FROM fonderie_account_deletion_codes WHERE user_id = $1
|
|
2790
|
+
)
|
|
2791
|
+
SELECT deleted_at AS "deletedAt" FROM archived`,
|
|
2792
|
+
[userId, channel]
|
|
2793
|
+
);
|
|
2794
|
+
return row ? new Date(row.deletedAt) : /* @__PURE__ */ new Date();
|
|
2795
|
+
};
|
|
2796
|
+
const announceArchived = async (ctx, deletedAt) => {
|
|
2797
|
+
const userId = ctx.user.id;
|
|
2798
|
+
const reqId = ctx.meta["requestId"];
|
|
2799
|
+
await background4(bus?.emit(
|
|
2800
|
+
EVENT_KEYS.userDeleted,
|
|
2801
|
+
{ userId, deletedAt: deletedAt.toISOString(), deleteOn: deletionDate(deletedAt, config).toISOString() },
|
|
2802
|
+
reqId !== void 0 ? { requestId: reqId } : void 0
|
|
2803
|
+
));
|
|
2804
|
+
await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
|
|
2805
|
+
};
|
|
2806
|
+
const archivedResponse = (deletedAt) => Response.json(
|
|
2807
|
+
{
|
|
2808
|
+
reason: "ACCOUNT_DELETED",
|
|
2809
|
+
explanation: "Account closed. It will be permanently deleted on the date shown; sign in before then to keep it.",
|
|
2810
|
+
result: { requestedAt: deletedAt.toISOString(), deleteOn: deletionDate(deletedAt, config).toISOString() }
|
|
2811
|
+
},
|
|
2812
|
+
{ status: 200, headers: cookieHeaders(clearedTokenCookies(config)) }
|
|
2813
|
+
);
|
|
2550
2814
|
return {
|
|
2551
2815
|
me: async (ctx) => {
|
|
2552
2816
|
const user = await users.findById(ctx.user.id);
|
|
@@ -2829,30 +3093,95 @@ function userController(store, config, bus) {
|
|
|
2829
3093
|
}
|
|
2830
3094
|
return setApiResponse5(HTTP5.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
|
|
2831
3095
|
},
|
|
3096
|
+
// Legacy one-call deletion (no code). Kept so existing apps keep working;
|
|
3097
|
+
// apps move to POST /users/me/deletion + /confirm (proof + notices).
|
|
2832
3098
|
deleteMe: async (ctx) => {
|
|
2833
|
-
const
|
|
2834
|
-
await
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
)
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
3099
|
+
const deletedAt = await archive(ctx.user.id, null);
|
|
3100
|
+
await announceArchived(ctx, deletedAt);
|
|
3101
|
+
return archivedResponse(deletedAt);
|
|
3102
|
+
},
|
|
3103
|
+
/**
|
|
3104
|
+
* Step 1 of deleting an account: a 6-digit code to the channel the person
|
|
3105
|
+
* picks ('email' | 'sms' — an address on the account; receiving the code
|
|
3106
|
+
* proves they hold it). Refused while a blocker objects (e.g. they own a
|
|
3107
|
+
* team with other members — transfer it first).
|
|
3108
|
+
*/
|
|
3109
|
+
requestDeletion: async (ctx) => {
|
|
3110
|
+
const user = ctx.user;
|
|
3111
|
+
const body = ctx.meta["body"];
|
|
3112
|
+
const channel = body?.["channel"] === "sms" ? "sms" : body?.["channel"] === "email" ? "email" : null;
|
|
3113
|
+
if (!channel) {
|
|
3114
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "channel must be 'email' or 'sms'");
|
|
3115
|
+
}
|
|
3116
|
+
const address = channel === "email" ? user.email : user.phone;
|
|
3117
|
+
if (!address) {
|
|
3118
|
+
return setApiResponse5(
|
|
3119
|
+
HTTP5.UNPROCESSABLE,
|
|
3120
|
+
channel === "email" ? "NO_EMAIL_ON_ACCOUNT" : "NO_PHONE_ON_ACCOUNT",
|
|
3121
|
+
`There is no ${channel === "email" ? "email address" : "phone number"} on this account to send the code to.`
|
|
3122
|
+
);
|
|
3123
|
+
}
|
|
3124
|
+
const refused = await deletionBlocked(user.id);
|
|
3125
|
+
if (refused) return refused;
|
|
3126
|
+
const remainingMs = checkCooldown(await deletions.lastSentAt(user.id), DELETION_CODE_COOLDOWN_MS);
|
|
3127
|
+
if (remainingMs > 0) {
|
|
3128
|
+
const retryAfter = Math.ceil(remainingMs / 1e3);
|
|
3129
|
+
return setApiResponse5(HTTP5.TOO_MANY_REQUESTS, "VERIFICATION_COOLDOWN", `Wait ${retryAfter}s before requesting a new code.`, { retryAfter });
|
|
3130
|
+
}
|
|
3131
|
+
const code = randomInt2(1e5, 1e6).toString();
|
|
3132
|
+
await deletions.saveCode(user.id, code, channel, new Date(Date.now() + DELETION_CODE_TTL_MS));
|
|
3133
|
+
await background4(bus?.emit(NOTIFICATION_EVENT3, {
|
|
3134
|
+
type: MESSAGE_KEYS.accountDeletionCode,
|
|
3135
|
+
locale: user.locale,
|
|
3136
|
+
data: { code },
|
|
3137
|
+
recipient: channel === "email" ? { email: address, phone: null, deviceToken: null } : { email: null, phone: address, deviceToken: null }
|
|
3138
|
+
}));
|
|
3139
|
+
return setApiResponse5(HTTP5.ACCEPTED, "ACCOUNT_DELETION_CODE_SENT", "A code to confirm the deletion was sent.", {
|
|
3140
|
+
channel,
|
|
3141
|
+
expiresInSeconds: DELETION_CODE_TTL_MS / 1e3,
|
|
3142
|
+
mfaRequired: user.mfaEnabled === true
|
|
3143
|
+
});
|
|
3144
|
+
},
|
|
3145
|
+
/**
|
|
3146
|
+
* Step 2: the code (and, with two-factor on, a second factor) archives the
|
|
3147
|
+
* account — closed at once, restorable at sign-in until the purge date.
|
|
3148
|
+
*/
|
|
3149
|
+
confirmDeletion: async (ctx) => {
|
|
3150
|
+
const user = ctx.user;
|
|
3151
|
+
const body = ctx.meta["body"];
|
|
3152
|
+
const code = typeof body?.["code"] === "string" ? body["code"].trim() : "";
|
|
3153
|
+
if (!/^\d{6}$/.test(code)) {
|
|
3154
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "code must be the 6-digit code that was sent");
|
|
3155
|
+
}
|
|
3156
|
+
const refused = await deletionBlocked(user.id);
|
|
3157
|
+
if (refused) return refused;
|
|
3158
|
+
if (user.mfaEnabled && !await verifySecondFactor(store, config, user.id, body?.["mfaCode"])) {
|
|
3159
|
+
return setApiResponse5(HTTP5.UNAUTHORIZED, "MFA_REQUIRED", "Enter a code from your authenticator app (or a backup code) to confirm.");
|
|
3160
|
+
}
|
|
3161
|
+
const checked = await deletions.checkCode(user.id, code);
|
|
3162
|
+
if (checked.result !== "ok") {
|
|
3163
|
+
return setApiResponse5(
|
|
3164
|
+
HTTP5.BAD_REQUEST,
|
|
3165
|
+
"VERIFICATION_FAILED",
|
|
3166
|
+
checked.result === "exhausted" || checked.result === "none" || checked.result === "expired" ? "This code can no longer be used. Request a new one." : "That code is incorrect."
|
|
3167
|
+
);
|
|
3168
|
+
}
|
|
3169
|
+
const deletedAt = await archive(user.id, checked.channel ?? null);
|
|
3170
|
+
await announceArchived(ctx, deletedAt);
|
|
3171
|
+
const deleteOn = deletionDate(deletedAt, config);
|
|
3172
|
+
const address = checked.channel === "sms" ? user.phone : user.email;
|
|
3173
|
+
if (address) {
|
|
3174
|
+
await background4(bus?.emit(NOTIFICATION_EVENT3, {
|
|
3175
|
+
type: MESSAGE_KEYS.accountDeletionScheduled,
|
|
3176
|
+
locale: user.locale,
|
|
3177
|
+
data: {
|
|
3178
|
+
deleteOn: deleteOn.toISOString().slice(0, 10),
|
|
3179
|
+
[COURIER_FORMAT_KEY]: { deleteOn: { date: deleteOn.toISOString(), style: "long" } }
|
|
3180
|
+
},
|
|
3181
|
+
recipient: checked.channel === "sms" ? { email: null, phone: address, deviceToken: null } : { email: address, phone: null, deviceToken: null }
|
|
3182
|
+
}));
|
|
3183
|
+
}
|
|
3184
|
+
return archivedResponse(deletedAt);
|
|
2856
3185
|
},
|
|
2857
3186
|
// Subject Access Request — the authenticated user's own data as a portable
|
|
2858
3187
|
// JSON bundle. Only auth-owned data, and only safe fields: no password hash,
|
|
@@ -2894,8 +3223,8 @@ function userController(store, config, bus) {
|
|
|
2894
3223
|
}
|
|
2895
3224
|
|
|
2896
3225
|
// src/controllers/oauth.controller.ts
|
|
2897
|
-
import { randomBytes as randomBytes4, createHash as
|
|
2898
|
-
import
|
|
3226
|
+
import { randomBytes as randomBytes4, createHash as createHash5 } from "crypto";
|
|
3227
|
+
import jwt3 from "jsonwebtoken";
|
|
2899
3228
|
import { setApiResponse as setApiResponse6, HTTP as HTTP6, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
|
|
2900
3229
|
import { NOTIFICATION_EVENT as NOTIFICATION_EVENT4 } from "@fonderie/events";
|
|
2901
3230
|
|
|
@@ -2980,7 +3309,7 @@ var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
|
|
|
2980
3309
|
var APPLE_ISSUER = "https://appleid.apple.com";
|
|
2981
3310
|
function mintAppleClientSecret(apple) {
|
|
2982
3311
|
const now = Math.floor(Date.now() / 1e3);
|
|
2983
|
-
return
|
|
3312
|
+
return jwt3.sign(
|
|
2984
3313
|
{ iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
|
|
2985
3314
|
apple.privateKey,
|
|
2986
3315
|
{ algorithm: "ES256", keyid: apple.keyId }
|
|
@@ -2989,14 +3318,14 @@ function mintAppleClientSecret(apple) {
|
|
|
2989
3318
|
var appleKeys = createJwksSource(APPLE_KEYS_URL);
|
|
2990
3319
|
async function verifyAppleIdToken(idToken, opts) {
|
|
2991
3320
|
if (opts.audiences.length === 0) return null;
|
|
2992
|
-
const decoded =
|
|
3321
|
+
const decoded = jwt3.decode(idToken, { complete: true });
|
|
2993
3322
|
if (!decoded || typeof decoded === "string") return null;
|
|
2994
3323
|
const kid = decoded.header.kid;
|
|
2995
3324
|
if (!kid) return null;
|
|
2996
3325
|
const key = await appleKeys.getKey(kid);
|
|
2997
3326
|
if (!key) return null;
|
|
2998
3327
|
try {
|
|
2999
|
-
const payload =
|
|
3328
|
+
const payload = jwt3.verify(idToken, key, {
|
|
3000
3329
|
algorithms: ["RS256"],
|
|
3001
3330
|
issuer: APPLE_ISSUER,
|
|
3002
3331
|
// jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
|
|
@@ -3016,14 +3345,14 @@ var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
|
3016
3345
|
var googleKeys = createJwksSource(GOOGLE_KEYS_URL);
|
|
3017
3346
|
async function verifyGoogleIdToken(idToken, opts) {
|
|
3018
3347
|
if (opts.audiences.length === 0) return null;
|
|
3019
|
-
const decoded =
|
|
3348
|
+
const decoded = jwt3.decode(idToken, { complete: true });
|
|
3020
3349
|
if (!decoded || typeof decoded === "string") return null;
|
|
3021
3350
|
const kid = decoded.header.kid;
|
|
3022
3351
|
if (!kid) return null;
|
|
3023
3352
|
const key = await googleKeys.getKey(kid);
|
|
3024
3353
|
if (!key) return null;
|
|
3025
3354
|
try {
|
|
3026
|
-
const payload =
|
|
3355
|
+
const payload = jwt3.verify(idToken, key, {
|
|
3027
3356
|
algorithms: ["RS256"],
|
|
3028
3357
|
issuer: [...GOOGLE_ISSUERS],
|
|
3029
3358
|
audience: opts.audiences
|
|
@@ -3125,7 +3454,7 @@ function oauthController(store, config, bus) {
|
|
|
3125
3454
|
failureReason: "pending_deletion",
|
|
3126
3455
|
...meta
|
|
3127
3456
|
});
|
|
3128
|
-
const res = pendingDeletionResponse(archived.deletedAt, config);
|
|
3457
|
+
const res = pendingDeletionResponse({ ...archived, deletedAt: archived.deletedAt }, provider, config);
|
|
3129
3458
|
const headers = cookieHeaders(clearCookies);
|
|
3130
3459
|
headers.set("content-type", res.headers.get("content-type") ?? "application/json");
|
|
3131
3460
|
return new Response(res.body, { status: res.status, headers });
|
|
@@ -3395,7 +3724,7 @@ function oauthController(store, config, bus) {
|
|
|
3395
3724
|
});
|
|
3396
3725
|
return setApiResponse6(HTTP6.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
3397
3726
|
}
|
|
3398
|
-
const tokenHash =
|
|
3727
|
+
const tokenHash = createHash5("sha256").update(identityToken).digest("hex");
|
|
3399
3728
|
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
3400
3729
|
const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
|
|
3401
3730
|
if (!firstUse) {
|
|
@@ -3445,7 +3774,7 @@ function oauthController(store, config, bus) {
|
|
|
3445
3774
|
});
|
|
3446
3775
|
return setApiResponse6(HTTP6.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
|
|
3447
3776
|
}
|
|
3448
|
-
const tokenHash =
|
|
3777
|
+
const tokenHash = createHash5("sha256").update(idToken).digest("hex");
|
|
3449
3778
|
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
3450
3779
|
if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
|
|
3451
3780
|
await loginEvents.recordSafe({
|
|
@@ -3534,6 +3863,11 @@ function buildAuthRoutes(store, config, bus, locales) {
|
|
|
3534
3863
|
// refuses when it would leave the account with no way to sign in.
|
|
3535
3864
|
R("unlinkOauth", "DELETE", "/auth/oauth/:provider", requireAuth, user.unlinkOauth),
|
|
3536
3865
|
R("deleteMe", "DELETE", "/users", requireAuth, verifyGate, user.deleteMe),
|
|
3866
|
+
// Deleting with proof: a code to a chosen channel, then confirm. The
|
|
3867
|
+
// account is archived until the purge; signing in offers to keep it.
|
|
3868
|
+
R("requestDeletion", "POST", "/users/me/deletion", requireAuth, verifyGate, validate(requestDeletionSchema), user.requestDeletion),
|
|
3869
|
+
R("confirmDeletion", "POST", "/users/me/deletion/confirm", ipLimit("verify"), requireAuth, verifyGate, validate(confirmDeletionSchema), user.confirmDeletion),
|
|
3870
|
+
R("restoreAccount", "POST", "/auth/account/restore", ipLimit("login"), validate(restoreAccountSchema), auth.restoreAccount),
|
|
3537
3871
|
R("exportMe", "GET", "/users/export", requireAuth, user.exportMe),
|
|
3538
3872
|
// Security surfaces (Protected; the caller's own login history + sessions).
|
|
3539
3873
|
// The literal /others route is registered before /:id so it isn't
|
|
@@ -3737,8 +4071,11 @@ function withSession(store, config) {
|
|
|
3737
4071
|
if (payload.sid && !await sessions.aliveBySid(payload.sid)) {
|
|
3738
4072
|
return next();
|
|
3739
4073
|
}
|
|
3740
|
-
|
|
3741
|
-
if (!user
|
|
4074
|
+
let user = await users.findById(payload.sub);
|
|
4075
|
+
if (!user && payload.mfaPending && payload.loginMethod === "phone") {
|
|
4076
|
+
user = await users.findArchivedById(payload.sub);
|
|
4077
|
+
}
|
|
4078
|
+
if (!user || user.suspended || user.deletedAt && !payload.mfaPending) {
|
|
3742
4079
|
return next();
|
|
3743
4080
|
}
|
|
3744
4081
|
Object.assign(ctx, {
|
|
@@ -3913,7 +4250,7 @@ var AuthModule = class {
|
|
|
3913
4250
|
name = "@fonderie/auth";
|
|
3914
4251
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
3915
4252
|
// say what is actually deployed rather than 'not reported'.
|
|
3916
|
-
version = "7.
|
|
4253
|
+
version = "7.24.0";
|
|
3917
4254
|
// Users, sessions, login history, suspend — only through @fonderie/admin.
|
|
3918
4255
|
describeAdmin() {
|
|
3919
4256
|
return { routes: describeAuthAdminRoutes(this.store, this.bus) };
|
|
@@ -4101,6 +4438,29 @@ inmediato y cambia tu contrase\xF1a \u2014 otra persona podr\xEDa iniciar sesi\x
|
|
|
4101
4438
|
{{provider}} ya no se puede usar para iniciar sesi\xF3n. Tu correo electr\xF3nico y tu contrase\xF1a siguen funcionando.
|
|
4102
4439
|
|
|
4103
4440
|
Si fuiste t\xFA, todo est\xE1 en orden. Si no, contacta con soporte de inmediato.`
|
|
4441
|
+
},
|
|
4442
|
+
[MESSAGE_KEYS.accountDeletionCode]: {
|
|
4443
|
+
subject: `Confirma la eliminaci\xF3n de tu cuenta`,
|
|
4444
|
+
html: `<h1>Confirmar la eliminaci\xF3n de la cuenta</h1>
|
|
4445
|
+
<p>Usa este c\xF3digo para confirmar que quieres eliminar tu cuenta:</p>
|
|
4446
|
+
<p><span class="pin-code">{{code}}</span></p>
|
|
4447
|
+
<p class="muted">Este c\xF3digo vence en 15 minutos. Si no pediste eliminar tu cuenta, ignora este mensaje y cambia tu contrase\xF1a: tu cuenta seguir\xE1 igual.</p>`,
|
|
4448
|
+
text: `Tu c\xF3digo para confirmar la eliminaci\xF3n de tu cuenta: {{code}}. Vence en 15 minutos. \xBFNo fuiste t\xFA? Ignora esto y cambia tu contrase\xF1a.`
|
|
4449
|
+
},
|
|
4450
|
+
[MESSAGE_KEYS.accountDeletionScheduled]: {
|
|
4451
|
+
subject: `Tu cuenta se eliminar\xE1 el {{deleteOn}}`,
|
|
4452
|
+
html: `<h1>Tu cuenta est\xE1 programada para eliminarse</h1>
|
|
4453
|
+
<p>Recibimos tu solicitud. Tu cuenta est\xE1 cerrada y se eliminar\xE1 de forma permanente el <strong>{{deleteOn}}</strong>.</p>
|
|
4454
|
+
<p>\xBFCambiaste de opini\xF3n? Inicia sesi\xF3n antes de esa fecha y elige <strong>Conservar mi cuenta</strong>.</p>
|
|
4455
|
+
<p class="muted">Si no lo pediste, inicia sesi\xF3n ahora para conservar tu cuenta y cambia tu contrase\xF1a.</p>`,
|
|
4456
|
+
text: `Tu cuenta se eliminar\xE1 de forma permanente el {{deleteOn}}. \xBFCambiaste de opini\xF3n? Inicia sesi\xF3n antes y elige Conservar mi cuenta.`
|
|
4457
|
+
},
|
|
4458
|
+
[MESSAGE_KEYS.accountRestored]: {
|
|
4459
|
+
subject: `Tu cuenta fue restablecida`,
|
|
4460
|
+
html: `<h1>Bienvenido de nuevo</h1>
|
|
4461
|
+
<p>Se cancel\xF3 la eliminaci\xF3n de tu cuenta y tu cuenta vuelve a estar activa.</p>
|
|
4462
|
+
<p class="muted">Si no fuiste t\xFA, cambia tu contrase\xF1a de inmediato.</p>`,
|
|
4463
|
+
text: `Se cancel\xF3 la eliminaci\xF3n de tu cuenta; tu cuenta vuelve a estar activa. \xBFNo fuiste t\xFA? Cambia tu contrase\xF1a ahora.`
|
|
4104
4464
|
}
|
|
4105
4465
|
};
|
|
4106
4466
|
|
|
@@ -4261,6 +4621,29 @@ pourrait se connecter \xE0 votre place.`
|
|
|
4261
4621
|
{{provider}} ne peut plus \xEAtre utilis\xE9 pour vous connecter. Votre adresse e-mail et votre mot de passe fonctionnent toujours.
|
|
4262
4622
|
|
|
4263
4623
|
Si c'est bien vous, tout est en ordre. Sinon, contactez imm\xE9diatement le support.`
|
|
4624
|
+
},
|
|
4625
|
+
[MESSAGE_KEYS.accountDeletionCode]: {
|
|
4626
|
+
subject: `Confirmez la suppression de votre compte`,
|
|
4627
|
+
html: `<h1>Confirmer la suppression du compte</h1>
|
|
4628
|
+
<p>Utilisez ce code pour confirmer que vous souhaitez supprimer votre compte :</p>
|
|
4629
|
+
<p><span class="pin-code">{{code}}</span></p>
|
|
4630
|
+
<p class="muted">Ce code expire dans 15 minutes. Si vous n’avez pas demand\xE9 la suppression de votre compte, ignorez ce message et changez votre mot de passe — votre compte reste tel quel.</p>`,
|
|
4631
|
+
text: `Votre code pour confirmer la suppression de votre compte : {{code}}. Il expire dans 15 minutes. Ce n'est pas vous ? Ignorez ce message et changez votre mot de passe.`
|
|
4632
|
+
},
|
|
4633
|
+
[MESSAGE_KEYS.accountDeletionScheduled]: {
|
|
4634
|
+
subject: `Votre compte sera supprim\xE9 le {{deleteOn}}`,
|
|
4635
|
+
html: `<h1>Votre compte sera supprim\xE9</h1>
|
|
4636
|
+
<p>Nous avons bien re\xE7u votre demande. Votre compte est ferm\xE9 et sera supprim\xE9 d\xE9finitivement le <strong>{{deleteOn}}</strong>.</p>
|
|
4637
|
+
<p>Vous avez chang\xE9 d’avis ? Connectez-vous avant cette date et choisissez <strong>Conserver mon compte</strong>.</p>
|
|
4638
|
+
<p class="muted">Si vous n’\xEAtes pas \xE0 l’origine de cette demande, connectez-vous maintenant pour conserver votre compte et changez votre mot de passe.</p>`,
|
|
4639
|
+
text: `Votre compte sera supprim\xE9 d\xE9finitivement le {{deleteOn}}. Vous avez chang\xE9 d'avis ? Connectez-vous avant cette date et choisissez Conserver mon compte.`
|
|
4640
|
+
},
|
|
4641
|
+
[MESSAGE_KEYS.accountRestored]: {
|
|
4642
|
+
subject: `Votre compte a \xE9t\xE9 r\xE9tabli`,
|
|
4643
|
+
html: `<h1>Bon retour</h1>
|
|
4644
|
+
<p>La suppression de votre compte a \xE9t\xE9 annul\xE9e et votre compte est de nouveau actif.</p>
|
|
4645
|
+
<p class="muted">Si ce n’est pas vous, changez votre mot de passe imm\xE9diatement.</p>`,
|
|
4646
|
+
text: `La suppression de votre compte a \xE9t\xE9 annul\xE9e ; votre compte est de nouveau actif. Ce n'est pas vous ? Changez votre mot de passe maintenant.`
|
|
4264
4647
|
}
|
|
4265
4648
|
};
|
|
4266
4649
|
|
|
@@ -4415,6 +4798,29 @@ var ZH_HANS_TEMPLATES = {
|
|
|
4415
4798
|
\u5DF2\u65E0\u6CD5\u518D\u4F7F\u7528 {{provider}} \u767B\u5F55\u3002\u60A8\u7684\u7535\u5B50\u90AE\u7BB1\u548C\u5BC6\u7801\u4ECD\u7136\u53EF\u7528\u3002
|
|
4416
4799
|
|
|
4417
4800
|
\u5982\u679C\u662F\u60A8\u672C\u4EBA\u79FB\u9664\u7684\uFF0C\u65E0\u9700\u4EFB\u4F55\u64CD\u4F5C\u3002\u5982\u679C\u4E0D\u662F\uFF0C\u8BF7\u7ACB\u5373\u8054\u7CFB\u5BA2\u670D\u3002`
|
|
4801
|
+
},
|
|
4802
|
+
[MESSAGE_KEYS.accountDeletionCode]: {
|
|
4803
|
+
subject: `\u786E\u8BA4\u5220\u9664\u60A8\u7684\u8D26\u6237`,
|
|
4804
|
+
html: `<h1>\u786E\u8BA4\u5220\u9664\u8D26\u6237</h1>
|
|
4805
|
+
<p>\u8BF7\u4F7F\u7528\u6B64\u9A8C\u8BC1\u7801\u786E\u8BA4\u60A8\u8981\u5220\u9664\u8D26\u6237\uFF1A</p>
|
|
4806
|
+
<p><span class="pin-code">{{code}}</span></p>
|
|
4807
|
+
<p class="muted">\u6B64\u9A8C\u8BC1\u7801\u5C06\u5728 15 \u5206\u949F\u540E\u5931\u6548\u3002\u5982\u679C\u60A8\u6CA1\u6709\u7533\u8BF7\u5220\u9664\u8D26\u6237\uFF0C\u8BF7\u5FFD\u7565\u6B64\u6D88\u606F\u5E76\u66F4\u6539\u5BC6\u7801\uFF0C\u60A8\u7684\u8D26\u6237\u5C06\u4FDD\u6301\u4E0D\u53D8\u3002</p>`,
|
|
4808
|
+
text: `\u786E\u8BA4\u5220\u9664\u8D26\u6237\u7684\u9A8C\u8BC1\u7801\uFF1A{{code}}\uFF0C15 \u5206\u949F\u5185\u6709\u6548\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8BF7\u5FFD\u7565\u5E76\u66F4\u6539\u5BC6\u7801\u3002`
|
|
4809
|
+
},
|
|
4810
|
+
[MESSAGE_KEYS.accountDeletionScheduled]: {
|
|
4811
|
+
subject: `\u60A8\u7684\u8D26\u6237\u5C06\u4E8E {{deleteOn}} \u5220\u9664`,
|
|
4812
|
+
html: `<h1>\u60A8\u7684\u8D26\u6237\u5DF2\u5B89\u6392\u5220\u9664</h1>
|
|
4813
|
+
<p>\u6211\u4EEC\u5DF2\u6536\u5230\u60A8\u7684\u7533\u8BF7\u3002\u60A8\u7684\u8D26\u6237\u5DF2\u5173\u95ED\uFF0C\u5E76\u5C06\u4E8E <strong>{{deleteOn}}</strong> \u6C38\u4E45\u5220\u9664\u3002</p>
|
|
4814
|
+
<p>\u6539\u53D8\u4E3B\u610F\u4E86\uFF1F\u8BF7\u5728\u8BE5\u65E5\u671F\u524D\u767B\u5F55\u5E76\u9009\u62E9<strong>\u4FDD\u7559\u6211\u7684\u8D26\u6237</strong>\u3002</p>
|
|
4815
|
+
<p class="muted">\u5982\u679C\u8FD9\u4E0D\u662F\u60A8\u672C\u4EBA\u7684\u7533\u8BF7\uFF0C\u8BF7\u7ACB\u5373\u767B\u5F55\u4EE5\u4FDD\u7559\u8D26\u6237\u5E76\u66F4\u6539\u5BC6\u7801\u3002</p>`,
|
|
4816
|
+
text: `\u60A8\u7684\u8D26\u6237\u5C06\u4E8E {{deleteOn}} \u6C38\u4E45\u5220\u9664\u3002\u6539\u53D8\u4E3B\u610F\u4E86\uFF1F\u8BF7\u5728\u6B64\u524D\u767B\u5F55\u5E76\u9009\u62E9\u201C\u4FDD\u7559\u6211\u7684\u8D26\u6237\u201D\u3002`
|
|
4817
|
+
},
|
|
4818
|
+
[MESSAGE_KEYS.accountRestored]: {
|
|
4819
|
+
subject: `\u60A8\u7684\u8D26\u6237\u5DF2\u6062\u590D`,
|
|
4820
|
+
html: `<h1>\u6B22\u8FCE\u56DE\u6765</h1>
|
|
4821
|
+
<p>\u60A8\u7684\u8D26\u6237\u5220\u9664\u5DF2\u53D6\u6D88\uFF0C\u8D26\u6237\u5DF2\u91CD\u65B0\u542F\u7528\u3002</p>
|
|
4822
|
+
<p class="muted">\u5982\u679C\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF0C\u8BF7\u7ACB\u5373\u66F4\u6539\u5BC6\u7801\u3002</p>`,
|
|
4823
|
+
text: `\u60A8\u7684\u8D26\u6237\u5220\u9664\u5DF2\u53D6\u6D88\uFF0C\u8D26\u6237\u5DF2\u91CD\u65B0\u542F\u7528\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8BF7\u7ACB\u5373\u66F4\u6539\u5BC6\u7801\u3002`
|
|
4418
4824
|
}
|
|
4419
4825
|
};
|
|
4420
4826
|
|
|
@@ -4569,6 +4975,29 @@ var ZH_HANT_TEMPLATES = {
|
|
|
4569
4975
|
\u5DF2\u7121\u6CD5\u518D\u4F7F\u7528 {{provider}} \u767B\u5165\u3002\u60A8\u7684\u96FB\u5B50\u90F5\u4EF6\u548C\u5BC6\u78BC\u4ECD\u53EF\u4F7F\u7528\u3002
|
|
4570
4976
|
|
|
4571
4977
|
\u5982\u679C\u662F\u60A8\u672C\u4EBA\u79FB\u9664\u7684\uFF0C\u7121\u9808\u63A1\u53D6\u4EFB\u4F55\u884C\u52D5\u3002\u5982\u679C\u4E0D\u662F\uFF0C\u8ACB\u7ACB\u5373\u806F\u7D61\u5BA2\u670D\u3002`
|
|
4978
|
+
},
|
|
4979
|
+
[MESSAGE_KEYS.accountDeletionCode]: {
|
|
4980
|
+
subject: `\u78BA\u8A8D\u522A\u9664\u60A8\u7684\u5E33\u6236`,
|
|
4981
|
+
html: `<h1>\u78BA\u8A8D\u522A\u9664\u5E33\u6236</h1>
|
|
4982
|
+
<p>\u8ACB\u4F7F\u7528\u6B64\u9A57\u8B49\u78BC\u78BA\u8A8D\u60A8\u8981\u522A\u9664\u5E33\u6236\uFF1A</p>
|
|
4983
|
+
<p><span class="pin-code">{{code}}</span></p>
|
|
4984
|
+
<p class="muted">\u6B64\u9A57\u8B49\u78BC\u5C07\u5728 15 \u5206\u9418\u5F8C\u5931\u6548\u3002\u5982\u679C\u60A8\u6C92\u6709\u7533\u8ACB\u522A\u9664\u5E33\u6236\uFF0C\u8ACB\u5FFD\u7565\u6B64\u8A0A\u606F\u4E26\u66F4\u6539\u5BC6\u78BC\uFF0C\u60A8\u7684\u5E33\u6236\u5C07\u4FDD\u6301\u4E0D\u8B8A\u3002</p>`,
|
|
4985
|
+
text: `\u78BA\u8A8D\u522A\u9664\u5E33\u6236\u7684\u9A57\u8B49\u78BC\uFF1A{{code}}\uFF0C15 \u5206\u9418\u5167\u6709\u6548\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8ACB\u5FFD\u7565\u4E26\u66F4\u6539\u5BC6\u78BC\u3002`
|
|
4986
|
+
},
|
|
4987
|
+
[MESSAGE_KEYS.accountDeletionScheduled]: {
|
|
4988
|
+
subject: `\u60A8\u7684\u5E33\u6236\u5C07\u65BC {{deleteOn}} \u522A\u9664`,
|
|
4989
|
+
html: `<h1>\u60A8\u7684\u5E33\u6236\u5DF2\u6392\u5B9A\u522A\u9664</h1>
|
|
4990
|
+
<p>\u6211\u5011\u5DF2\u6536\u5230\u60A8\u7684\u7533\u8ACB\u3002\u60A8\u7684\u5E33\u6236\u5DF2\u95DC\u9589\uFF0C\u4E26\u5C07\u65BC <strong>{{deleteOn}}</strong> \u6C38\u4E45\u522A\u9664\u3002</p>
|
|
4991
|
+
<p>\u6539\u8B8A\u4E3B\u610F\u4E86\uFF1F\u8ACB\u5728\u8A72\u65E5\u671F\u524D\u767B\u5165\u4E26\u9078\u64C7<strong>\u4FDD\u7559\u6211\u7684\u5E33\u6236</strong>\u3002</p>
|
|
4992
|
+
<p class="muted">\u5982\u679C\u9019\u4E0D\u662F\u60A8\u672C\u4EBA\u7684\u7533\u8ACB\uFF0C\u8ACB\u7ACB\u5373\u767B\u5165\u4EE5\u4FDD\u7559\u5E33\u6236\u4E26\u66F4\u6539\u5BC6\u78BC\u3002</p>`,
|
|
4993
|
+
text: `\u60A8\u7684\u5E33\u6236\u5C07\u65BC {{deleteOn}} \u6C38\u4E45\u522A\u9664\u3002\u6539\u8B8A\u4E3B\u610F\u4E86\uFF1F\u8ACB\u5728\u6B64\u524D\u767B\u5165\u4E26\u9078\u64C7\u300C\u4FDD\u7559\u6211\u7684\u5E33\u6236\u300D\u3002`
|
|
4994
|
+
},
|
|
4995
|
+
[MESSAGE_KEYS.accountRestored]: {
|
|
4996
|
+
subject: `\u60A8\u7684\u5E33\u6236\u5DF2\u6062\u5FA9`,
|
|
4997
|
+
html: `<h1>\u6B61\u8FCE\u56DE\u4F86</h1>
|
|
4998
|
+
<p>\u60A8\u7684\u5E33\u6236\u522A\u9664\u5DF2\u53D6\u6D88\uFF0C\u5E33\u6236\u5DF2\u91CD\u65B0\u555F\u7528\u3002</p>
|
|
4999
|
+
<p class="muted">\u5982\u679C\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF0C\u8ACB\u7ACB\u5373\u66F4\u6539\u5BC6\u78BC\u3002</p>`,
|
|
5000
|
+
text: `\u60A8\u7684\u5E33\u6236\u522A\u9664\u5DF2\u53D6\u6D88\uFF0C\u5E33\u6236\u5DF2\u91CD\u65B0\u555F\u7528\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8ACB\u7ACB\u5373\u66F4\u6539\u5BC6\u78BC\u3002`
|
|
4572
5001
|
}
|
|
4573
5002
|
};
|
|
4574
5003
|
|
|
@@ -4727,6 +5156,29 @@ change your password \u2014 someone else may be able to sign in as you.`
|
|
|
4727
5156
|
{{provider}} can no longer be used to sign in. Your email and password still work.
|
|
4728
5157
|
|
|
4729
5158
|
If you did this, you're all set. If not, contact support right away.`
|
|
5159
|
+
},
|
|
5160
|
+
[MESSAGE_KEYS.accountDeletionCode]: {
|
|
5161
|
+
subject: `Confirm your account deletion`,
|
|
5162
|
+
html: `<h1>Confirm account deletion</h1>
|
|
5163
|
+
<p>Use this code to confirm that you want to delete your account:</p>
|
|
5164
|
+
<p><span class="pin-code">{{code}}</span></p>
|
|
5165
|
+
<p class="muted">This code expires in 15 minutes. If you didn’t ask to delete your account, ignore this message and change your password — your account stays as it is.</p>`,
|
|
5166
|
+
text: `Your code to confirm deleting your account: {{code}}. It expires in 15 minutes. Didn't ask? Ignore this and change your password.`
|
|
5167
|
+
},
|
|
5168
|
+
[MESSAGE_KEYS.accountDeletionScheduled]: {
|
|
5169
|
+
subject: `Your account will be deleted on {{deleteOn}}`,
|
|
5170
|
+
html: `<h1>Your account is scheduled for deletion</h1>
|
|
5171
|
+
<p>We received your request. Your account is closed and will be permanently deleted on <strong>{{deleteOn}}</strong>.</p>
|
|
5172
|
+
<p>Changed your mind? Sign in before that date and choose <strong>Keep my account</strong>.</p>
|
|
5173
|
+
<p class="muted">If you didn’t ask for this, sign in now to keep your account and change your password.</p>`,
|
|
5174
|
+
text: `Your account will be permanently deleted on {{deleteOn}}. Changed your mind? Sign in before then and choose Keep my account.`
|
|
5175
|
+
},
|
|
5176
|
+
[MESSAGE_KEYS.accountRestored]: {
|
|
5177
|
+
subject: `Your account was restored`,
|
|
5178
|
+
html: `<h1>Welcome back</h1>
|
|
5179
|
+
<p>Your account deletion was cancelled and your account is active again.</p>
|
|
5180
|
+
<p class="muted">If you didn’t do this, change your password right away.</p>`,
|
|
5181
|
+
text: `Your account deletion was cancelled; your account is active again. Didn't do this? Change your password now.`
|
|
4730
5182
|
}
|
|
4731
5183
|
};
|
|
4732
5184
|
var DEFAULT_TEMPLATES = withTranslations(EN_TEMPLATES, {
|
|
@@ -4752,7 +5204,10 @@ var SAMPLE_PAYLOADS = {
|
|
|
4752
5204
|
[MESSAGE_KEYS.mfaDisabled]: {},
|
|
4753
5205
|
[MESSAGE_KEYS.mfaBackupCodesRegenerated]: {},
|
|
4754
5206
|
[MESSAGE_KEYS.emailChanged]: { newEmail: "new@example.com" },
|
|
4755
|
-
[MESSAGE_KEYS.phoneChanged]: {}
|
|
5207
|
+
[MESSAGE_KEYS.phoneChanged]: {},
|
|
5208
|
+
[MESSAGE_KEYS.accountDeletionCode]: { code: "123456" },
|
|
5209
|
+
[MESSAGE_KEYS.accountDeletionScheduled]: { deleteOn: "November 3, 2026" },
|
|
5210
|
+
[MESSAGE_KEYS.accountRestored]: {}
|
|
4756
5211
|
};
|
|
4757
5212
|
|
|
4758
5213
|
// src/middlewares/require-auth.ts
|