@vsaas/loopback 12.3.1 → 12.5.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/common/models/user.json +6 -0
- package/dist/common/models/user.cjs +99 -11
- package/dist/common/models/user2.cjs +6 -0
- package/dist/lib/loopback.cjs +1 -0
- package/dist/lib/rate-limit/remote.cjs +5 -3
- package/dist/lib/two-factor/service.cjs +115 -4
- package/dist/lib/two-factor/service.d.cts +39 -1
- package/dist/package.cjs +1 -1
- package/docs/application-guide.md +126 -0
- package/package.json +4 -4
package/common/models/user.json
CHANGED
|
@@ -52,6 +52,12 @@
|
|
|
52
52
|
"permission": "ALLOW",
|
|
53
53
|
"property": "verifyTwoFactor"
|
|
54
54
|
},
|
|
55
|
+
{
|
|
56
|
+
"principalType": "ROLE",
|
|
57
|
+
"principalId": "$authenticated",
|
|
58
|
+
"permission": "ALLOW",
|
|
59
|
+
"property": "getTwoFactorStatus"
|
|
60
|
+
},
|
|
55
61
|
{
|
|
56
62
|
"principalType": "ROLE",
|
|
57
63
|
"principalId": "$authenticated",
|
|
@@ -3,6 +3,7 @@ const require_runtime$1 = require("../../_virtual/_rolldown/runtime.cjs");
|
|
|
3
3
|
const require_lib_globalize = require("../../lib/globalize.cjs");
|
|
4
4
|
const require_lib_runtime = require("../../lib/runtime.cjs");
|
|
5
5
|
const require_lib_utils = require("../../lib/utils.cjs");
|
|
6
|
+
const require_lib_two_factor_service = require("../../lib/two-factor/service.cjs");
|
|
6
7
|
//#region src/common/models/user.ts
|
|
7
8
|
var require_user = /* @__PURE__ */ require_runtime$1.__commonJSMin(((exports, module) => {
|
|
8
9
|
const path = require("path");
|
|
@@ -12,6 +13,7 @@ var require_user = /* @__PURE__ */ require_runtime$1.__commonJSMin(((exports, mo
|
|
|
12
13
|
require("fs");
|
|
13
14
|
const isEmail = require("isemail");
|
|
14
15
|
const utils = require_lib_utils;
|
|
16
|
+
const { TWO_FACTOR_ENROLLMENT_SCOPE } = (require_lib_two_factor_service.init_service(), require_runtime$1.__toCommonJS(require_lib_two_factor_service.service_exports));
|
|
15
17
|
const qs = require("querystring");
|
|
16
18
|
const crypto = require("crypto");
|
|
17
19
|
const assert = require("assert");
|
|
@@ -209,6 +211,17 @@ var require_user = /* @__PURE__ */ require_runtime$1.__commonJSMin(((exports, mo
|
|
|
209
211
|
process.nextTick(fn, null, true);
|
|
210
212
|
return fn.promise;
|
|
211
213
|
};
|
|
214
|
+
/**
|
|
215
|
+
* Whether the application requires `user` to use a second factor. Override
|
|
216
|
+
* it to enforce a policy: a user that has not enrolled yet logs in with a
|
|
217
|
+
* session restricted to the enrollment, and cannot disable the factor.
|
|
218
|
+
*/
|
|
219
|
+
User.isTwoFactorRequired = function(user, options, fn) {
|
|
220
|
+
if (typeof options === "function") fn = options;
|
|
221
|
+
fn = fn || utils.createPromiseCallback();
|
|
222
|
+
process.nextTick(fn, null, false);
|
|
223
|
+
return fn.promise;
|
|
224
|
+
};
|
|
212
225
|
User.buildTwoFactorLoginContext = function(credentials, options, fn) {
|
|
213
226
|
if (typeof options === "function") fn = options;
|
|
214
227
|
fn = fn || utils.createPromiseCallback();
|
|
@@ -235,6 +248,61 @@ var require_user = /* @__PURE__ */ require_runtime$1.__commonJSMin(((exports, mo
|
|
|
235
248
|
}
|
|
236
249
|
return this._authenticationSecurity.verifyTwoFactor(verification, options || {}, fn);
|
|
237
250
|
};
|
|
251
|
+
function twoFactorUnavailable(Model, fn) {
|
|
252
|
+
fn = fn || utils.createPromiseCallback();
|
|
253
|
+
const error = /* @__PURE__ */ new Error("Two-factor authentication is not configured");
|
|
254
|
+
error.statusCode = Model.settings.twoFactor == null ? 404 : 503;
|
|
255
|
+
error.code = Model.settings.twoFactor == null ? "TWO_FACTOR_NOT_CONFIGURED" : "AUTHENTICATION_SECURITY_UNAVAILABLE";
|
|
256
|
+
process.nextTick(fn, error);
|
|
257
|
+
return fn.promise;
|
|
258
|
+
}
|
|
259
|
+
User.getTwoFactorStatus = function(userId, options, fn) {
|
|
260
|
+
if (typeof options === "function") {
|
|
261
|
+
fn = options;
|
|
262
|
+
options = void 0;
|
|
263
|
+
}
|
|
264
|
+
if (!this._authenticationSecurityReady || !this._authenticationSecurity) return twoFactorUnavailable(this, fn);
|
|
265
|
+
return this._authenticationSecurity.status(userId, options || {}, fn);
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* DANGER: removes the second factor of user `userId` and closes their
|
|
269
|
+
* sessions, so their account is protected by the password alone until they
|
|
270
|
+
* enroll again. Whoever can call this can strip 2FA from that user.
|
|
271
|
+
*
|
|
272
|
+
* It is deliberately not a remote method: an application that exposes it
|
|
273
|
+
* must declare its own endpoint and grant it to the narrowest roles. As a
|
|
274
|
+
* floor, this method requires an authenticated actor other than the user,
|
|
275
|
+
* who must prove themselves with `request.currentPassword` and, when they
|
|
276
|
+
* use 2FA, `request.totpCode`. It emits `twoFactorReset` with the actor.
|
|
277
|
+
*/
|
|
278
|
+
User.resetTwoFactor = function(userId, request, options, fn) {
|
|
279
|
+
if (typeof options === "function") {
|
|
280
|
+
fn = options;
|
|
281
|
+
options = void 0;
|
|
282
|
+
}
|
|
283
|
+
fn = fn || utils.createPromiseCallback();
|
|
284
|
+
const Model = this;
|
|
285
|
+
const token = options && options.accessToken;
|
|
286
|
+
if (!Model._authenticationSecurityReady || !Model._authenticationSecurity) return twoFactorUnavailable(Model, fn);
|
|
287
|
+
const ActorModel = token && token.principalType && token.principalType !== Model.modelName ? Model.app && Model.app.models[token.principalType] : Model;
|
|
288
|
+
if (!token || token.userId == null || !ActorModel) {
|
|
289
|
+
const error = /* @__PURE__ */ new Error("Authorization required");
|
|
290
|
+
error.statusCode = 401;
|
|
291
|
+
error.code = "AUTHORIZATION_REQUIRED";
|
|
292
|
+
process.nextTick(fn, error);
|
|
293
|
+
return fn.promise;
|
|
294
|
+
}
|
|
295
|
+
if (ActorModel === Model && String(token.userId) === String(userId)) {
|
|
296
|
+
const error = /* @__PURE__ */ new Error("Use disable to change your own factor");
|
|
297
|
+
error.statusCode = 403;
|
|
298
|
+
error.code = "TWO_FACTOR_RESET_SELF";
|
|
299
|
+
process.nextTick(fn, error);
|
|
300
|
+
return fn.promise;
|
|
301
|
+
}
|
|
302
|
+
if (!ActorModel._authenticationSecurityReady || !ActorModel._authenticationSecurity) return twoFactorUnavailable(ActorModel, fn);
|
|
303
|
+
ActorModel._authenticationSecurity.proveActor(token.userId, request || {}, options).then(() => Model._authenticationSecurity.reset(userId, options)).then((result) => fn(null, result), fn);
|
|
304
|
+
return fn.promise;
|
|
305
|
+
};
|
|
238
306
|
for (const method of [
|
|
239
307
|
"beginTwoFactorEnrollment",
|
|
240
308
|
"confirmTwoFactorEnrollment",
|
|
@@ -245,14 +313,7 @@ var require_user = /* @__PURE__ */ require_runtime$1.__commonJSMin(((exports, mo
|
|
|
245
313
|
fn = options;
|
|
246
314
|
options = void 0;
|
|
247
315
|
}
|
|
248
|
-
if (!this._authenticationSecurityReady || !this._authenticationSecurity)
|
|
249
|
-
fn = fn || utils.createPromiseCallback();
|
|
250
|
-
const error = /* @__PURE__ */ new Error("Two-factor authentication is not configured");
|
|
251
|
-
error.statusCode = this.settings.twoFactor == null ? 404 : 503;
|
|
252
|
-
error.code = this.settings.twoFactor == null ? "TWO_FACTOR_NOT_CONFIGURED" : "AUTHENTICATION_SECURITY_UNAVAILABLE";
|
|
253
|
-
process.nextTick(fn, error);
|
|
254
|
-
return fn.promise;
|
|
255
|
-
}
|
|
316
|
+
if (!this._authenticationSecurityReady || !this._authenticationSecurity) return twoFactorUnavailable(this, fn);
|
|
256
317
|
const serviceMethod = {
|
|
257
318
|
beginTwoFactorEnrollment: "beginEnrollment",
|
|
258
319
|
confirmTwoFactorEnrollment: "confirmEnrollment",
|
|
@@ -690,9 +751,33 @@ var require_user = /* @__PURE__ */ require_runtime$1.__commonJSMin(((exports, mo
|
|
|
690
751
|
path: "/two-factor/verify"
|
|
691
752
|
}
|
|
692
753
|
});
|
|
693
|
-
|
|
754
|
+
const enrollmentScopes = ["DEFAULT", TWO_FACTOR_ENROLLMENT_SCOPE];
|
|
755
|
+
UserModel.remoteMethod("getTwoFactorStatus", {
|
|
756
|
+
description: "Two-factor state of the current user.",
|
|
757
|
+
accepts: [{
|
|
758
|
+
arg: "userId",
|
|
759
|
+
type: "any",
|
|
760
|
+
http: getUserIdFromRequestContext
|
|
761
|
+
}, {
|
|
762
|
+
arg: "options",
|
|
763
|
+
type: "object",
|
|
764
|
+
http: buildAuthenticationOptions
|
|
765
|
+
}],
|
|
766
|
+
returns: {
|
|
767
|
+
arg: "status",
|
|
768
|
+
type: "object",
|
|
769
|
+
root: true
|
|
770
|
+
},
|
|
771
|
+
accessScopes: enrollmentScopes,
|
|
772
|
+
http: {
|
|
773
|
+
verb: "get",
|
|
774
|
+
path: "/two-factor/status"
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
function registerTwoFactorManagementMethod(name, path, description, accessScopes) {
|
|
694
778
|
UserModel.remoteMethod(name, {
|
|
695
779
|
description,
|
|
780
|
+
...accessScopes ? { accessScopes } : {},
|
|
696
781
|
accepts: [
|
|
697
782
|
{
|
|
698
783
|
arg: "userId",
|
|
@@ -722,13 +807,14 @@ var require_user = /* @__PURE__ */ require_runtime$1.__commonJSMin(((exports, mo
|
|
|
722
807
|
}
|
|
723
808
|
});
|
|
724
809
|
}
|
|
725
|
-
registerTwoFactorManagementMethod("beginTwoFactorEnrollment", "/two-factor/setup", "Begin two-factor enrollment.");
|
|
726
|
-
registerTwoFactorManagementMethod("confirmTwoFactorEnrollment", "/two-factor/confirm", "Confirm two-factor enrollment.");
|
|
810
|
+
registerTwoFactorManagementMethod("beginTwoFactorEnrollment", "/two-factor/setup", "Begin two-factor enrollment.", enrollmentScopes);
|
|
811
|
+
registerTwoFactorManagementMethod("confirmTwoFactorEnrollment", "/two-factor/confirm", "Confirm two-factor enrollment.", enrollmentScopes);
|
|
727
812
|
registerTwoFactorManagementMethod("regenerateTwoFactorRecoveryCodes", "/two-factor/recovery-codes", "Regenerate two-factor recovery codes.");
|
|
728
813
|
registerTwoFactorManagementMethod("disableTwoFactor", "/two-factor/disable", "Disable two-factor authentication.");
|
|
729
814
|
for (const method of [
|
|
730
815
|
"login",
|
|
731
816
|
"verifyTwoFactor",
|
|
817
|
+
"getTwoFactorStatus",
|
|
732
818
|
"beginTwoFactorEnrollment",
|
|
733
819
|
"confirmTwoFactorEnrollment",
|
|
734
820
|
"regenerateTwoFactorRecoveryCodes",
|
|
@@ -741,6 +827,7 @@ var require_user = /* @__PURE__ */ require_runtime$1.__commonJSMin(((exports, mo
|
|
|
741
827
|
for (const method of [
|
|
742
828
|
"login",
|
|
743
829
|
"verifyTwoFactor",
|
|
830
|
+
"getTwoFactorStatus",
|
|
744
831
|
"beginTwoFactorEnrollment",
|
|
745
832
|
"confirmTwoFactorEnrollment",
|
|
746
833
|
"regenerateTwoFactorRecoveryCodes",
|
|
@@ -765,6 +852,7 @@ var require_user = /* @__PURE__ */ require_runtime$1.__commonJSMin(((exports, mo
|
|
|
765
852
|
},
|
|
766
853
|
description: "Do not supply this argument, it is automatically extracted from request headers."
|
|
767
854
|
}],
|
|
855
|
+
accessScopes: enrollmentScopes,
|
|
768
856
|
http: { verb: "all" }
|
|
769
857
|
});
|
|
770
858
|
UserModel.remoteMethod("prototype.verify", {
|
|
@@ -48,6 +48,12 @@ var require_user = /* @__PURE__ */ require("../../_virtual/_rolldown/runtime.cjs
|
|
|
48
48
|
"permission": "ALLOW",
|
|
49
49
|
"property": "verifyTwoFactor"
|
|
50
50
|
},
|
|
51
|
+
{
|
|
52
|
+
"principalType": "ROLE",
|
|
53
|
+
"principalId": "$authenticated",
|
|
54
|
+
"permission": "ALLOW",
|
|
55
|
+
"property": "getTwoFactorStatus"
|
|
56
|
+
},
|
|
51
57
|
{
|
|
52
58
|
"principalType": "ROLE",
|
|
53
59
|
"principalId": "$authenticated",
|
package/dist/lib/loopback.cjs
CHANGED
|
@@ -311,6 +311,7 @@ var require_loopback = /* @__PURE__ */ require_runtime$1.__commonJSMin(((exports
|
|
|
311
311
|
*/
|
|
312
312
|
require_lib_builtin_models(loopback);
|
|
313
313
|
loopback.DataSource = juggler.DataSource;
|
|
314
|
+
loopback.registerNormalizer = juggler.registerNormalizer;
|
|
314
315
|
function getPackageRoot() {
|
|
315
316
|
return path.join(__dirname, "..", "..");
|
|
316
317
|
}
|
|
@@ -150,9 +150,11 @@ function installRemoteRateLimiting(app) {
|
|
|
150
150
|
});
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
153
|
+
function installForSharedClass(sharedClass) {
|
|
154
|
+
installForModel(sharedClass?.ctor);
|
|
155
|
+
}
|
|
156
|
+
app.on("modelRemoted", installForSharedClass);
|
|
157
|
+
app.on("remoteMethodAdded", installForSharedClass);
|
|
156
158
|
app.installRemoteRateLimits = function() {
|
|
157
159
|
app.models().forEach(installForModel);
|
|
158
160
|
return app;
|
|
@@ -5,6 +5,10 @@ const require_lib_two_factor_crypto = require("./crypto.cjs");
|
|
|
5
5
|
const require_lib_two_factor_totp = require("./totp.cjs");
|
|
6
6
|
const require_lib_two_factor_recovery_codes = require("./recovery-codes.cjs");
|
|
7
7
|
//#region src/lib/two-factor/service.ts
|
|
8
|
+
var service_exports = /* @__PURE__ */ require_runtime.__exportAll({
|
|
9
|
+
TWO_FACTOR_ENROLLMENT_SCOPE: () => TWO_FACTOR_ENROLLMENT_SCOPE,
|
|
10
|
+
TwoFactorService: () => TwoFactorService
|
|
11
|
+
});
|
|
8
12
|
function count(result) {
|
|
9
13
|
return typeof result === "number" ? result : Number(result?.count || 0);
|
|
10
14
|
}
|
|
@@ -30,6 +34,9 @@ function invoke(target, name, args) {
|
|
|
30
34
|
if (result && typeof result.then === "function") result.then((value) => done(null, value), done);
|
|
31
35
|
});
|
|
32
36
|
}
|
|
37
|
+
function hasEnrollmentScope(token) {
|
|
38
|
+
return Array.isArray(token?.scopes) && token.scopes.includes("two-factor-enrollment");
|
|
39
|
+
}
|
|
33
40
|
function includeUser(include) {
|
|
34
41
|
if (Array.isArray(include)) return include.map(String).map((v) => v.toLowerCase()).includes("user");
|
|
35
42
|
return String(include || "").toLowerCase() === "user";
|
|
@@ -40,12 +47,13 @@ function loginError() {
|
|
|
40
47
|
error.code = "LOGIN_FAILED";
|
|
41
48
|
return error;
|
|
42
49
|
}
|
|
43
|
-
var TwoFactorService;
|
|
50
|
+
var TWO_FACTOR_ENROLLMENT_SCOPE, TwoFactorService;
|
|
44
51
|
var init_service = require_runtime.__esmMin((() => {
|
|
45
52
|
require_lib_two_factor_errors.init_errors();
|
|
46
53
|
require_lib_two_factor_crypto.init_crypto();
|
|
47
54
|
require_lib_two_factor_totp.init_totp();
|
|
48
55
|
require_lib_two_factor_recovery_codes.init_recovery_codes();
|
|
56
|
+
TWO_FACTOR_ENROLLMENT_SCOPE = "two-factor-enrollment";
|
|
49
57
|
TwoFactorService = class {
|
|
50
58
|
User;
|
|
51
59
|
config;
|
|
@@ -84,12 +92,31 @@ var init_service = require_runtime.__esmMin((() => {
|
|
|
84
92
|
confirmEnrollment(userId, request, options, callback) {
|
|
85
93
|
return optionalCallback(this.confirmEnrollmentInternal(userId, request || {}, options || {}), callback);
|
|
86
94
|
}
|
|
95
|
+
status(userId, options, callback) {
|
|
96
|
+
return optionalCallback(this.statusInternal(userId, options || {}), callback);
|
|
97
|
+
}
|
|
87
98
|
regenerateRecoveryCodes(userId, request, options, callback) {
|
|
88
99
|
return optionalCallback(this.regenerateRecoveryCodesInternal(userId, request || {}, options || {}), callback);
|
|
89
100
|
}
|
|
90
101
|
disable(userId, request, options, callback) {
|
|
91
102
|
return optionalCallback(this.disableInternal(userId, request || {}, options || {}), callback);
|
|
92
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Removes the second factor of another user, e.g. after the device and the
|
|
106
|
+
* recovery codes were lost. Called through `User.resetTwoFactor` once the
|
|
107
|
+
* actor proved itself (`proveActor`); see its warning.
|
|
108
|
+
*/
|
|
109
|
+
reset(principalId, options, callback) {
|
|
110
|
+
return optionalCallback(this.resetInternal(String(principalId), options || {}), callback);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Step-up for an operation on another user: the actor of `options` signs
|
|
114
|
+
* it with their password, and with their authenticator code when they use
|
|
115
|
+
* a second factor. Recovery codes are not accepted here.
|
|
116
|
+
*/
|
|
117
|
+
proveActor(actorId, request, options, callback) {
|
|
118
|
+
return optionalCallback(this.proveActorInternal(String(actorId), request || {}, options || {}), callback);
|
|
119
|
+
}
|
|
93
120
|
deletePrincipals(principalIds, callback) {
|
|
94
121
|
return optionalCallback(this.deletePrincipalsInternal(principalIds.map(String)), callback);
|
|
95
122
|
}
|
|
@@ -147,6 +174,7 @@ var init_service = require_runtime.__esmMin((() => {
|
|
|
147
174
|
} }) : null;
|
|
148
175
|
if (!credential) {
|
|
149
176
|
if (this.rateLimiter) await this.rateLimiter.reset(observed);
|
|
177
|
+
if (await this.isRequired(user, options)) return this.issueEnrollmentToken(user, normalizedInclude);
|
|
150
178
|
return this.issueAccessToken(user, credentials, normalizedInclude, false);
|
|
151
179
|
}
|
|
152
180
|
const secondFactorObserved = [];
|
|
@@ -268,6 +296,32 @@ var init_service = require_runtime.__esmMin((() => {
|
|
|
268
296
|
if (includeUser(include)) token.__data.user = user;
|
|
269
297
|
return token;
|
|
270
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Issues the session of a user that must enroll before using the API. The
|
|
301
|
+
* token has to come back restricted: an application `createAccessToken`
|
|
302
|
+
* override may swap the scope (e.g. a forced password change), but an
|
|
303
|
+
* unrestricted token would bypass the requirement, so it is revoked.
|
|
304
|
+
*/
|
|
305
|
+
async issueEnrollmentToken(user, include) {
|
|
306
|
+
const config = this.requireConfig(true);
|
|
307
|
+
const token = await invoke(user, "createAccessToken", [{
|
|
308
|
+
ttl: Math.ceil(config.enrollmentTtlMs / 1e3),
|
|
309
|
+
scopes: [TWO_FACTOR_ENROLLMENT_SCOPE]
|
|
310
|
+
}, {}]);
|
|
311
|
+
if (!token?.id) throw require_lib_two_factor_errors.authError("AUTHENTICATION_SECURITY_UNAVAILABLE", 503);
|
|
312
|
+
const scopes = token.scopes;
|
|
313
|
+
if (!Array.isArray(scopes) || !scopes.length || scopes.includes("DEFAULT")) {
|
|
314
|
+
await this.revokeToken(token);
|
|
315
|
+
throw require_lib_two_factor_errors.authError("AUTHENTICATION_SECURITY_UNAVAILABLE", 503);
|
|
316
|
+
}
|
|
317
|
+
if (includeUser(include)) token.__data.user = user;
|
|
318
|
+
return token;
|
|
319
|
+
}
|
|
320
|
+
/** Enrollment can only be demanded while users are able to enroll. */
|
|
321
|
+
async isRequired(user, options) {
|
|
322
|
+
if (this.config?.mode !== "enroll-and-verify") return false;
|
|
323
|
+
return await invoke(this.User, "isTwoFactorRequired", [user, options]) === true;
|
|
324
|
+
}
|
|
271
325
|
async buildTwoFactorTokenOptions(credentials, options) {
|
|
272
326
|
return invoke(this.User, "buildTwoFactorLoginContext", [credentials, options]);
|
|
273
327
|
}
|
|
@@ -607,6 +661,21 @@ var init_service = require_runtime.__esmMin((() => {
|
|
|
607
661
|
throw require_lib_two_factor_errors.authError("AUTHENTICATION_SECURITY_UNAVAILABLE", 503, "Authentication security reconciliation required");
|
|
608
662
|
}
|
|
609
663
|
}
|
|
664
|
+
async statusInternal(userId, options) {
|
|
665
|
+
const config = this.requireConfig();
|
|
666
|
+
const user = await this.authenticatedUser(userId, options);
|
|
667
|
+
const enabled = config.mode !== "disabled" && await this.Credential.count({
|
|
668
|
+
principalType: this.User.modelName,
|
|
669
|
+
principalId: String(userId),
|
|
670
|
+
provider: "totp",
|
|
671
|
+
status: "enabled"
|
|
672
|
+
}) > 0;
|
|
673
|
+
return {
|
|
674
|
+
mode: config.mode,
|
|
675
|
+
enabled,
|
|
676
|
+
required: await this.isRequired(user, options)
|
|
677
|
+
};
|
|
678
|
+
}
|
|
610
679
|
async beginEnrollmentInternal(userId, request, options) {
|
|
611
680
|
const config = this.requireConfig(true);
|
|
612
681
|
const user = await this.authenticatedUser(userId, options);
|
|
@@ -707,7 +776,7 @@ var init_service = require_runtime.__esmMin((() => {
|
|
|
707
776
|
const generated = require_lib_two_factor_recovery_codes.generateRecoveryCodes(config.recoveryCodeCount, config.recoveryCodeBytes);
|
|
708
777
|
await this.persistRecoverySet(credential, generated);
|
|
709
778
|
try {
|
|
710
|
-
await this.revokeSessions(principalId, options, config.sessions.onEnable);
|
|
779
|
+
await this.revokeSessions(principalId, options, hasEnrollmentScope(options.accessToken) ? "revoke-all" : config.sessions.onEnable);
|
|
711
780
|
if (count(await this.Credential.updateAll({
|
|
712
781
|
id: credential.id,
|
|
713
782
|
status: "pending",
|
|
@@ -941,8 +1010,44 @@ var init_service = require_runtime.__esmMin((() => {
|
|
|
941
1010
|
status: "unused"
|
|
942
1011
|
}) !== rows.length) throw new Error("Incomplete recovery code set");
|
|
943
1012
|
}
|
|
1013
|
+
async proveActorInternal(actorId, request, options) {
|
|
1014
|
+
const config = this.requireConfig();
|
|
1015
|
+
const actor = await this.authenticatedUser(actorId, options);
|
|
1016
|
+
const observed = await this.reauthenticateManagement(actor, request.currentPassword);
|
|
1017
|
+
const credential = config.mode === "disabled" ? null : await this.Credential.findOne({ where: {
|
|
1018
|
+
principalType: this.User.modelName,
|
|
1019
|
+
principalId: actorId,
|
|
1020
|
+
provider: "totp",
|
|
1021
|
+
status: "enabled"
|
|
1022
|
+
} });
|
|
1023
|
+
if (credential) {
|
|
1024
|
+
if (!(await this.verifyCredentialFactor(credential, "totp", request.totpCode, `management:${require_lib_two_factor_crypto.randomToken(16)}`)).valid) {
|
|
1025
|
+
await this.consumeManagementFailure(actorId);
|
|
1026
|
+
throw require_lib_two_factor_errors.invalidFactor();
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (this.rateLimiter && observed) await this.rateLimiter.reset([observed]);
|
|
1030
|
+
}
|
|
1031
|
+
async resetInternal(principalId, options) {
|
|
1032
|
+
this.requireConfig();
|
|
1033
|
+
const token = options.accessToken;
|
|
1034
|
+
if (!token || token.userId == null) throw require_lib_two_factor_errors.authError("AUTHORIZATION_REQUIRED", 401, "Authorization required");
|
|
1035
|
+
if (String(token.userId) === principalId && (!("principalType" in token) || token.principalType === this.User.modelName)) throw require_lib_two_factor_errors.authError("TWO_FACTOR_RESET_SELF", 403, "Use disable to change your own factor");
|
|
1036
|
+
if (!await this.User.findById(principalId)) throw require_lib_two_factor_errors.authError("NOT_FOUND", 404, "User not found");
|
|
1037
|
+
await this.revokeSessions(principalId, {}, "revoke-all");
|
|
1038
|
+
await this.destroyFactors([principalId]);
|
|
1039
|
+
this.audit("twoFactorReset", {
|
|
1040
|
+
principalId,
|
|
1041
|
+
actorPrincipalType: token.principalType ?? this.User.modelName,
|
|
1042
|
+
actorPrincipalId: String(token.userId)
|
|
1043
|
+
});
|
|
1044
|
+
return { enabled: false };
|
|
1045
|
+
}
|
|
944
1046
|
async deletePrincipalsInternal(principalIds) {
|
|
945
1047
|
if (!principalIds.length) return;
|
|
1048
|
+
await Promise.all([this.destroyFactors(principalIds), this.rateLimiter ? Promise.all(principalIds.map((principalId) => this.rateLimiter.deletePrincipal(principalId))) : Promise.resolve()]);
|
|
1049
|
+
}
|
|
1050
|
+
async destroyFactors(principalIds) {
|
|
946
1051
|
const credentialIds = (await this.Credential.find({
|
|
947
1052
|
where: {
|
|
948
1053
|
principalType: this.User.modelName,
|
|
@@ -959,8 +1064,7 @@ var init_service = require_runtime.__esmMin((() => {
|
|
|
959
1064
|
this.Credential.destroyAll({
|
|
960
1065
|
principalType: this.User.modelName,
|
|
961
1066
|
principalId: { inq: principalIds }
|
|
962
|
-
})
|
|
963
|
-
this.rateLimiter ? Promise.all(principalIds.map((principalId) => this.rateLimiter.deletePrincipal(principalId))) : Promise.resolve()
|
|
1067
|
+
})
|
|
964
1068
|
]);
|
|
965
1069
|
}
|
|
966
1070
|
async rotateEncryptionInternal(options) {
|
|
@@ -1099,6 +1203,7 @@ var init_service = require_runtime.__esmMin((() => {
|
|
|
1099
1203
|
}));
|
|
1100
1204
|
//#endregion
|
|
1101
1205
|
init_service();
|
|
1206
|
+
exports.TWO_FACTOR_ENROLLMENT_SCOPE = TWO_FACTOR_ENROLLMENT_SCOPE;
|
|
1102
1207
|
exports.TwoFactorService = TwoFactorService;
|
|
1103
1208
|
Object.defineProperty(exports, "init_service", {
|
|
1104
1209
|
enumerable: true,
|
|
@@ -1106,3 +1211,9 @@ Object.defineProperty(exports, "init_service", {
|
|
|
1106
1211
|
return init_service;
|
|
1107
1212
|
}
|
|
1108
1213
|
});
|
|
1214
|
+
Object.defineProperty(exports, "service_exports", {
|
|
1215
|
+
enumerable: true,
|
|
1216
|
+
get: function() {
|
|
1217
|
+
return service_exports;
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { NormalizedTwoFactorConfig } from "./config.cjs";
|
|
2
2
|
import { AuthenticationRateLimiter } from "./rate-limit.cjs";
|
|
3
3
|
//#region src/lib/two-factor/service.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Scope of the access token issued at login when the application requires a
|
|
6
|
+
* second factor (`User.isTwoFactorRequired`) and the user has not enrolled yet.
|
|
7
|
+
* It only reaches the methods that complete the enrollment.
|
|
8
|
+
*/
|
|
9
|
+
declare const TWO_FACTOR_ENROLLMENT_SCOPE = "two-factor-enrollment";
|
|
4
10
|
declare class TwoFactorService {
|
|
5
11
|
private readonly User;
|
|
6
12
|
private readonly config;
|
|
@@ -23,12 +29,31 @@ declare class TwoFactorService {
|
|
|
23
29
|
enabled: boolean;
|
|
24
30
|
recoveryCodes: string[];
|
|
25
31
|
}>;
|
|
32
|
+
status(userId: any, options: any, callback?: Function): Promise<{
|
|
33
|
+
mode: "disabled" | "verify-only" | "enroll-and-verify";
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
required: boolean;
|
|
36
|
+
}>;
|
|
26
37
|
regenerateRecoveryCodes(userId: any, request: any, options: any, callback?: Function): Promise<{
|
|
27
38
|
recoveryCodes: any;
|
|
28
39
|
}>;
|
|
29
40
|
disable(userId: any, request: any, options: any, callback?: Function): Promise<{
|
|
30
41
|
enabled: boolean;
|
|
31
42
|
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Removes the second factor of another user, e.g. after the device and the
|
|
45
|
+
* recovery codes were lost. Called through `User.resetTwoFactor` once the
|
|
46
|
+
* actor proved itself (`proveActor`); see its warning.
|
|
47
|
+
*/
|
|
48
|
+
reset(principalId: any, options: any, callback?: Function): Promise<{
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
}>;
|
|
51
|
+
/**
|
|
52
|
+
* Step-up for an operation on another user: the actor of `options` signs
|
|
53
|
+
* it with their password, and with their authenticator code when they use
|
|
54
|
+
* a second factor. Recovery codes are not accepted here.
|
|
55
|
+
*/
|
|
56
|
+
proveActor(actorId: any, request: any, options: any, callback?: Function): Promise<void>;
|
|
32
57
|
deletePrincipals(principalIds: ReadonlyArray<any>, callback?: Function): Promise<void>;
|
|
33
58
|
rotateEncryption(options?: any, callback?: Function): Promise<{
|
|
34
59
|
credentialsReencrypted: number;
|
|
@@ -45,6 +70,15 @@ declare class TwoFactorService {
|
|
|
45
70
|
private resolveSource;
|
|
46
71
|
private hasPassword;
|
|
47
72
|
private issueAccessToken;
|
|
73
|
+
/**
|
|
74
|
+
* Issues the session of a user that must enroll before using the API. The
|
|
75
|
+
* token has to come back restricted: an application `createAccessToken`
|
|
76
|
+
* override may swap the scope (e.g. a forced password change), but an
|
|
77
|
+
* unrestricted token would bypass the requirement, so it is revoked.
|
|
78
|
+
*/
|
|
79
|
+
private issueEnrollmentToken;
|
|
80
|
+
/** Enrollment can only be demanded while users are able to enroll. */
|
|
81
|
+
private isRequired;
|
|
48
82
|
private buildTwoFactorTokenOptions;
|
|
49
83
|
private secretAad;
|
|
50
84
|
private challengeAad;
|
|
@@ -58,6 +92,7 @@ declare class TwoFactorService {
|
|
|
58
92
|
private confirmRecoveryReservation;
|
|
59
93
|
private releaseRecoveryReservation;
|
|
60
94
|
private compensateChallengeFailure;
|
|
95
|
+
private statusInternal;
|
|
61
96
|
private beginEnrollmentInternal;
|
|
62
97
|
private confirmEnrollmentInternal;
|
|
63
98
|
private regenerateRecoveryCodesInternal;
|
|
@@ -67,7 +102,10 @@ declare class TwoFactorService {
|
|
|
67
102
|
private consumeManagementFailure;
|
|
68
103
|
private enabledCredential;
|
|
69
104
|
private persistRecoverySet;
|
|
105
|
+
private proveActorInternal;
|
|
106
|
+
private resetInternal;
|
|
70
107
|
private deletePrincipalsInternal;
|
|
108
|
+
private destroyFactors;
|
|
71
109
|
private rotateEncryptionInternal;
|
|
72
110
|
private revokeSessions;
|
|
73
111
|
private revokeToken;
|
|
@@ -75,4 +113,4 @@ declare class TwoFactorService {
|
|
|
75
113
|
private audit;
|
|
76
114
|
}
|
|
77
115
|
//#endregion
|
|
78
|
-
export { TwoFactorService };
|
|
116
|
+
export { TWO_FACTOR_ENROLLMENT_SCOPE, TwoFactorService };
|
package/dist/package.cjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
var require_package = /* @__PURE__ */ require("./_virtual/_rolldown/runtime.cjs").__commonJSMin(((exports, module) => {
|
|
3
3
|
module.exports = {
|
|
4
4
|
"name": "@vsaas/loopback",
|
|
5
|
-
"version": "12.
|
|
5
|
+
"version": "12.5.0",
|
|
6
6
|
"description": "Fork of LoopBack 3 framework for building APIs and microservices in Node.js",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"StrongLoop",
|
|
@@ -1031,6 +1031,132 @@ that the validation entrypoint itself remains callback-oriented. In
|
|
|
1031
1031
|
promise-first codebases, persisting with `await` is usually the cleanest way to
|
|
1032
1032
|
run `validatePromise` rules.
|
|
1033
1033
|
|
|
1034
|
+
### Property normalization
|
|
1035
|
+
|
|
1036
|
+
String properties can declare `normalize`, an ordered list of steps applied
|
|
1037
|
+
before validation and persistence. With `trim`, a `required` property rejects
|
|
1038
|
+
`" "` instead of storing it.
|
|
1039
|
+
|
|
1040
|
+
```json
|
|
1041
|
+
"properties": {
|
|
1042
|
+
"email": { "type": "string", "required": true, "normalize": ["trim", "lowercase"] },
|
|
1043
|
+
"name": { "type": "string", "normalize": ["trim", "collapseSpaces", "nfc"] },
|
|
1044
|
+
"nickname": { "type": "string", "normalize": ["trim", "emptyToNull"] },
|
|
1045
|
+
"bio": { "type": "string", "normalize": ["stripHtml", "trim"] },
|
|
1046
|
+
"tags": { "type": ["string"], "normalize": ["trim", "lowercase"] }
|
|
1047
|
+
}
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
Built-in steps:
|
|
1051
|
+
|
|
1052
|
+
| Name | Effect |
|
|
1053
|
+
| ---------------- | --------------------------------------------------------------------- |
|
|
1054
|
+
| `trim` | Remove leading and trailing whitespace |
|
|
1055
|
+
| `lowercase` | Lowercase |
|
|
1056
|
+
| `uppercase` | Uppercase |
|
|
1057
|
+
| `nfc` | Unicode NFC normalization |
|
|
1058
|
+
| `unaccent` | Remove diacritics (`Ñandú` → `Nandu`) |
|
|
1059
|
+
| `collapseSpaces` | Replace whitespace runs, newlines included, with one space |
|
|
1060
|
+
| `emptyToNull` | Store `""` as `null`; place it after `trim` to also catch blanks |
|
|
1061
|
+
| `stripInvisible` | Remove control, zero-width, bidi-override and BOM characters |
|
|
1062
|
+
| `numeric` | Keep digits only |
|
|
1063
|
+
| `alphanumeric` | Keep letters and digits only |
|
|
1064
|
+
| `stripHtml` | Drop tags, comments and `script`/`style` content; keep the plain text |
|
|
1065
|
+
|
|
1066
|
+
`stripHtml` neither decodes nor escapes entities: `Tom & Jerry` stays as is
|
|
1067
|
+
and `a < b` is kept. It is data hygiene, not XSS protection; still escape on
|
|
1068
|
+
output. It keeps no markup, so it is not meant for rich-text fields.
|
|
1069
|
+
|
|
1070
|
+
Register project-specific steps before models are defined, for example at the
|
|
1071
|
+
top of `server/server.js`, and reference them by name. Inline functions also
|
|
1072
|
+
work in programmatic models. A step returning `null` or `undefined` ends the
|
|
1073
|
+
pipeline with that value.
|
|
1074
|
+
|
|
1075
|
+
```js
|
|
1076
|
+
loopback.registerNormalizer('rut', (value) => value.replace(/[.\s]/g, '').toUpperCase());
|
|
1077
|
+
```
|
|
1078
|
+
|
|
1079
|
+
When it runs:
|
|
1080
|
+
|
|
1081
|
+
- On every assignment through the model: `create`, `createAll`, `save`,
|
|
1082
|
+
`patchAttributes`/`updateAttributes`, `updateAttribute`, `replaceById`,
|
|
1083
|
+
`replaceOrCreate`, `patchOrCreate`/`upsert`, `upsertWithWhere`,
|
|
1084
|
+
`findOrCreate`, relation and embedded-model writes, and values set by
|
|
1085
|
+
`before save` hooks. `persist` hooks already see normalized data.
|
|
1086
|
+
- On partial updates, only the properties present in the payload are
|
|
1087
|
+
normalized; other stored values are untouched. `updateAll` normalizes its
|
|
1088
|
+
data, and validates the same values it persists.
|
|
1089
|
+
- On `where` filters, values compared for equality (`eq`, `neq`, `inq`, `nin`
|
|
1090
|
+
and plain values) are normalized like stored values, so
|
|
1091
|
+
`{ email: ' Ana@X.com ' }` matches `ana@x.com`. Pattern and range operators
|
|
1092
|
+
(`like`, `ilike`, `regexp`, `gt`, `between`, …) are left as written.
|
|
1093
|
+
- Not on reads: rows written before a property gained `normalize` are returned
|
|
1094
|
+
as stored. They are normalized the next time they are written through
|
|
1095
|
+
`save()`, so migrate existing data when you add a step that changes it,
|
|
1096
|
+
especially `lowercase`, since equality filters will no longer match the old
|
|
1097
|
+
values.
|
|
1098
|
+
- Not inside extended operators (`$set`, `$push`, …) or custom property
|
|
1099
|
+
setters; both receive the value as written.
|
|
1100
|
+
|
|
1101
|
+
Unknown step names, or `normalize` on a non-string property, throw when the
|
|
1102
|
+
model is defined.
|
|
1103
|
+
|
|
1104
|
+
### Property constraints
|
|
1105
|
+
|
|
1106
|
+
Properties can declare the JSON Schema keywords that the API explorer already
|
|
1107
|
+
publishes, and they are enforced as model validations. They are checked after
|
|
1108
|
+
normalization, so `"normalize": "trim"` with `"maxLength": 128` validates the
|
|
1109
|
+
trimmed value.
|
|
1110
|
+
|
|
1111
|
+
```json
|
|
1112
|
+
"properties": {
|
|
1113
|
+
"name": { "type": "string", "required": true, "maxLength": 128, "normalize": "trim" },
|
|
1114
|
+
"code": { "type": "string", "pattern": "^[A-Z]{3}-[0-9]{4}$" },
|
|
1115
|
+
"contact": { "type": "string", "format": "email" },
|
|
1116
|
+
"operability": { "type": "number", "min": 0, "max": 100 },
|
|
1117
|
+
"mode": { "type": "string", "enum": ["NONE", "CRON"] },
|
|
1118
|
+
"tags": { "type": ["string"], "maxItems": 20, "maxLength": 32 }
|
|
1119
|
+
}
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
| Keyword | Applies to | Error code |
|
|
1123
|
+
| -------------------------------- | ----------------------- | ------------------------------------------- |
|
|
1124
|
+
| `minLength`, `maxLength` | strings | `length.min`, `length.max` |
|
|
1125
|
+
| `pattern` | strings | `format` |
|
|
1126
|
+
| `format` | strings | `format.email`, `format.uri`, `format.uuid` |
|
|
1127
|
+
| `minimum`/`min`, `maximum`/`max` | numbers | `range.min`, `range.max` |
|
|
1128
|
+
| `enum` | string, number, boolean | `inclusion` |
|
|
1129
|
+
| `minItems`, `maxItems` | arrays | `items.min`, `items.max` |
|
|
1130
|
+
|
|
1131
|
+
- Lengths count Unicode characters, so an emoji counts as one.
|
|
1132
|
+
- Bounds are inclusive. `min`/`max` are aliases of `minimum`/`maximum`.
|
|
1133
|
+
- On array properties, `minItems`/`maxItems` apply to the array and the other
|
|
1134
|
+
keywords to each item.
|
|
1135
|
+
- `format` validates `email`, `uri` (absolute) and `uuid`. Other formats, such
|
|
1136
|
+
as `date-time` or `int32`, are documentation only.
|
|
1137
|
+
- Constraints only check present values: `null` and `undefined` pass, and
|
|
1138
|
+
`required` decides whether a value must be present. An empty string is a
|
|
1139
|
+
value; use `"normalize": ["trim", "emptyToNull"]` to treat blanks as absent.
|
|
1140
|
+
- A keyword on a property of the wrong type, or an invalid limit, throws when
|
|
1141
|
+
the model is defined.
|
|
1142
|
+
|
|
1143
|
+
Failures are regular validation errors: the write is rejected with
|
|
1144
|
+
`ValidationError` (HTTP 422) and `error.details.codes` lists the codes above
|
|
1145
|
+
per property.
|
|
1146
|
+
|
|
1147
|
+
Constraints run wherever model validation runs: `create`, `save`,
|
|
1148
|
+
`patchAttributes`, `replaceById`, `replaceOrCreate`, `upsertWithWhere`, and
|
|
1149
|
+
`patchOrCreate` with `validateUpsert: true`. Two writes skip full validation by
|
|
1150
|
+
default because their partial data would fail `required`: `updateAll` (unless
|
|
1151
|
+
`validateUpdate: true`) and `patchOrCreate` (unless `validateUpsert: true`).
|
|
1152
|
+
They still check the constraints of the properties they write. Passing
|
|
1153
|
+
`{ validate: false }`, or setting `validateUpdate`/`validateUpsert` or
|
|
1154
|
+
`automaticValidation` to `false`, skips both.
|
|
1155
|
+
|
|
1156
|
+
Constraints declared on existing models start being enforced when you upgrade.
|
|
1157
|
+
Writes of values outside them, including a `patchAttributes` that leaves an
|
|
1158
|
+
invalid stored value untouched, will be rejected, so fix stored data first.
|
|
1159
|
+
|
|
1034
1160
|
## Optional packages
|
|
1035
1161
|
|
|
1036
1162
|
### `@vsaas/loopback-component-storage`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vsaas/loopback",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.5.0",
|
|
4
4
|
"description": "Fork of LoopBack 3 framework for building APIs and microservices in Node.js",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"StrongLoop",
|
|
@@ -104,9 +104,9 @@
|
|
|
104
104
|
"nodemailer": "^9.1.1",
|
|
105
105
|
"otplib": "13.4.1",
|
|
106
106
|
"serve-favicon": "^2.5.1",
|
|
107
|
-
"@vsaas/
|
|
108
|
-
"@vsaas/loopback-datasource-juggler": "^13.0
|
|
109
|
-
"@vsaas/
|
|
107
|
+
"@vsaas/loopback-connector-remote": "^12.0.2",
|
|
108
|
+
"@vsaas/loopback-datasource-juggler": "^13.1.0",
|
|
109
|
+
"@vsaas/remoting": "^13.0.2"
|
|
110
110
|
},
|
|
111
111
|
"devDependencies": {
|
|
112
112
|
"@types/node": "25.5.0",
|