@luckystack/login 0.7.2 → 0.7.4
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/CHANGELOG.md +33 -0
- package/CLAUDE.md +5 -2
- package/dist/{chunk-L5MHCPNN.js → chunk-CLYMWF3A.js} +1 -1
- package/dist/{chunk-L5MHCPNN.js.map → chunk-CLYMWF3A.js.map} +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +194 -72
- package/dist/index.js.map +1 -1
- package/dist/register.js +1 -1
- package/docs/redirect-validation.md +5 -3
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
registerOAuthProviderFactory,
|
|
15
15
|
registerOAuthProviders,
|
|
16
16
|
registerUserAdapter
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-CLYMWF3A.js";
|
|
18
18
|
import {
|
|
19
19
|
activeUsersKeyFor,
|
|
20
20
|
deleteSession,
|
|
@@ -139,10 +139,20 @@ var registerPostLoginRedirect = (resolver) => {
|
|
|
139
139
|
return resolver;
|
|
140
140
|
};
|
|
141
141
|
var getPostLoginRedirect = () => activeResolver;
|
|
142
|
+
var RELATIVE_BASE = "http://luckystack-relative.invalid";
|
|
143
|
+
var resolvePostLoginRedirectAgainstDefault = (candidate, defaultUrl) => {
|
|
144
|
+
if (!URL.canParse(candidate, RELATIVE_BASE)) return candidate;
|
|
145
|
+
const parsed = new URL(candidate, RELATIVE_BASE);
|
|
146
|
+
if (parsed.origin !== RELATIVE_BASE) return candidate;
|
|
147
|
+
if (!URL.canParse(defaultUrl)) return candidate;
|
|
148
|
+
const fallback = new URL(defaultUrl);
|
|
149
|
+
if (fallback.protocol !== "http:" && fallback.protocol !== "https:") return candidate;
|
|
150
|
+
return new URL(candidate, fallback).toString();
|
|
151
|
+
};
|
|
142
152
|
|
|
143
153
|
// src/login.ts
|
|
144
154
|
import { URLSearchParams as URLSearchParams2 } from "url";
|
|
145
|
-
import { tryCatch as tryCatch2, tryCatchSync, redis as redisClient, getUploadsDir, dispatchHook, getLogger as getLogger3, formatKey, getProjectConfig as getProjectConfig4, getCookieValue } from "@luckystack/core";
|
|
155
|
+
import { tryCatch as tryCatch2, tryCatchSync, redis as redisClient, getUploadsDir, dispatchHook, getLogger as getLogger3, formatKey, getProjectConfig as getProjectConfig4, getCookieValue, resolveDevCallbackUrl } from "@luckystack/core";
|
|
146
156
|
import bcrypt from "bcryptjs";
|
|
147
157
|
import { randomBytes, createHash, timingSafeEqual } from "crypto";
|
|
148
158
|
import validator from "validator";
|
|
@@ -773,11 +783,12 @@ var isAllowedRedirectUrl = (url) => {
|
|
|
773
783
|
});
|
|
774
784
|
};
|
|
775
785
|
var exchangeOAuthToken = async (provider, code, codeVerifier) => {
|
|
786
|
+
const redirectUri = resolveDevCallbackUrl(provider.callbackURL);
|
|
776
787
|
const values = {
|
|
777
788
|
code,
|
|
778
789
|
client_id: provider.clientID,
|
|
779
790
|
client_secret: provider.clientSecret,
|
|
780
|
-
redirect_uri:
|
|
791
|
+
redirect_uri: redirectUri,
|
|
781
792
|
grant_type: "authorization_code",
|
|
782
793
|
...codeVerifier ? { code_verifier: codeVerifier } : {}
|
|
783
794
|
};
|
|
@@ -799,7 +810,7 @@ var exchangeOAuthToken = async (provider, code, codeVerifier) => {
|
|
|
799
810
|
formParams.append("client_secret", provider.clientSecret);
|
|
800
811
|
formParams.append("code", values.code);
|
|
801
812
|
formParams.append("grant_type", "authorization_code");
|
|
802
|
-
formParams.append("redirect_uri",
|
|
813
|
+
formParams.append("redirect_uri", redirectUri);
|
|
803
814
|
if (codeVerifier) {
|
|
804
815
|
formParams.append("code_verifier", codeVerifier);
|
|
805
816
|
}
|
|
@@ -986,7 +997,9 @@ var resolvePostLoginRedirect = async ({
|
|
|
986
997
|
getLogger3().warn(`[oauth] postLoginRedirect resolver threw`, { message: resolverError.message });
|
|
987
998
|
return fallbackUrl;
|
|
988
999
|
}
|
|
989
|
-
if (resolved && isAllowedRedirectUrl(resolved))
|
|
1000
|
+
if (resolved && isAllowedRedirectUrl(resolved)) {
|
|
1001
|
+
return resolvePostLoginRedirectAgainstDefault(resolved, fallbackUrl);
|
|
1002
|
+
}
|
|
990
1003
|
if (resolved) {
|
|
991
1004
|
getLogger3().warn(
|
|
992
1005
|
`[oauth] postLoginRedirect returned a URL not in allowed origins \u2014 falling back`,
|
|
@@ -1266,33 +1279,53 @@ var normalizeIdentity = (identity) => identity.trim().toLowerCase();
|
|
|
1266
1279
|
var codeKey = (purpose, identity) => formatKey4("-emailcode", `${purpose}:${normalizeIdentity(identity)}`);
|
|
1267
1280
|
var attemptsKey = (purpose, identity) => formatKey4("-emailcode-attempts", `${purpose}:${normalizeIdentity(identity)}`);
|
|
1268
1281
|
var hashCode = (code) => crypto.createHash("sha256").update(code).digest("hex");
|
|
1282
|
+
var ISSUE_CODE_SCRIPT = `
|
|
1283
|
+
redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2])
|
|
1284
|
+
redis.call('del', KEYS[2])
|
|
1285
|
+
return 1
|
|
1286
|
+
`;
|
|
1287
|
+
var VERIFY_CODE_SCRIPT = `
|
|
1288
|
+
local stored = redis.call('get', KEYS[1])
|
|
1289
|
+
if not stored then return 0 end
|
|
1290
|
+
local attempts = redis.call('incr', KEYS[2])
|
|
1291
|
+
local remaining = redis.call('ttl', KEYS[1])
|
|
1292
|
+
redis.call('expire', KEYS[2], remaining > 0 and remaining or 600)
|
|
1293
|
+
if attempts > tonumber(ARGV[2]) then
|
|
1294
|
+
redis.call('del', KEYS[1])
|
|
1295
|
+
redis.call('del', KEYS[2])
|
|
1296
|
+
return 3
|
|
1297
|
+
end
|
|
1298
|
+
if stored ~= ARGV[1] then return 1 end
|
|
1299
|
+
redis.call('del', KEYS[1])
|
|
1300
|
+
redis.call('del', KEYS[2])
|
|
1301
|
+
return 2
|
|
1302
|
+
`;
|
|
1269
1303
|
var generateNumericCode = (digits) => String(crypto.randomInt(0, 10 ** digits)).padStart(digits, "0");
|
|
1270
1304
|
var issueEmailCode = async ({ purpose, identity, ttlSeconds, digits }) => {
|
|
1271
1305
|
const code = generateNumericCode(digits);
|
|
1272
|
-
await redis3.
|
|
1273
|
-
|
|
1306
|
+
await redis3.eval(
|
|
1307
|
+
ISSUE_CODE_SCRIPT,
|
|
1308
|
+
2,
|
|
1309
|
+
codeKey(purpose, identity),
|
|
1310
|
+
attemptsKey(purpose, identity),
|
|
1311
|
+
hashCode(code),
|
|
1312
|
+
String(ttlSeconds)
|
|
1313
|
+
);
|
|
1274
1314
|
return code;
|
|
1275
1315
|
};
|
|
1276
1316
|
var verifyEmailCode = async ({ purpose, identity, code, maxAttempts }) => {
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
const submitted = Buffer.from(hashCode(code.trim()));
|
|
1290
|
-
const expected = Buffer.from(storedHash);
|
|
1291
|
-
const matches = submitted.length === expected.length && crypto.timingSafeEqual(submitted, expected);
|
|
1292
|
-
if (!matches) return "invalid";
|
|
1293
|
-
const deleted = await redis3.del(key);
|
|
1294
|
-
await redis3.del(counter);
|
|
1295
|
-
return deleted > 0 ? "valid" : "expired";
|
|
1317
|
+
const result = Number(await redis3.eval(
|
|
1318
|
+
VERIFY_CODE_SCRIPT,
|
|
1319
|
+
2,
|
|
1320
|
+
codeKey(purpose, identity),
|
|
1321
|
+
attemptsKey(purpose, identity),
|
|
1322
|
+
hashCode(code.trim()),
|
|
1323
|
+
String(maxAttempts)
|
|
1324
|
+
));
|
|
1325
|
+
if (result === 2) return "valid";
|
|
1326
|
+
if (result === 1) return "invalid";
|
|
1327
|
+
if (result === 3) return "locked";
|
|
1328
|
+
return "expired";
|
|
1296
1329
|
};
|
|
1297
1330
|
var clearEmailCode = async (purpose, identity) => {
|
|
1298
1331
|
await redis3.del(codeKey(purpose, identity));
|
|
@@ -1377,16 +1410,65 @@ var buildOtpauthUri = ({ secret, accountName, issuer }) => {
|
|
|
1377
1410
|
|
|
1378
1411
|
// src/twoFactor.ts
|
|
1379
1412
|
var sha256 = (value) => crypto3.createHash("sha256").update(value).digest("hex");
|
|
1380
|
-
var
|
|
1413
|
+
var LEGACY_ENCRYPTED_PREFIX = "gcm:";
|
|
1414
|
+
var VERSIONED_ENCRYPTED_PREFIX = "enc:v2:";
|
|
1415
|
+
var KEY_ID_LENGTH = 24;
|
|
1381
1416
|
var warnedPlaintextSecret = false;
|
|
1382
|
-
var
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1417
|
+
var warnedInvalidLegacyKeyRing = false;
|
|
1418
|
+
var toEncryptionKey = (raw) => ({
|
|
1419
|
+
id: crypto3.createHash("sha256").update(`luckystack-totp-key-id:${raw}`).digest("hex").slice(0, KEY_ID_LENGTH),
|
|
1420
|
+
key: crypto3.createHash("sha256").update(raw).digest()
|
|
1421
|
+
});
|
|
1422
|
+
var readLegacyEncryptionKeys = () => {
|
|
1423
|
+
const raw = process.env.TOTP_ENCRYPTION_LEGACY_KEYS;
|
|
1424
|
+
if (!raw) return [];
|
|
1425
|
+
try {
|
|
1426
|
+
const parsed = JSON.parse(raw);
|
|
1427
|
+
if (!Array.isArray(parsed)) {
|
|
1428
|
+
throw new TypeError("expected a JSON array of non-empty strings");
|
|
1429
|
+
}
|
|
1430
|
+
const keys = [];
|
|
1431
|
+
for (const value of parsed) {
|
|
1432
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1433
|
+
throw new TypeError("expected a JSON array of non-empty strings");
|
|
1434
|
+
}
|
|
1435
|
+
keys.push(value);
|
|
1436
|
+
}
|
|
1437
|
+
return keys;
|
|
1438
|
+
} catch {
|
|
1439
|
+
if (!warnedInvalidLegacyKeyRing) {
|
|
1440
|
+
warnedInvalidLegacyKeyRing = true;
|
|
1441
|
+
getLogger5().error("[2fa] TOTP_ENCRYPTION_LEGACY_KEYS must be a JSON array of non-empty strings; legacy TOTP rows cannot be decrypted until it is fixed.");
|
|
1442
|
+
}
|
|
1443
|
+
return [];
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
var encryptionKeyRing = () => {
|
|
1447
|
+
const primaryRaw = process.env.TOTP_ENCRYPTION_KEY;
|
|
1448
|
+
const primary = primaryRaw ? toEncryptionKey(primaryRaw) : null;
|
|
1449
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1450
|
+
const all = [];
|
|
1451
|
+
for (const raw of [primaryRaw, ...readLegacyEncryptionKeys()]) {
|
|
1452
|
+
if (!raw) continue;
|
|
1453
|
+
const entry = toEncryptionKey(raw);
|
|
1454
|
+
if (seen.has(entry.id)) continue;
|
|
1455
|
+
seen.add(entry.id);
|
|
1456
|
+
all.push(entry);
|
|
1457
|
+
}
|
|
1458
|
+
return { primary, all };
|
|
1459
|
+
};
|
|
1460
|
+
var decryptWithKey = (key, ivPart, tagPart, dataPart) => {
|
|
1461
|
+
try {
|
|
1462
|
+
const decipher = crypto3.createDecipheriv("aes-256-gcm", key, Buffer.from(ivPart, "base64"));
|
|
1463
|
+
decipher.setAuthTag(Buffer.from(tagPart, "base64"));
|
|
1464
|
+
return Buffer.concat([decipher.update(Buffer.from(dataPart, "base64")), decipher.final()]).toString("utf8");
|
|
1465
|
+
} catch {
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1386
1468
|
};
|
|
1387
1469
|
var encryptTotpSecret = (secret) => {
|
|
1388
|
-
const
|
|
1389
|
-
if (!
|
|
1470
|
+
const { primary } = encryptionKeyRing();
|
|
1471
|
+
if (!primary) {
|
|
1390
1472
|
if (!warnedPlaintextSecret) {
|
|
1391
1473
|
warnedPlaintextSecret = true;
|
|
1392
1474
|
getLogger5().warn("[2fa] TOTP_ENCRYPTION_KEY is not set \u2014 TOTP secrets are stored unencrypted. Set it in .env.local to encrypt new enrollments at rest.");
|
|
@@ -1394,34 +1476,58 @@ var encryptTotpSecret = (secret) => {
|
|
|
1394
1476
|
return secret;
|
|
1395
1477
|
}
|
|
1396
1478
|
const iv = crypto3.randomBytes(12);
|
|
1397
|
-
const cipher = crypto3.createCipheriv("aes-256-gcm", key, iv);
|
|
1479
|
+
const cipher = crypto3.createCipheriv("aes-256-gcm", primary.key, iv);
|
|
1398
1480
|
const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
|
|
1399
|
-
return `${
|
|
1400
|
-
};
|
|
1401
|
-
var
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1481
|
+
return `${VERSIONED_ENCRYPTED_PREFIX}${primary.id}:${iv.toString("base64")}:${cipher.getAuthTag().toString("base64")}:${ciphertext.toString("base64")}`;
|
|
1482
|
+
};
|
|
1483
|
+
var decryptTotpSecretWithMetadata = (stored) => {
|
|
1484
|
+
const ring = encryptionKeyRing();
|
|
1485
|
+
if (!stored.startsWith(LEGACY_ENCRYPTED_PREFIX) && !stored.startsWith("enc:")) {
|
|
1486
|
+
return { secret: stored, needsReencrypt: ring.primary !== null };
|
|
1487
|
+
}
|
|
1488
|
+
if (stored.startsWith(VERSIONED_ENCRYPTED_PREFIX)) {
|
|
1489
|
+
const [keyId, ivPart2, tagPart2, dataPart2] = stored.slice(VERSIONED_ENCRYPTED_PREFIX.length).split(":");
|
|
1490
|
+
if (!keyId || !ivPart2 || !tagPart2 || !dataPart2) return null;
|
|
1491
|
+
const entry = ring.all.find((candidate) => candidate.id === keyId);
|
|
1492
|
+
if (!entry) {
|
|
1493
|
+
getLogger5().error("[2fa] TOTP secret references an unavailable encryption key id. Add the previous key to TOTP_ENCRYPTION_LEGACY_KEYS.");
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
const secret = decryptWithKey(entry.key, ivPart2, tagPart2, dataPart2);
|
|
1497
|
+
if (!secret) {
|
|
1498
|
+
getLogger5().error("[2fa] TOTP secret decryption failed (ciphertext is invalid or the configured key is wrong).");
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
return {
|
|
1502
|
+
secret,
|
|
1503
|
+
needsReencrypt: ring.primary !== null && entry.id !== ring.primary.id
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
if (!stored.startsWith(LEGACY_ENCRYPTED_PREFIX)) {
|
|
1507
|
+
getLogger5().error("[2fa] unsupported TOTP ciphertext version.");
|
|
1406
1508
|
return null;
|
|
1407
1509
|
}
|
|
1408
|
-
const [ivPart, tagPart, dataPart] = stored.slice(
|
|
1409
|
-
if (!ivPart || !tagPart || !dataPart)
|
|
1410
|
-
|
|
1411
|
-
const decipher = crypto3.createDecipheriv("aes-256-gcm", key, Buffer.from(ivPart, "base64"));
|
|
1412
|
-
decipher.setAuthTag(Buffer.from(tagPart, "base64"));
|
|
1413
|
-
return Buffer.concat([decipher.update(Buffer.from(dataPart, "base64")), decipher.final()]).toString("utf8");
|
|
1414
|
-
} catch {
|
|
1415
|
-
getLogger5().error("[2fa] TOTP secret decryption failed (wrong TOTP_ENCRYPTION_KEY?).");
|
|
1510
|
+
const [ivPart, tagPart, dataPart] = stored.slice(LEGACY_ENCRYPTED_PREFIX.length).split(":");
|
|
1511
|
+
if (!ivPart || !tagPart || !dataPart || ring.all.length === 0) {
|
|
1512
|
+
getLogger5().error("[2fa] found a legacy encrypted TOTP secret but no matching primary/legacy key is configured.");
|
|
1416
1513
|
return null;
|
|
1417
1514
|
}
|
|
1515
|
+
for (const entry of ring.all) {
|
|
1516
|
+
const secret = decryptWithKey(entry.key, ivPart, tagPart, dataPart);
|
|
1517
|
+
if (secret) return { secret, needsReencrypt: ring.primary !== null };
|
|
1518
|
+
}
|
|
1519
|
+
getLogger5().error("[2fa] legacy TOTP secret decryption failed with every configured key.");
|
|
1520
|
+
return null;
|
|
1418
1521
|
};
|
|
1522
|
+
var decryptTotpSecret = (stored) => decryptTotpSecretWithMetadata(stored)?.secret ?? null;
|
|
1419
1523
|
var challengeKey = (token) => formatKey5("-2fa-challenge", sha256(token));
|
|
1420
1524
|
var challengeAttemptsKey = (token) => formatKey5("-2fa-challenge-attempts", sha256(token));
|
|
1421
1525
|
var usedTimestepKey = (userId, timestep) => formatKey5("-2fa-usedstep", `${userId}:${String(timestep)}`);
|
|
1422
1526
|
var accountFailureKey = (userId) => formatKey5("-2fa-accountfail", userId);
|
|
1423
1527
|
var RECOVERY_LEASE_MS = 5e3;
|
|
1424
1528
|
var recoveryLeaseName = (userId) => `2fa-recovery:${userId}`;
|
|
1529
|
+
var ENROLLMENT_LEASE_MS = 3e4;
|
|
1530
|
+
var enrollmentLeaseName = (userId) => `2fa-enrollment:${userId}`;
|
|
1425
1531
|
var pendingSecretKey = (userId) => formatKey5("-2fa-pending", userId);
|
|
1426
1532
|
var ACCOUNT_FAIL_LIMIT = 10;
|
|
1427
1533
|
var ACCOUNT_FAIL_WINDOW_SECONDS = 15 * 60;
|
|
@@ -1475,13 +1581,23 @@ var burnChallenge = async (token) => {
|
|
|
1475
1581
|
var verifyTotpForUser = async (user, code, enforceSingleUse) => {
|
|
1476
1582
|
if (!user.totpSecret) return false;
|
|
1477
1583
|
if (!/^\d{6}$/.test(code.trim())) return false;
|
|
1478
|
-
const
|
|
1479
|
-
if (!
|
|
1480
|
-
const result = verifyTotp({ secret, code });
|
|
1584
|
+
const decrypted = decryptTotpSecretWithMetadata(user.totpSecret);
|
|
1585
|
+
if (!decrypted) return false;
|
|
1586
|
+
const result = verifyTotp({ secret: decrypted.secret, code });
|
|
1481
1587
|
if (!result.valid || result.timestep === null) return false;
|
|
1482
|
-
if (
|
|
1483
|
-
|
|
1484
|
-
|
|
1588
|
+
if (enforceSingleUse) {
|
|
1589
|
+
const claimed = await redis4.set(usedTimestepKey(user.id, result.timestep), "1", "EX", 60 * 2, "NX");
|
|
1590
|
+
if (claimed === null) return false;
|
|
1591
|
+
}
|
|
1592
|
+
if (decrypted.needsReencrypt) {
|
|
1593
|
+
const [migrationError] = await tryCatch3(() => getUserAdapter().update(user.id, {
|
|
1594
|
+
totpSecret: encryptTotpSecret(decrypted.secret)
|
|
1595
|
+
}));
|
|
1596
|
+
if (migrationError) {
|
|
1597
|
+
getLogger5().error("[2fa] TOTP encryption-key lazy migration failed; verification succeeded but the existing ciphertext was kept.", migrationError);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
return true;
|
|
1485
1601
|
};
|
|
1486
1602
|
var matchRecoveryHash = (hashes, code) => {
|
|
1487
1603
|
const submitted = Buffer.from(sha256(code.trim().toLowerCase()));
|
|
@@ -1628,24 +1744,30 @@ var generateRecoveryCodes = () => {
|
|
|
1628
1744
|
var confirmTotpEnrollment = async (user, code) => {
|
|
1629
1745
|
const blocked = enrollmentBlockedReason(user);
|
|
1630
1746
|
if (blocked) return { ok: false, reason: blocked };
|
|
1631
|
-
const
|
|
1632
|
-
if (!
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1747
|
+
const leaseToken = await acquireLease(enrollmentLeaseName(user.id), ENROLLMENT_LEASE_MS);
|
|
1748
|
+
if (!leaseToken) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
|
|
1749
|
+
try {
|
|
1750
|
+
const pendingStored = await redis4.get(pendingSecretKey(user.id));
|
|
1751
|
+
if (!pendingStored) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
|
|
1752
|
+
const secret = decryptTotpSecret(pendingStored);
|
|
1753
|
+
if (!secret) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
|
|
1754
|
+
const result = verifyTotp({ secret, code });
|
|
1755
|
+
if (!result.valid) return { ok: false, reason: "login.twoFactorInvalidCode" };
|
|
1756
|
+
const { raw, hashes } = generateRecoveryCodes();
|
|
1757
|
+
const [updateError] = await tryCatch3(() => getUserAdapter().update(user.id, {
|
|
1758
|
+
totpSecret: encryptTotpSecret(secret),
|
|
1759
|
+
twoFactorEnabled: true,
|
|
1760
|
+
recoveryCodes: hashes
|
|
1761
|
+
}));
|
|
1762
|
+
if (updateError) {
|
|
1763
|
+
getLogger5().error("[2fa] enrollment persist failed \u2014 is your UserAdapter/schema missing the 2FA fields? (twoFactorEnabled, totpSecret, recoveryCodes)", updateError);
|
|
1764
|
+
return { ok: false, reason: "login.twoFactorPersistFailed" };
|
|
1765
|
+
}
|
|
1766
|
+
await redis4.del(pendingSecretKey(user.id));
|
|
1767
|
+
return { ok: true, recoveryCodes: raw };
|
|
1768
|
+
} finally {
|
|
1769
|
+
await releaseLease(enrollmentLeaseName(user.id), leaseToken);
|
|
1646
1770
|
}
|
|
1647
|
-
await redis4.del(pendingSecretKey(user.id));
|
|
1648
|
-
return { ok: true, recoveryCodes: raw };
|
|
1649
1771
|
};
|
|
1650
1772
|
var disableTwoFactor = async (user, code) => {
|
|
1651
1773
|
const viaTotp = await verifyTotpForUser(user, code, false);
|