@cairs-npm/express-auth-2fa 0.1.1 → 0.1.3
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.cjs +67 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -2
- package/dist/index.d.ts +49 -2
- package/dist/index.js +68 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -174,6 +174,23 @@ var configSchema = import_zod.z.object({
|
|
|
174
174
|
rpName: import_zod.z.string().min(1),
|
|
175
175
|
origins: import_zod.z.array(import_zod.z.string().url()).min(1)
|
|
176
176
|
}).optional(),
|
|
177
|
+
/**
|
|
178
|
+
* Allow POST /auth/login/start: sign in with the identifier and a verification method only
|
|
179
|
+
* (no password). Single-factor — enable only for apps that already sign in by code. Pass a
|
|
180
|
+
* function to allow it per request, e.g. only for `type: "badge"`.
|
|
181
|
+
*/
|
|
182
|
+
passwordlessLogin: import_zod.z.union([
|
|
183
|
+
import_zod.z.boolean(),
|
|
184
|
+
import_zod.z.custom(
|
|
185
|
+
(v) => typeof v === "function"
|
|
186
|
+
)
|
|
187
|
+
]).default(false),
|
|
188
|
+
/**
|
|
189
|
+
* Project-wide 2-Step Verification switch. False: password sign-in never asks for a code
|
|
190
|
+
* (whatever each user chose) and the two-step setting can't be turned on. Passwordless
|
|
191
|
+
* sign-in still sends a code — it's the only factor there.
|
|
192
|
+
*/
|
|
193
|
+
twoStepEnabled: import_zod.z.boolean().default(true),
|
|
177
194
|
/** 2-Step Verification state for users who have not chosen one yet. */
|
|
178
195
|
twoStepDefault: import_zod.z.boolean().default(false),
|
|
179
196
|
limits: limitSchema.default({}),
|
|
@@ -636,11 +653,16 @@ var MethodService = class {
|
|
|
636
653
|
const cred = await this.cols.credentials.findOne({ userId });
|
|
637
654
|
const docs = await this.cols.methods.find({ userId, status: "active" }).toArray();
|
|
638
655
|
const methods = docs.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.createdAt.getTime() - b.createdAt.getTime()).map(toApiMethod);
|
|
639
|
-
|
|
656
|
+
const available = this.config.twoStepEnabled;
|
|
657
|
+
const twoStepEnabled = available && (cred?.twoStepEnabled ?? this.config.twoStepDefault);
|
|
658
|
+
return { twoStepEnabled, twoStepAvailable: available, methods };
|
|
640
659
|
}
|
|
641
660
|
/** BR-15 — enable needs ≥1 active method; disable needs step-up + revokes trusted devices. */
|
|
642
661
|
async setTwoStep(userId, sid, enabled, stepUpToken) {
|
|
643
662
|
const now = this.config.clock();
|
|
663
|
+
if (enabled && !this.config.twoStepEnabled) {
|
|
664
|
+
throw apiError(409, "VALIDATION_ERROR", "2-Step Verification is turned off for this application.");
|
|
665
|
+
}
|
|
644
666
|
if (enabled) {
|
|
645
667
|
await ensureEmailMethod(this.config, this.cols, userId);
|
|
646
668
|
const count = await this.cols.methods.countDocuments({ userId, status: "active" });
|
|
@@ -880,18 +902,48 @@ var AuthService = class {
|
|
|
880
902
|
throw apiError(423, "LOCKED", "Too many attempts. Try again later.", { retryAt: cred.lockedUntil });
|
|
881
903
|
}
|
|
882
904
|
await this.resetFailedLogins(cred, now);
|
|
883
|
-
const
|
|
884
|
-
|
|
905
|
+
const twoStep = this.config.twoStepEnabled && cred.twoStepEnabled;
|
|
906
|
+
const trustedOk = twoStep && await this.checkTrustedDevice(req, user.id);
|
|
907
|
+
if (!twoStep || trustedOk) {
|
|
885
908
|
await this.audit.record(user.id, "login_success", ctx);
|
|
886
909
|
return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
|
|
887
910
|
}
|
|
888
|
-
await
|
|
889
|
-
|
|
890
|
-
const active = await this.cols.methods.find({ userId: user.id, status: "active" }).toArray();
|
|
891
|
-
if (active.length === 0) {
|
|
911
|
+
const challenge = await this.issueLoginChallenge(user, ctx, now, false);
|
|
912
|
+
if (!challenge) {
|
|
892
913
|
await this.audit.record(user.id, "login_success", ctx);
|
|
893
914
|
return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
|
|
894
915
|
}
|
|
916
|
+
return challenge;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Passwordless sign-in (config.passwordlessLogin): the identifier alone starts the same
|
|
920
|
+
* verification step as a password sign-in. Always needs a method — there's no password to
|
|
921
|
+
* fall back on.
|
|
922
|
+
*/
|
|
923
|
+
async loginStart(req, input) {
|
|
924
|
+
const allow = this.config.passwordlessLogin;
|
|
925
|
+
const allowed = typeof allow === "function" ? await allow({ username: input.username, type: input.type }) : allow;
|
|
926
|
+
if (!allowed) throw apiError(404, "VALIDATION_ERROR", "Not found.");
|
|
927
|
+
const now = this.config.clock();
|
|
928
|
+
const ctx = ctxOf(req);
|
|
929
|
+
const user = await this.config.userAdapter.findByLogin(input.username, { type: input.type, passwordless: true });
|
|
930
|
+
if (!user) throw apiError(404, "INVALID_CREDENTIALS", "We couldn't find an account with that email.");
|
|
931
|
+
const cred = await this.getCredentials(user.id);
|
|
932
|
+
if (cred?.lockedUntil && cred.lockedUntil.getTime() > now.getTime()) {
|
|
933
|
+
throw apiError(423, "LOCKED", "Too many attempts. Try again later.", { retryAt: cred.lockedUntil });
|
|
934
|
+
}
|
|
935
|
+
const challenge = await this.issueLoginChallenge(user, ctx, now, true, input.type);
|
|
936
|
+
if (!challenge) {
|
|
937
|
+
throw apiError(409, "NO_METHODS", "We can't send you a code: this account has no email or verification method.");
|
|
938
|
+
}
|
|
939
|
+
return challenge;
|
|
940
|
+
}
|
|
941
|
+
/** Creates a login challenge over the user's active methods; null when there are none. */
|
|
942
|
+
async issueLoginChallenge(user, ctx, now, passwordless, clientType) {
|
|
943
|
+
await ensureEmailMethod(this.config, this.cols, user.id, user.email);
|
|
944
|
+
await pruneUnreadableMethods(this.config, this.cols, user.id, this.audit);
|
|
945
|
+
const active = await this.cols.methods.find({ userId: user.id, status: "active" }).toArray();
|
|
946
|
+
if (active.length === 0) return null;
|
|
895
947
|
const challengeId = randomId("chg");
|
|
896
948
|
await this.cols.challenges.insertOne({
|
|
897
949
|
_id: challengeId,
|
|
@@ -902,6 +954,8 @@ var AuthService = class {
|
|
|
902
954
|
smsSentAt: null,
|
|
903
955
|
smsSendCount: 0,
|
|
904
956
|
attempts: 0,
|
|
957
|
+
...passwordless ? { passwordless: true } : {},
|
|
958
|
+
...clientType ? { clientType } : {},
|
|
905
959
|
expiresAt: new Date(now.getTime() + this.config.ttl.challengeSec * 1e3),
|
|
906
960
|
createdAt: now
|
|
907
961
|
});
|
|
@@ -1135,7 +1189,7 @@ var MfaService = class {
|
|
|
1135
1189
|
await this.audit.record(ch.userId, "login_success", ctxOf(req));
|
|
1136
1190
|
const user = await this.config.userAdapter.findById(ch.userId);
|
|
1137
1191
|
const ua = req.headers["user-agent"];
|
|
1138
|
-
const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "otp"], {
|
|
1192
|
+
const result = await this.auth.authenticate(ch.userId, req, res, ch.passwordless ? ["otp"] : ["pwd", "otp"], {
|
|
1139
1193
|
rememberDevice: input.rememberDevice,
|
|
1140
1194
|
deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
|
|
1141
1195
|
});
|
|
@@ -1383,7 +1437,7 @@ var PasskeyService = class {
|
|
|
1383
1437
|
);
|
|
1384
1438
|
await this.audit.record(ch.userId, "login_success", ctxOf(req));
|
|
1385
1439
|
const ua = req.headers["user-agent"];
|
|
1386
|
-
const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "hwk"], {
|
|
1440
|
+
const result = await this.auth.authenticate(ch.userId, req, res, ch.passwordless ? ["hwk"] : ["pwd", "hwk"], {
|
|
1387
1441
|
rememberDevice: input.rememberDevice,
|
|
1388
1442
|
deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
|
|
1389
1443
|
});
|
|
@@ -1573,6 +1627,10 @@ function buildAuthRouter(config, cols, services) {
|
|
|
1573
1627
|
const input = parseBody(import_auth_2fa_contract.loginRequestSchema, req.body);
|
|
1574
1628
|
return services.auth.login(req, res, input);
|
|
1575
1629
|
}));
|
|
1630
|
+
router.post("/auth/login/start", rateLimit(cols, config, "login_ip", loginIpPerMin, 6e4), wrap(async (req) => {
|
|
1631
|
+
const input = parseBody(import_auth_2fa_contract.loginStartRequestSchema, req.body);
|
|
1632
|
+
return services.auth.loginStart(req, input);
|
|
1633
|
+
}));
|
|
1576
1634
|
router.post("/auth/mfa/send", rateLimit(cols, config, "mfa_send_ip", verifyIpPerMin, 6e4), wrap(async (req) => {
|
|
1577
1635
|
const input = parseBody(import_auth_2fa_contract.mfaSendRequestSchema, req.body);
|
|
1578
1636
|
return services.mfa.sendCode(input.challengeId, input.methodId);
|