@spinajs/rbac 2.0.520 → 2.0.522
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/lib/cjs/actions.d.ts +269 -93
- package/lib/cjs/actions.d.ts.map +1 -1
- package/lib/cjs/actions.js +695 -354
- package/lib/cjs/actions.js.map +1 -1
- package/lib/cjs/auth.d.ts.map +1 -1
- package/lib/cjs/auth.js +6 -6
- package/lib/cjs/auth.js.map +1 -1
- package/lib/cjs/cli/ChangeUserPassword.d.ts.map +1 -1
- package/lib/cjs/cli/ChangeUserPassword.js +2 -2
- package/lib/cjs/cli/ChangeUserPassword.js.map +1 -1
- package/lib/cjs/cli/CreateUser.d.ts.map +1 -1
- package/lib/cjs/cli/CreateUser.js +8 -1
- package/lib/cjs/cli/CreateUser.js.map +1 -1
- package/lib/cjs/config/rbac.d.ts +40 -0
- package/lib/cjs/config/rbac.d.ts.map +1 -1
- package/lib/cjs/config/rbac.js +40 -0
- package/lib/cjs/config/rbac.js.map +1 -1
- package/lib/cjs/events/UserCreated.d.ts +2 -1
- package/lib/cjs/events/UserCreated.d.ts.map +1 -1
- package/lib/cjs/events/UserCreated.js +13 -3
- package/lib/cjs/events/UserCreated.js.map +1 -1
- package/lib/cjs/exceptions.d.ts +69 -0
- package/lib/cjs/exceptions.d.ts.map +1 -0
- package/lib/cjs/exceptions.js +86 -0
- package/lib/cjs/exceptions.js.map +1 -0
- package/lib/cjs/index.d.ts +1 -0
- package/lib/cjs/index.d.ts.map +1 -1
- package/lib/cjs/index.js +1 -0
- package/lib/cjs/index.js.map +1 -1
- package/lib/cjs/interfaces.d.ts +2 -7
- package/lib/cjs/interfaces.d.ts.map +1 -1
- package/lib/cjs/interfaces.js +1 -8
- package/lib/cjs/interfaces.js.map +1 -1
- package/lib/cjs/password.d.ts +18 -1
- package/lib/cjs/password.d.ts.map +1 -1
- package/lib/cjs/password.js +61 -5
- package/lib/cjs/password.js.map +1 -1
- package/lib/cjs/profile.js +1 -1
- package/lib/cjs/profile.js.map +1 -1
- package/lib/mjs/actions.d.ts +269 -93
- package/lib/mjs/actions.d.ts.map +1 -1
- package/lib/mjs/actions.js +682 -358
- package/lib/mjs/actions.js.map +1 -1
- package/lib/mjs/auth.d.ts.map +1 -1
- package/lib/mjs/auth.js +7 -7
- package/lib/mjs/auth.js.map +1 -1
- package/lib/mjs/cli/ChangeUserPassword.d.ts.map +1 -1
- package/lib/mjs/cli/ChangeUserPassword.js +3 -3
- package/lib/mjs/cli/ChangeUserPassword.js.map +1 -1
- package/lib/mjs/cli/CreateUser.d.ts.map +1 -1
- package/lib/mjs/cli/CreateUser.js +8 -1
- package/lib/mjs/cli/CreateUser.js.map +1 -1
- package/lib/mjs/config/rbac.d.ts +40 -0
- package/lib/mjs/config/rbac.d.ts.map +1 -1
- package/lib/mjs/config/rbac.js +40 -0
- package/lib/mjs/config/rbac.js.map +1 -1
- package/lib/mjs/events/UserCreated.d.ts +2 -1
- package/lib/mjs/events/UserCreated.d.ts.map +1 -1
- package/lib/mjs/events/UserCreated.js +13 -3
- package/lib/mjs/events/UserCreated.js.map +1 -1
- package/lib/mjs/exceptions.d.ts +69 -0
- package/lib/mjs/exceptions.d.ts.map +1 -0
- package/lib/mjs/exceptions.js +71 -0
- package/lib/mjs/exceptions.js.map +1 -0
- package/lib/mjs/index.d.ts +1 -0
- package/lib/mjs/index.d.ts.map +1 -1
- package/lib/mjs/index.js +1 -0
- package/lib/mjs/index.js.map +1 -1
- package/lib/mjs/interfaces.d.ts +2 -7
- package/lib/mjs/interfaces.d.ts.map +1 -1
- package/lib/mjs/interfaces.js +0 -7
- package/lib/mjs/interfaces.js.map +1 -1
- package/lib/mjs/password.d.ts +18 -1
- package/lib/mjs/password.d.ts.map +1 -1
- package/lib/mjs/password.js +62 -6
- package/lib/mjs/password.js.map +1 -1
- package/lib/mjs/profile.js +2 -2
- package/lib/mjs/profile.js.map +1 -1
- package/lib/tsconfig.cjs.tsbuildinfo +1 -1
- package/lib/tsconfig.mjs.tsbuildinfo +1 -1
- package/package.json +11 -11
package/lib/cjs/actions.js
CHANGED
|
@@ -3,20 +3,31 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
6
|
+
exports.getUser = getUser;
|
|
7
|
+
exports.getUserUnsafe = getUserUnsafe;
|
|
8
|
+
exports.getSystemUser = getSystemUser;
|
|
9
|
+
exports.getUsersByRole = getUsersByRole;
|
|
10
|
+
exports.setUserMeta = setUserMeta;
|
|
11
|
+
exports.getUserMeta = getUserMeta;
|
|
12
|
+
exports.sendUserEmail = sendUserEmail;
|
|
13
|
+
exports.updateUser = updateUser;
|
|
14
|
+
exports.revokeUserSessions = revokeUserSessions;
|
|
7
15
|
exports._get_system_user = _get_system_user;
|
|
8
16
|
exports._get_users_by_role = _get_users_by_role;
|
|
9
17
|
exports._get_user = _get_user;
|
|
18
|
+
exports._user = _user;
|
|
19
|
+
exports._user_unsafe = _user_unsafe;
|
|
10
20
|
exports._set_user_meta = _set_user_meta;
|
|
11
21
|
exports._get_user_meta = _get_user_meta;
|
|
12
22
|
exports._user_email = _user_email;
|
|
13
23
|
exports._user_ev = _user_ev;
|
|
14
24
|
exports._user_update = _user_update;
|
|
15
|
-
exports._user = _user;
|
|
16
|
-
exports._user_unsafe = _user_unsafe;
|
|
17
|
-
exports.revokeUserSessions = revokeUserSessions;
|
|
18
25
|
exports.activate = activate;
|
|
19
26
|
exports.deactivate = deactivate;
|
|
27
|
+
exports.roleList = roleList;
|
|
28
|
+
exports.assertRolesExist = assertRolesExist;
|
|
29
|
+
exports.assertNoProtectedMetadata = assertNoProtectedMetadata;
|
|
30
|
+
exports.assertUserUnique = assertUserUnique;
|
|
20
31
|
exports.create = create;
|
|
21
32
|
exports.deleteUser = deleteUser;
|
|
22
33
|
exports.grant = grant;
|
|
@@ -25,10 +36,16 @@ exports.ban = ban;
|
|
|
25
36
|
exports.unban = unban;
|
|
26
37
|
exports.passwordChangeRequest = passwordChangeRequest;
|
|
27
38
|
exports.confirmPasswordReset = confirmPasswordReset;
|
|
39
|
+
exports.changeUserPassword = changeUserPassword;
|
|
28
40
|
exports.changePassword = changePassword;
|
|
29
41
|
exports.expirePassword = expirePassword;
|
|
42
|
+
exports.notifyPasswordWillExpire = notifyPasswordWillExpire;
|
|
43
|
+
exports.verifyPassword = verifyPassword;
|
|
30
44
|
exports.passwordMatch = passwordMatch;
|
|
31
45
|
exports.login = login;
|
|
46
|
+
exports.assertNotLocked = assertNotLocked;
|
|
47
|
+
exports.clearLoginThrottle = clearLoginThrottle;
|
|
48
|
+
exports.registerFailedLogin = registerFailedLogin;
|
|
32
49
|
const orm_1 = require("@spinajs/orm");
|
|
33
50
|
const util_1 = require("@spinajs/util");
|
|
34
51
|
const lodash_1 = __importDefault(require("lodash"));
|
|
@@ -37,193 +54,149 @@ const queue_1 = require("@spinajs/queue");
|
|
|
37
54
|
const User_js_1 = require("./models/User.js");
|
|
38
55
|
const configuration_1 = require("@spinajs/configuration");
|
|
39
56
|
const index_js_1 = require("./events/index.js");
|
|
57
|
+
const di_1 = require("@spinajs/di");
|
|
58
|
+
const log_1 = require("@spinajs/log");
|
|
40
59
|
const interfaces_js_1 = require("./interfaces.js");
|
|
41
60
|
const luxon_1 = require("luxon");
|
|
42
61
|
const exceptions_1 = require("@spinajs/exceptions");
|
|
62
|
+
const exceptions_js_1 = require("./exceptions.js");
|
|
43
63
|
const crypto_1 = require("crypto");
|
|
44
64
|
const uuid_1 = require("uuid");
|
|
45
65
|
const UserLoginFailed_js_1 = require("./events/UserLoginFailed.js");
|
|
46
66
|
const UserMetadataChange_js_1 = require("./events/UserMetadataChange.js");
|
|
47
67
|
const UserPasswordExpired_js_1 = require("./events/UserPasswordExpired.js");
|
|
48
68
|
const model_token_js_1 = require("./model-token.js");
|
|
49
|
-
var E_CODES;
|
|
50
|
-
(function (E_CODES) {
|
|
51
|
-
E_CODES[E_CODES["E_TOKEN_EXPIRED"] = 0] = "E_TOKEN_EXPIRED";
|
|
52
|
-
E_CODES[E_CODES["E_TOKEN_INVALID"] = 1] = "E_TOKEN_INVALID";
|
|
53
|
-
E_CODES[E_CODES["E_PASSWORD_DOES_NOT_MEET_REQUIREMENTS"] = 2] = "E_PASSWORD_DOES_NOT_MEET_REQUIREMENTS";
|
|
54
|
-
E_CODES[E_CODES["E_USER_NOT_FOUND"] = 3] = "E_USER_NOT_FOUND";
|
|
55
|
-
E_CODES[E_CODES["E_USER_ALREADY_EXISTS"] = 4] = "E_USER_ALREADY_EXISTS";
|
|
56
|
-
E_CODES[E_CODES["E_USER_NOT_ACTIVE"] = 5] = "E_USER_NOT_ACTIVE";
|
|
57
|
-
E_CODES[E_CODES["E_USER_BANNED"] = 6] = "E_USER_BANNED";
|
|
58
|
-
E_CODES[E_CODES["E_METADATA_NOT_FOUND"] = 7] = "E_METADATA_NOT_FOUND";
|
|
59
|
-
E_CODES[E_CODES["E_METADATA_NOT_POPULATED"] = 8] = "E_METADATA_NOT_POPULATED";
|
|
60
|
-
E_CODES[E_CODES["E_EMAIL_NOT_CONFIGURED"] = 9] = "E_EMAIL_NOT_CONFIGURED";
|
|
61
|
-
E_CODES[E_CODES["E_NO_EMAIL_TEMPLATE"] = 10] = "E_NO_EMAIL_TEMPLATE";
|
|
62
|
-
E_CODES[E_CODES["E_NOT_LOGGED"] = 11] = "E_NOT_LOGGED";
|
|
63
|
-
})(E_CODES || (exports.E_CODES = E_CODES = {}));
|
|
64
69
|
/**
|
|
65
70
|
* ===============================================
|
|
66
|
-
*
|
|
71
|
+
* FUNDAMENTALS ( imperative helpers )
|
|
67
72
|
* ===============================================
|
|
68
73
|
*/
|
|
69
74
|
/**
|
|
75
|
+
* Resolves a user by identifier with metadata populated.
|
|
76
|
+
* If a {@link User} instance is passed it is returned as-is; otherwise the user is
|
|
77
|
+
* looked up by id, uuid, email, or login and its metadata relation is populated.
|
|
70
78
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* @returns system user
|
|
79
|
+
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
|
|
74
80
|
*/
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
async function getUser(identifier) {
|
|
82
|
+
const id = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_nil)())(identifier, 'identifier');
|
|
83
|
+
if (id instanceof User_js_1.User) {
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
return (0, model_token_js_1.userModel)().query().whereAnything(id).populate('Metadata').firstOrFail();
|
|
81
87
|
}
|
|
82
88
|
/**
|
|
89
|
+
* Unsafe user retrieval. It does not check for rbac permission, so this
|
|
90
|
+
* function can read ANY user in system. USE IT CAREFULLY
|
|
83
91
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* @param role user role
|
|
87
|
-
* @returns
|
|
92
|
+
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
|
|
88
93
|
*/
|
|
89
|
-
function
|
|
90
|
-
|
|
94
|
+
async function getUserUnsafe(identifier) {
|
|
95
|
+
const id = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_nil)())(identifier, 'identifier');
|
|
96
|
+
if (id instanceof User_js_1.UserBase) {
|
|
97
|
+
return id;
|
|
98
|
+
}
|
|
99
|
+
return User_js_1.UserBase.query().whereAnything(id).populate('Metadata').firstOrFail();
|
|
91
100
|
}
|
|
92
101
|
/**
|
|
102
|
+
* Gets system user account
|
|
93
103
|
*
|
|
94
|
-
*
|
|
104
|
+
* @returns system user
|
|
105
|
+
*/
|
|
106
|
+
async function getSystemUser() {
|
|
107
|
+
const systemRole = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)())((0, configuration_1.cfg)('rbac.systemRole'), 'rbac.systemRole');
|
|
108
|
+
const roleColumn = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)())((0, configuration_1.cfg)('rbac.roleColumn'), 'rbac.roleColumn');
|
|
109
|
+
// base User on purpose: system account must resolve inside scoped request contexts
|
|
110
|
+
return User_js_1.User.query().where(roleColumn, systemRole).firstOrFail();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Gets users by role.
|
|
95
114
|
*
|
|
96
|
-
* @param user
|
|
97
|
-
* @returns
|
|
115
|
+
* @param role user roles
|
|
98
116
|
*/
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
return async () => (0, model_token_js_1.userModel)().where('Uuid', user).firstOrFail();
|
|
102
|
-
}
|
|
103
|
-
if (lodash_1.default.isNumber(user)) {
|
|
104
|
-
return async () => (0, model_token_js_1.userModel)().getOrFail(user);
|
|
105
|
-
}
|
|
106
|
-
return () => Promise.resolve(user);
|
|
117
|
+
async function getUsersByRole(role) {
|
|
118
|
+
return (0, model_token_js_1.userModel)().select().withRole(role);
|
|
107
119
|
}
|
|
108
120
|
/**
|
|
109
121
|
* Sets metadata key-value pairs on a user.
|
|
110
122
|
* Accepts either an array of `{ key, value }` objects or a single metadata key string with a separate value.
|
|
111
123
|
* Emits a {@link UserMetadataChange} event after the metadata is persisted.
|
|
112
124
|
*
|
|
125
|
+
* @param u - user to modify
|
|
113
126
|
* @param meta - metadata key (string) or array of `{ key, value }` entries to set
|
|
114
127
|
* @param value - value to assign when `meta` is a single key string (default: `null`)
|
|
115
|
-
* @returns a function that receives a {@link User} and returns the updated user
|
|
116
128
|
*/
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}));
|
|
128
|
-
return u;
|
|
129
|
-
};
|
|
129
|
+
async function setUserMeta(u, meta, value = null) {
|
|
130
|
+
const mArgs = (0, util_1._check_arg)((0, util_1._non_nil)(new exceptions_js_1.MetadataNotPopulated('User metadata not loaded', { user: u.Uuid })), (0, util_1._to_array)())(meta, 'Metadata');
|
|
131
|
+
mArgs.forEach((m) => {
|
|
132
|
+
lodash_1.default.isString(m) ? (u.Metadata[m] = value) : (u.Metadata[m.key] = m.value);
|
|
133
|
+
});
|
|
134
|
+
await u.Metadata.update();
|
|
135
|
+
// the event carries the resolved entries, not the raw input - a single
|
|
136
|
+
// string key is normalised to a { key, value } pair
|
|
137
|
+
await (0, queue_1.ev)(new UserMetadataChange_js_1.UserMetadataChange(u, mArgs.map((m) => (lodash_1.default.isString(m) ? { key: m, value } : m))));
|
|
138
|
+
return u;
|
|
130
139
|
}
|
|
131
140
|
/**
|
|
132
141
|
* Retrieves a single metadata value from a user by key.
|
|
133
142
|
* Throws if the user's metadata has not been populated or the requested key does not exist.
|
|
134
143
|
*
|
|
144
|
+
* @param u - user to read from
|
|
135
145
|
* @param key - metadata key to retrieve
|
|
136
|
-
* @returns a function that receives a {@link User} and returns the metadata value
|
|
137
146
|
*/
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return u.Metadata[key];
|
|
143
|
-
};
|
|
147
|
+
async function getUserMeta(u, key) {
|
|
148
|
+
(0, util_1._check_arg)((0, util_1._non_nil)(new exceptions_js_1.MetadataNotPopulated('User metadata not loaded', { user: u.Uuid, key })))(u.Metadata, 'Metadata');
|
|
149
|
+
(0, util_1._check_arg)((0, util_1._non_nil)(new exceptions_js_1.MetadataNotFound('Metadata not found in user data', { user: u.Uuid, key })))(u.Metadata[key], `Metadata.${key}`);
|
|
150
|
+
return u.Metadata[key];
|
|
144
151
|
}
|
|
145
152
|
/**
|
|
146
|
-
*
|
|
147
|
-
* Templates are defined in rbac configuration
|
|
153
|
+
* Sends a user notification email. Templates are defined in rbac configuration.
|
|
148
154
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*/
|
|
152
|
-
function _user_email(cfgTemplate) {
|
|
153
|
-
// NOTE: tap semantics - the user flows through, the email send result is
|
|
154
|
-
// deliberately discarded. Actions end with this step and must resolve with
|
|
155
|
-
// the User, not with an EmailSend job.
|
|
156
|
-
return async (u) => {
|
|
157
|
-
await (0, util_1._chain)((0, util_1._use)((0, configuration_1._cfg)('rbac.email.connection', 'default'), 'connection'), (0, util_1._use)((0, configuration_1._cfg)(`rbac.email.${cfgTemplate}`), 'template'), ({ connection, template }) => {
|
|
158
|
-
(0, util_1._check_arg)((0, util_1._non_nil)(new exceptions_1.ErrorCode(E_CODES.E_NO_EMAIL_TEMPLATE, `Email template ${cfgTemplate} not configured. Check rbac.email in config`)))(template, 'template');
|
|
159
|
-
(0, util_1._check_arg)((0, util_1._is_string)((0, util_1._non_empty)(), (0, util_1._max_length)(128)))(template.template, 'email.template');
|
|
160
|
-
(0, util_1._check_arg)((0, util_1._is_string)((0, util_1._non_empty)(), (0, util_1._max_length)(128)))(template.subject, 'email.subject');
|
|
161
|
-
return (template.enabled &&
|
|
162
|
-
(0, email_1._email_deferred)({
|
|
163
|
-
to: [u.Email],
|
|
164
|
-
connection,
|
|
165
|
-
model: u.toJSON(),
|
|
166
|
-
tag: `rbac-user-${cfgTemplate}`,
|
|
167
|
-
template: template.template,
|
|
168
|
-
subject: template.subject,
|
|
169
|
-
}));
|
|
170
|
-
});
|
|
171
|
-
return u;
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Emits a user-related event through the queue service.
|
|
155
|
+
* The email send result is deliberately discarded - actions end with this step
|
|
156
|
+
* and must resolve with the User, not with an EmailSend job.
|
|
176
157
|
*
|
|
177
|
-
* @param
|
|
178
|
-
* @param
|
|
179
|
-
* @
|
|
158
|
+
* @param u - recipient
|
|
159
|
+
* @param cfgTemplate - which `rbac.email.*` entry describes the message
|
|
160
|
+
* @param model - extra template variables merged over the user's own fields.
|
|
161
|
+
* Given as a FUNCTION of the user so a caller can compute them from the row it
|
|
162
|
+
* has just written ( the password-reset token is the case that needs it ).
|
|
163
|
+
* Nothing here is persisted and nothing is logged: whatever it carries goes
|
|
164
|
+
* straight into the rendered message.
|
|
180
165
|
*/
|
|
181
|
-
function
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
166
|
+
async function sendUserEmail(u, cfgTemplate, model) {
|
|
167
|
+
const extra = model ? await model(u) : undefined;
|
|
168
|
+
const connection = (0, configuration_1.cfg)('rbac.email.connection', 'default');
|
|
169
|
+
let template;
|
|
170
|
+
try {
|
|
171
|
+
template = (0, configuration_1.cfg)(`rbac.email.${cfgTemplate}`);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
throw new exceptions_js_1.EmailTemplateNotConfigured(`Email template ${cfgTemplate} not configured. Check rbac.email in config`, undefined, err);
|
|
175
|
+
}
|
|
176
|
+
(0, util_1._check_arg)((0, util_1._is_string)((0, util_1._non_empty)(), (0, util_1._max_length)(128)))(template.template, 'email.template');
|
|
177
|
+
(0, util_1._check_arg)((0, util_1._is_string)((0, util_1._non_empty)(), (0, util_1._max_length)(128)))(template.subject, 'email.subject');
|
|
178
|
+
if (template.enabled) {
|
|
179
|
+
await (0, email_1.emailDeferred)({
|
|
180
|
+
to: [u.Email],
|
|
181
|
+
connection,
|
|
182
|
+
model: { ...u.toJSON(), ...(extra ?? {}) },
|
|
183
|
+
tag: `rbac-user-${cfgTemplate}`,
|
|
184
|
+
template: template.template,
|
|
185
|
+
subject: template.subject,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return u;
|
|
186
189
|
}
|
|
187
190
|
/**
|
|
188
191
|
* Persists partial changes to a user record and emits a {@link UserChanged} event.
|
|
189
192
|
*
|
|
193
|
+
* @param u - user to update
|
|
190
194
|
* @param data - optional partial user fields to merge into the existing record
|
|
191
|
-
* @returns a function that receives a {@link User}, applies the update, and returns the user
|
|
192
195
|
*/
|
|
193
|
-
function
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* Resolves a user by identifier with metadata populated.
|
|
201
|
-
* If a {@link User} instance is passed it is returned as-is; otherwise the user is
|
|
202
|
-
* looked up by id, uuid, email, or login and its metadata relation is populated.
|
|
203
|
-
*
|
|
204
|
-
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
|
|
205
|
-
* @returns a thunk that resolves to the {@link User}
|
|
206
|
-
*/
|
|
207
|
-
function _user(identifier) {
|
|
208
|
-
const id = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_nil)())(identifier, 'identifier');
|
|
209
|
-
if (id instanceof User_js_1.User) {
|
|
210
|
-
return () => Promise.resolve(id);
|
|
211
|
-
}
|
|
212
|
-
return () => (0, model_token_js_1.userModel)().query().whereAnything(id).populate('Metadata').firstOrFail();
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Unsafe user retrieval. It does not chack for rbac permission, to this
|
|
216
|
-
* function can read ANY user in system. USE IT CAREFULLY
|
|
217
|
-
*
|
|
218
|
-
* @param identifier
|
|
219
|
-
* @returns
|
|
220
|
-
*/
|
|
221
|
-
function _user_unsafe(identifier) {
|
|
222
|
-
const id = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_nil)())(identifier, 'identifier');
|
|
223
|
-
if (id instanceof User_js_1.UserBase) {
|
|
224
|
-
return () => Promise.resolve(id);
|
|
225
|
-
}
|
|
226
|
-
return () => User_js_1.UserBase.query().whereAnything(id).populate('Metadata').firstOrFail();
|
|
196
|
+
async function updateUser(u, data) {
|
|
197
|
+
await (0, orm_1.updateModel)(u, data);
|
|
198
|
+
await (0, queue_1.ev)(new index_js_1.UserChanged(u));
|
|
199
|
+
return u;
|
|
227
200
|
}
|
|
228
201
|
/**
|
|
229
202
|
* Destroys every session belonging to a user, on every device.
|
|
@@ -245,18 +218,83 @@ async function revokeUserSessions(user) {
|
|
|
245
218
|
if (!userId) {
|
|
246
219
|
return;
|
|
247
220
|
}
|
|
248
|
-
const provider = await (0, configuration_1.
|
|
221
|
+
const provider = await (0, configuration_1.service)('rbac.session', interfaces_js_1.SessionProvider);
|
|
249
222
|
await provider.deleteByUser(userId);
|
|
250
223
|
}
|
|
251
224
|
/**
|
|
252
|
-
*
|
|
225
|
+
* ===============================================
|
|
226
|
+
* FP WRAPPERS ( kept for compatibility and for use in chains )
|
|
227
|
+
* ===============================================
|
|
228
|
+
*/
|
|
229
|
+
/**
|
|
230
|
+
* Thunk form of {@link getSystemUser}.
|
|
231
|
+
*/
|
|
232
|
+
function _get_system_user() {
|
|
233
|
+
return getSystemUser();
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Thunk form of {@link getUsersByRole}.
|
|
253
237
|
*/
|
|
254
|
-
function
|
|
238
|
+
function _get_users_by_role(role) {
|
|
239
|
+
return () => getUsersByRole(role);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Gets rbac user model by uuid or id, WITHOUT metadata populated.
|
|
243
|
+
*/
|
|
244
|
+
function _get_user(user) {
|
|
245
|
+
if (lodash_1.default.isString(user)) {
|
|
246
|
+
return async () => (0, model_token_js_1.userModel)().where('Uuid', user).firstOrFail();
|
|
247
|
+
}
|
|
248
|
+
if (lodash_1.default.isNumber(user)) {
|
|
249
|
+
return async () => (0, model_token_js_1.userModel)().getOrFail(user);
|
|
250
|
+
}
|
|
251
|
+
return () => Promise.resolve(user);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Thunk form of {@link getUser}.
|
|
255
|
+
*/
|
|
256
|
+
function _user(identifier) {
|
|
257
|
+
return () => getUser(identifier);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Thunk form of {@link getUserUnsafe}.
|
|
261
|
+
*/
|
|
262
|
+
function _user_unsafe(identifier) {
|
|
263
|
+
return () => getUserUnsafe(identifier);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Chain step form of {@link setUserMeta}.
|
|
267
|
+
*/
|
|
268
|
+
function _set_user_meta(meta, value = null) {
|
|
269
|
+
return (u) => setUserMeta(u, meta, value);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Chain step form of {@link getUserMeta}.
|
|
273
|
+
*/
|
|
274
|
+
function _get_user_meta(key) {
|
|
275
|
+
return (u) => getUserMeta(u, key);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Chain step form of {@link sendUserEmail}.
|
|
279
|
+
*/
|
|
280
|
+
function _user_email(cfgTemplate, model) {
|
|
281
|
+
return (u) => sendUserEmail(u, cfgTemplate, model);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Chain step: emits a user-related event and forwards the user.
|
|
285
|
+
*/
|
|
286
|
+
function _user_ev(event, ...args) {
|
|
255
287
|
return async (u) => {
|
|
256
|
-
await
|
|
288
|
+
await (0, queue_1.ev)(new event(u, ...args));
|
|
257
289
|
return u;
|
|
258
290
|
};
|
|
259
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Chain step form of {@link updateUser}.
|
|
294
|
+
*/
|
|
295
|
+
function _user_update(data) {
|
|
296
|
+
return (u) => updateUser(u, data);
|
|
297
|
+
}
|
|
260
298
|
/**
|
|
261
299
|
* ===============================================
|
|
262
300
|
* USER ACTIONS
|
|
@@ -269,7 +307,11 @@ function _revoke_sessions() {
|
|
|
269
307
|
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
|
|
270
308
|
*/
|
|
271
309
|
async function activate(identifier) {
|
|
272
|
-
|
|
310
|
+
const u = await getUser(identifier);
|
|
311
|
+
await updateUser(u, { IsActive: true });
|
|
312
|
+
await (0, queue_1.ev)(new index_js_1.UserActivated(u));
|
|
313
|
+
await sendUserEmail(u, 'activated');
|
|
314
|
+
return u;
|
|
273
315
|
}
|
|
274
316
|
/**
|
|
275
317
|
* Deactivates a user account.
|
|
@@ -278,67 +320,269 @@ async function activate(identifier) {
|
|
|
278
320
|
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
|
|
279
321
|
*/
|
|
280
322
|
async function deactivate(identifier) {
|
|
323
|
+
const u = await getUser(identifier);
|
|
324
|
+
await updateUser(u, { IsActive: false });
|
|
281
325
|
// Sessions go with the account: a deactivated user must stop acting NOW, not
|
|
282
326
|
// whenever their session happens to expire.
|
|
283
|
-
|
|
327
|
+
await revokeUserSessions(u);
|
|
328
|
+
await (0, queue_1.ev)(new index_js_1.UserDeactivated(u));
|
|
329
|
+
await sendUserEmail(u, 'deactivated');
|
|
330
|
+
return u;
|
|
284
331
|
}
|
|
285
332
|
/**
|
|
286
333
|
* Reads a create-middleware list from configuration.
|
|
287
334
|
* An unset or empty `beforeCreate` / `afterCreate` list is a valid
|
|
288
|
-
* "no middleware" result ( `
|
|
335
|
+
* "no middleware" result ( `cfg` accepts empty arrays ).
|
|
289
336
|
*/
|
|
290
|
-
function
|
|
291
|
-
const mw = (0, configuration_1.
|
|
337
|
+
function middlewareList(path) {
|
|
338
|
+
const mw = (0, configuration_1.cfg)(path, []);
|
|
292
339
|
return Array.isArray(mw) ? mw : [];
|
|
293
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Runs the user through each middleware in turn, feeding each one the previous
|
|
343
|
+
* result.
|
|
344
|
+
*/
|
|
345
|
+
async function runCreateMiddleware(u, path) {
|
|
346
|
+
let current = u;
|
|
347
|
+
for (const mw of middlewareList(path)) {
|
|
348
|
+
current = await mw(current);
|
|
349
|
+
}
|
|
350
|
+
return current;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The roles a request denotes, whether it arrives as one name or a list.
|
|
354
|
+
*
|
|
355
|
+
* Trimmed, stripped of blanks and de-duplicated. Order is preserved so a caller
|
|
356
|
+
* that treats the first entry as the primary role keeps that meaning.
|
|
357
|
+
*
|
|
358
|
+
* De-duplication is not cosmetic: every downstream guard is charged per entry,
|
|
359
|
+
* so `['user', ' user ']` costs two checks for one role.
|
|
360
|
+
*
|
|
361
|
+
* @param role - a single role name or a list of them
|
|
362
|
+
*/
|
|
363
|
+
function roleList(role) {
|
|
364
|
+
if (role === undefined || role === null) {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
const wanted = (Array.isArray(role) ? role : [role]).map((r) => String(r ?? '').trim()).filter((r) => r.length > 0);
|
|
368
|
+
return [...new Set(wanted)];
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Refuses a role the application has not configured.
|
|
372
|
+
*
|
|
373
|
+
* A role counts as configured if it either holds grants in the resolved
|
|
374
|
+
* {@link AccessControl} instance or is merely declared in `rbac.roles` - the
|
|
375
|
+
* same definition of "known" `DefaultRoleGuard` (`@spinajs/rbac-http-admin`)
|
|
376
|
+
* already uses for its own route-level check. A role may legitimately be named
|
|
377
|
+
* before it is given any permission, and a narrower definition here would
|
|
378
|
+
* refuse a role the route layer of this same codebase already accepts.
|
|
379
|
+
* `hasRole` resolves roles defined only through `$extend`, so an
|
|
380
|
+
* inheritance-only role such as `system` is recognised.
|
|
381
|
+
*
|
|
382
|
+
* `rbac.requireKnownRole: false` turns the whole check off - see the comment
|
|
383
|
+
* at its first use below.
|
|
384
|
+
*
|
|
385
|
+
* @param roles - role names to check; every unknown name is reported at once
|
|
386
|
+
*/
|
|
387
|
+
function assertRolesExist(roles) {
|
|
388
|
+
// An application whose roles are defined at runtime rather than in static
|
|
389
|
+
// config turns this off wholesale. `rbac-http-admin`'s DefaultRoleGuard has
|
|
390
|
+
// carried the same escape hatch for its own route-level check since before
|
|
391
|
+
// this one existed; a library-level check that could not be turned off would
|
|
392
|
+
// make rbac unusable for those applications.
|
|
393
|
+
if ((0, configuration_1.cfg)('rbac.requireKnownRole', true) === false) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const ac = di_1.DI.get('AccessControl');
|
|
397
|
+
if (!ac) {
|
|
398
|
+
// No grants loaded at all means the application has not configured rbac, not
|
|
399
|
+
// that every role is invalid - refusing here would break bootstrap ordering.
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// "Known" the same way DefaultRoleGuard already means it: holding grants, or
|
|
403
|
+
// merely DECLARED. A role may legitimately be named before it is given any
|
|
404
|
+
// permission, and a narrower definition here would refuse roles the route
|
|
405
|
+
// layer of this same codebase already accepts.
|
|
406
|
+
//
|
|
407
|
+
// Guarded the way DefaultRoleGuard guards the same list: `rbac.roles` may be
|
|
408
|
+
// assembled dynamically, and one malformed entry must not turn every create()
|
|
409
|
+
// in the application into an unhandled TypeError. An entry without a `Name`
|
|
410
|
+
// simply never matches a real role.
|
|
411
|
+
const configured = (0, configuration_1.cfg)('rbac.roles', []);
|
|
412
|
+
const declared = (Array.isArray(configured) ? configured : []).map((r) => r?.Name).filter(Boolean);
|
|
413
|
+
const unknown = roles.filter((r) => !ac.hasRole(r) && !declared.includes(r));
|
|
414
|
+
if (unknown.length > 0) {
|
|
415
|
+
throw new exceptions_1.InvalidArgument(`Role(s) not configured in rbac.grants or rbac.roles: ${unknown.join(', ')}`, 'roles');
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Refuses metadata keys that decide account access.
|
|
420
|
+
*
|
|
421
|
+
* `user:pwd_reset:token` is a bearer credential redeemable at the PUBLIC reset
|
|
422
|
+
* endpoint and `user:2fa:*` is the second factor itself — writing either through
|
|
423
|
+
* a generic key-value merge hands out an account rather than annotating one.
|
|
424
|
+
* Ban and lockout keys are refused for the same reason bans have their own
|
|
425
|
+
* action: written directly they skip the event, the email and the session
|
|
426
|
+
* revocation that make a ban mean something.
|
|
427
|
+
*
|
|
428
|
+
* Lives here rather than in one http controller because the keys it protects are
|
|
429
|
+
* rbac's own, and an account seeded with a known reset token is an account
|
|
430
|
+
* takeover no matter which caller planted it — a CLI, a migration and a route
|
|
431
|
+
* all need the same refusal.
|
|
432
|
+
*
|
|
433
|
+
* @param metadata - the key-value bag a caller wants attached to an account
|
|
434
|
+
*/
|
|
435
|
+
function assertNoProtectedMetadata(metadata) {
|
|
436
|
+
if (!metadata) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const offending = Object.keys(metadata).filter((key) => {
|
|
440
|
+
// A glob reaches the metadata relation's setter as a PATTERN and rewrites
|
|
441
|
+
// every matching entry, so `*` alone would overwrite the whole set —
|
|
442
|
+
// including the protected keys listed above.
|
|
443
|
+
if (key.includes('*') || key.includes('?')) {
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
return User_js_1.USER_SECURITY_METADATA_KEYS.includes(key);
|
|
447
|
+
});
|
|
448
|
+
if (offending.length > 0) {
|
|
449
|
+
throw new exceptions_1.InvalidArgument(`Protected metadata keys cannot be set directly: ${offending.join(', ')}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Refuses a login / email already taken by another account.
|
|
454
|
+
*
|
|
455
|
+
* Exported because uniqueness is not only a creation-time rule: an update that
|
|
456
|
+
* renames an account has to apply exactly the same one, and a second
|
|
457
|
+
* implementation of it would be a second thing to keep in step. `exceptUserId`
|
|
458
|
+
* is what an update passes so an account does not clash with itself.
|
|
459
|
+
*
|
|
460
|
+
* Queries the base {@link User} rather than `userModel()`: uniqueness is GLOBAL,
|
|
461
|
+
* and an application's scoped subclass would hide the clashing row — turning a
|
|
462
|
+
* clean refusal into a driver error on the unique index.
|
|
463
|
+
*
|
|
464
|
+
* Soft-deleted rows are included for the same reason. They still occupy the
|
|
465
|
+
* unique indexes, so ignoring them trades this error for that driver error.
|
|
466
|
+
*
|
|
467
|
+
* The thrown {@link UserAlreadyExists} carries `fields`, naming WHICH of login / email
|
|
468
|
+
* clashed, so an http caller can mark the offending input rather than reporting
|
|
469
|
+
* that something, somewhere, is already in use.
|
|
470
|
+
*
|
|
471
|
+
* @param login - login to check, or undefined to skip the login check
|
|
472
|
+
* @param email - email to check, or undefined to skip the email check
|
|
473
|
+
* @param exceptUserId - id of the account being updated, which may keep its own values
|
|
474
|
+
*/
|
|
475
|
+
async function assertUserUnique(login, email, exceptUserId) {
|
|
476
|
+
const clashes = [];
|
|
477
|
+
if (login) {
|
|
478
|
+
const found = await User_js_1.User.query().withDeleted().where('Login', login).first();
|
|
479
|
+
if (found && found.Id !== exceptUserId) {
|
|
480
|
+
clashes.push('Login');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (email) {
|
|
484
|
+
const found = await User_js_1.User.query().withDeleted().where('Email', email).first();
|
|
485
|
+
if (found && found.Id !== exceptUserId) {
|
|
486
|
+
clashes.push('Email');
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (clashes.length > 0) {
|
|
490
|
+
throw new exceptions_js_1.UserAlreadyExists(`${clashes.join(' and ')} already in use`, { fields: clashes });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
294
493
|
/**
|
|
295
494
|
* Creates a new user account.
|
|
296
495
|
*
|
|
297
|
-
* Validates and normalises inputs,
|
|
298
|
-
*
|
|
299
|
-
*
|
|
496
|
+
* Validates and normalises inputs, refuses a duplicate login / email and
|
|
497
|
+
* protected metadata keys, hashes the password, inserts the user record,
|
|
498
|
+
* optionally sets metadata, runs configured `beforeCreate` / `afterCreate`
|
|
499
|
+
* middleware, emits a {@link UserCreated} event, and sends the "created" email.
|
|
500
|
+
*
|
|
501
|
+
* When no password is given, one is generated AND a password-reset link is
|
|
502
|
+
* mailed to the address. Those two are one decision, not two: a generated
|
|
503
|
+
* password is a secret nobody knows, so an account created without the reset
|
|
504
|
+
* link is an account with no way in at all. Callers that pass a password know
|
|
505
|
+
* it and own delivery themselves, so they get no link — which is what a CLI
|
|
506
|
+
* service account or a fixture wants.
|
|
300
507
|
*
|
|
301
508
|
* @param email - user email address (max 64 chars)
|
|
302
509
|
* @param login - user login name (max 32 chars)
|
|
303
|
-
* @param password - plain-text password; if empty a random one is generated
|
|
304
510
|
* @param roles - array of role names to assign
|
|
305
|
-
* @param
|
|
306
|
-
* @param metadata - optional key-value metadata to attach to the new user
|
|
511
|
+
* @param options - see {@link ICreateUserOptions}
|
|
307
512
|
* @returns an object containing the persisted {@link User} and the plain-text password
|
|
308
513
|
*/
|
|
309
|
-
async function create(email, login,
|
|
310
|
-
const sPassword = await (0, configuration_1.
|
|
514
|
+
async function create(email, login, roles, options) {
|
|
515
|
+
const sPassword = await (0, configuration_1.service)('rbac.password', interfaces_js_1.PasswordProvider);
|
|
516
|
+
// Whether the CALLER supplied a password decides who hands the account to its
|
|
517
|
+
// owner, so it is read before `_default` fills a generated one in and the two
|
|
518
|
+
// cases become indistinguishable.
|
|
519
|
+
const generated = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._default)(''))(options?.password, 'password') === '';
|
|
311
520
|
email = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)(), (0, util_1._is_email)(), (0, util_1._max_length)(64))(email, 'email');
|
|
312
521
|
login = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)(), (0, util_1._max_length)(32))(login, 'login');
|
|
313
|
-
|
|
522
|
+
const roleNames = roleList(roles);
|
|
523
|
+
if (roleNames.length === 0) {
|
|
524
|
+
throw new exceptions_1.InvalidArgument('At least one role must be given', 'roles');
|
|
525
|
+
}
|
|
526
|
+
assertRolesExist(roleNames);
|
|
527
|
+
const password = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._default)(() => sPassword.generate()))(options?.password, 'password');
|
|
528
|
+
// Only the SUPPLIED branch is checked. A generated password is asserted
|
|
529
|
+
// against the same rule inside `generate()`, and re-checking it here would
|
|
530
|
+
// only re-report a configuration fault as a caller mistake.
|
|
531
|
+
if (!generated) {
|
|
532
|
+
const validator = await (0, configuration_1.service)('rbac.password.validation', interfaces_js_1.PasswordValidationProvider);
|
|
533
|
+
if (!validator.check(password)) {
|
|
534
|
+
throw new exceptions_1.InvalidArgument('Password does not meet requirements', 'password');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
314
537
|
const hPassword = await sPassword.hash(password);
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
538
|
+
const metadata = options?.metadata;
|
|
539
|
+
// Ahead of everything else, and ahead of `beforeCreate` in particular: a
|
|
540
|
+
// request that is about to be refused must not first run middleware that
|
|
541
|
+
// writes to another system ( the legacy-user mirror is one ).
|
|
542
|
+
assertNoProtectedMetadata(metadata);
|
|
543
|
+
await assertUserUnique(login, email);
|
|
544
|
+
let u = new User_js_1.User({
|
|
545
|
+
Id: options?.id,
|
|
319
546
|
Email: email,
|
|
320
547
|
Login: login,
|
|
321
548
|
Password: hPassword,
|
|
322
|
-
Role:
|
|
549
|
+
Role: roleNames,
|
|
323
550
|
RegisteredAt: luxon_1.DateTime.now(),
|
|
324
551
|
CreatedAt: luxon_1.DateTime.now(),
|
|
325
552
|
IsActive: false,
|
|
326
553
|
Uuid: (0, uuid_1.v4)(),
|
|
327
|
-
})),
|
|
328
|
-
// run before create middleware
|
|
329
|
-
(u) => (0, util_1._chain)(u, ..._create_middleware('rbac.actions.create.beforeCreate')),
|
|
330
|
-
// insert to db
|
|
331
|
-
(0, orm_1._insert)(), (0, util_1._either)(() => metadata !== undefined, _set_user_meta(metadata ? Object.entries(metadata).map(([key, value]) => ({ key, value })) : []), async (u) => u),
|
|
332
|
-
// run after create middleware
|
|
333
|
-
(u) => (0, util_1._chain)(u, ..._create_middleware('rbac.actions.create.afterCreate')),
|
|
334
|
-
// send event
|
|
335
|
-
_user_ev(index_js_1.UserCreated, (u) => u.toJSON()),
|
|
336
|
-
// send email
|
|
337
|
-
(0, util_1._tap)(_user_email('created')),
|
|
338
|
-
// return user & password - if generated we want to know not hashed password
|
|
339
|
-
(u) => {
|
|
340
|
-
return { User: u, Password: password };
|
|
341
554
|
});
|
|
555
|
+
u = await runCreateMiddleware(u, 'rbac.actions.create.beforeCreate');
|
|
556
|
+
await (0, orm_1.insertModel)(u);
|
|
557
|
+
if (metadata !== undefined) {
|
|
558
|
+
await setUserMeta(u, Object.entries(metadata).map(([key, value]) => ({ key, value })));
|
|
559
|
+
}
|
|
560
|
+
u = await runCreateMiddleware(u, 'rbac.actions.create.afterCreate');
|
|
561
|
+
await (0, queue_1.ev)(new index_js_1.UserCreated(u));
|
|
562
|
+
await sendUserEmail(u, 'created');
|
|
563
|
+
// Hand the account to its owner when nobody else can: the password above was
|
|
564
|
+
// invented here and immediately hashed, so without this the account is
|
|
565
|
+
// unreachable until an administrator remembers a second screen.
|
|
566
|
+
//
|
|
567
|
+
// AFTER the "created" email so the two arrive in the order they are meant to
|
|
568
|
+
// be read, and BY UUID rather than by the instance in hand — the reset writes
|
|
569
|
+
// three metadata entries, and `getUser()` re-reads with `Metadata` populated,
|
|
570
|
+
// which an instance built by `new User(...)` never is. Handing it the
|
|
571
|
+
// instance stored nothing, silently, and left the account with no token.
|
|
572
|
+
//
|
|
573
|
+
// Swallowed on purpose: the account EXISTS by now. Throwing would tell the
|
|
574
|
+
// caller creation failed when it did not, inviting a retry that then fails on
|
|
575
|
+
// the duplicate login. A link that could not be issued can be re-sent.
|
|
576
|
+
if (generated) {
|
|
577
|
+
try {
|
|
578
|
+
await passwordChangeRequest(u.Uuid);
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
di_1.DI.resolve(log_1.Log, ['rbac']).error(err, `Could not issue the initial password reset for ${u.Uuid}. The account exists but its owner has no way in yet.`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// if generated we want to know not hashed password
|
|
585
|
+
return { User: u, Password: password };
|
|
342
586
|
}
|
|
343
587
|
/**
|
|
344
588
|
* Permanently deletes a user from the database.
|
|
@@ -347,13 +591,16 @@ async function create(email, login, password, roles, id, metadata) {
|
|
|
347
591
|
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
|
|
348
592
|
*/
|
|
349
593
|
async function deleteUser(identifier) {
|
|
350
|
-
|
|
594
|
+
const u = await getUser(identifier);
|
|
595
|
+
await u.destroy();
|
|
351
596
|
// Same reason a deactivation revokes: the account may no longer act. A live
|
|
352
597
|
// session outlasting the deletion is worse here than there — the session
|
|
353
598
|
// middleware resolves its user through `isActiveUser()`, which no longer
|
|
354
599
|
// matches a soft-deleted row, so every request from that session dies in
|
|
355
600
|
// the middleware instead of being cleanly logged out.
|
|
356
|
-
|
|
601
|
+
await revokeUserSessions(u);
|
|
602
|
+
await (0, queue_1.ev)(new index_js_1.UserDeleted(u));
|
|
603
|
+
await sendUserEmail(u, 'deleted');
|
|
357
604
|
}
|
|
358
605
|
/**
|
|
359
606
|
* Grants an additional role to a user.
|
|
@@ -365,7 +612,14 @@ async function deleteUser(identifier) {
|
|
|
365
612
|
*/
|
|
366
613
|
async function grant(identifier, role) {
|
|
367
614
|
role = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)())(role, 'role');
|
|
368
|
-
|
|
615
|
+
// A role you cannot create an account with must not be one you can add
|
|
616
|
+
// afterwards - otherwise grant is a way around the creation check.
|
|
617
|
+
assertRolesExist([role]);
|
|
618
|
+
const u = await getUser(identifier);
|
|
619
|
+
u.Role = lodash_1.default.uniq([...u.Role, role]);
|
|
620
|
+
await updateUser(u);
|
|
621
|
+
await (0, queue_1.ev)(new index_js_1.UserRoleGranted(u, role));
|
|
622
|
+
return u;
|
|
369
623
|
}
|
|
370
624
|
/**
|
|
371
625
|
* Revokes a role from a user.
|
|
@@ -377,74 +631,118 @@ async function grant(identifier, role) {
|
|
|
377
631
|
*/
|
|
378
632
|
async function revoke(identifier, role) {
|
|
379
633
|
role = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)())(role, 'role');
|
|
380
|
-
|
|
634
|
+
const u = await getUser(identifier);
|
|
635
|
+
u.Role = u.Role.filter((r) => r !== role);
|
|
636
|
+
await updateUser(u);
|
|
637
|
+
await (0, queue_1.ev)(new index_js_1.UserRoleRevoked(u, role));
|
|
638
|
+
return u;
|
|
381
639
|
}
|
|
382
640
|
/**
|
|
383
|
-
*
|
|
384
|
-
* Bans user for specified time. If duration is 0 user is banned for 24h
|
|
641
|
+
* Bans user for specified time. If duration is not given user is banned for 24h
|
|
385
642
|
*
|
|
386
643
|
* @param identifier user identifier one of : id, uuid, email, login
|
|
387
|
-
* @param reason
|
|
644
|
+
* @param reason reason for ban
|
|
388
645
|
* @param duration duration in seconds
|
|
389
|
-
* @returns
|
|
390
646
|
*/
|
|
391
647
|
async function ban(identifier, reason, duration) {
|
|
392
648
|
duration = (0, util_1._check_arg)((0, util_1._default)(24 * 60 * 60), (0, util_1._is_number)((0, util_1._gt)(0)))(duration, 'duration');
|
|
393
649
|
reason = (0, util_1._check_arg)((0, util_1._default)('NO_REASON'), (0, util_1._max_length)(255))(reason, 'reason');
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
650
|
+
const u = await getUser(identifier);
|
|
651
|
+
// duration-aware: an EXPIRED ban must not block re-banning ( the raw flag
|
|
652
|
+
// stays behind until an explicit unban clears it )
|
|
653
|
+
if (u.IsBanned) {
|
|
654
|
+
throw new exceptions_js_1.UserIsBanned(`User is already banned`, { user: u.Uuid });
|
|
655
|
+
}
|
|
656
|
+
await setUserMeta(u, [
|
|
400
657
|
{ key: User_js_1.USER_COMMON_METADATA.USER_BAN_DURATION, value: duration },
|
|
401
658
|
{ key: User_js_1.USER_COMMON_METADATA.USER_BAN_REASON, value: reason },
|
|
402
659
|
{ key: User_js_1.USER_COMMON_METADATA.USER_BAN_IS_BANNED, value: true },
|
|
403
660
|
{ key: User_js_1.USER_COMMON_METADATA.USER_BAN_START_DATE, value: luxon_1.DateTime.now() },
|
|
404
|
-
])
|
|
661
|
+
]);
|
|
405
662
|
// A ban that leaves the banned user's session alive bans nothing until that
|
|
406
663
|
// session expires — `isActiveUser` does not filter on the ban flag, so the
|
|
407
664
|
// session would keep resolving happily.
|
|
408
|
-
|
|
665
|
+
await revokeUserSessions(u);
|
|
666
|
+
await (0, queue_1.ev)(new index_js_1.UserBanned(u));
|
|
667
|
+
await sendUserEmail(u, 'banned');
|
|
668
|
+
return u;
|
|
409
669
|
}
|
|
410
670
|
/**
|
|
411
|
-
*
|
|
412
671
|
* Unban user
|
|
413
672
|
*
|
|
414
|
-
* @param identifier
|
|
415
|
-
* @returns
|
|
673
|
+
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
|
|
416
674
|
*/
|
|
417
675
|
async function unban(identifier) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
throw new exceptions_1.ErrorCode(E_CODES.E_USER_BANNED, `User is already unbanned`, { user: u });
|
|
423
|
-
}
|
|
424
|
-
}),
|
|
676
|
+
const u = await getUser(identifier);
|
|
677
|
+
if (!u.Metadata[User_js_1.USER_COMMON_METADATA.USER_BAN_IS_BANNED]) {
|
|
678
|
+
throw new exceptions_js_1.UserIsBanned(`User is already unbanned`, { user: u.Uuid });
|
|
679
|
+
}
|
|
425
680
|
// actually remove the ban metadata from the DB. Assigning a regex-like
|
|
426
681
|
// string key never cleared anything; delete() removes each key from store.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
682
|
+
await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_BAN_IS_BANNED);
|
|
683
|
+
await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_BAN_START_DATE);
|
|
684
|
+
await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_BAN_DURATION);
|
|
685
|
+
await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_BAN_REASON);
|
|
686
|
+
await (0, queue_1.ev)(new index_js_1.UserUnbanned(u));
|
|
687
|
+
await sendUserEmail(u, 'unbanned');
|
|
688
|
+
return u;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Builds the link a reset mail sends the user to.
|
|
692
|
+
*
|
|
693
|
+
* `rbac.password.resetUrl` is the application's own redemption page. The token
|
|
694
|
+
* and the address are appended as query parameters because that page has to send
|
|
695
|
+
* both back to `POST /auth/password/reset`, and it has no other way of knowing
|
|
696
|
+
* them. Returns an empty string when no url is configured — the template then
|
|
697
|
+
* renders whatever it does without one, rather than a link to nowhere.
|
|
698
|
+
*/
|
|
699
|
+
function passwordResetUrl(email, token) {
|
|
700
|
+
const base = (0, configuration_1.cfg)('rbac.password.resetUrl', '');
|
|
701
|
+
if (!base) {
|
|
702
|
+
return '';
|
|
703
|
+
}
|
|
704
|
+
const url = new URL(base);
|
|
705
|
+
url.searchParams.set('token', token);
|
|
706
|
+
url.searchParams.set('email', email);
|
|
707
|
+
return url.toString();
|
|
433
708
|
}
|
|
434
709
|
/**
|
|
435
710
|
* Initiates a password-change request for a user.
|
|
436
711
|
* Generates a reset token, stores it along with the current timestamp and configured
|
|
437
|
-
* wait time in the user's metadata,
|
|
712
|
+
* wait time in the user's metadata, emits a {@link UserPasswordChangeRequest} event and
|
|
713
|
+
* sends the `changePassword` mail carrying the token.
|
|
714
|
+
*
|
|
715
|
+
* THE MAIL IS THE POINT. The token is issued into metadata and never returned over HTTP —
|
|
716
|
+
* possession of the mailbox is what authorizes the reset — so an installation that does not
|
|
717
|
+
* deliver it has a reset flow nobody can complete. It used to be the application's job, via
|
|
718
|
+
* the event, and every application that had not written that subscriber silently issued
|
|
719
|
+
* tokens into the void. `rbac.email.changePassword.enabled: false` still turns it off for an
|
|
720
|
+
* application that really does deliver it some other way.
|
|
721
|
+
*
|
|
722
|
+
* The token reaches the template through the model and is NOT logged: it is a bearer
|
|
723
|
+
* credential for `POST /auth/password/reset`.
|
|
438
724
|
*
|
|
439
725
|
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
|
|
440
726
|
*/
|
|
441
727
|
async function passwordChangeRequest(identifier) {
|
|
442
|
-
const pwdWaitTime =
|
|
443
|
-
|
|
728
|
+
const pwdWaitTime = (0, configuration_1.cfg)('rbac.password.passwordResetWaitTime');
|
|
729
|
+
const token = (0, uuid_1.v4)();
|
|
730
|
+
const u = await getUser(identifier);
|
|
731
|
+
await setUserMeta(u, [
|
|
444
732
|
{ key: User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_START_DATE, value: luxon_1.DateTime.now() },
|
|
445
|
-
{ key: User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_TOKEN, value:
|
|
733
|
+
{ key: User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_TOKEN, value: token },
|
|
446
734
|
{ key: User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_WAIT_TIME, value: pwdWaitTime },
|
|
447
|
-
])
|
|
735
|
+
]);
|
|
736
|
+
await (0, queue_1.ev)(new index_js_1.UserPasswordChangeRequest(u));
|
|
737
|
+
await sendUserEmail(u, 'changePassword', (usr) => ({
|
|
738
|
+
Token: token,
|
|
739
|
+
ResetUrl: passwordResetUrl(usr.Email, token),
|
|
740
|
+
// Minutes rather than the raw seconds: a template writes "the link is
|
|
741
|
+
// valid for X minutes", and doing the arithmetic in a handlebars
|
|
742
|
+
// expression is not something every template engine can do.
|
|
743
|
+
ExpiresInMinutes: Math.round(pwdWaitTime / 60),
|
|
744
|
+
}));
|
|
745
|
+
return u;
|
|
448
746
|
}
|
|
449
747
|
/**
|
|
450
748
|
* Confirms a password reset by validating the token and expiration, then changing the password.
|
|
@@ -455,45 +753,46 @@ async function passwordChangeRequest(identifier) {
|
|
|
455
753
|
* @param token - the reset token that was issued by {@link passwordChangeRequest}
|
|
456
754
|
*/
|
|
457
755
|
async function confirmPasswordReset(identifier, newPassword, token) {
|
|
458
|
-
|
|
756
|
+
const u = await getUser(identifier);
|
|
459
757
|
// A reset must not resurrect an account that is banned, deactivated or
|
|
460
758
|
// deleted — otherwise the reset flow is a way around every one of those
|
|
461
|
-
// states. Same
|
|
759
|
+
// states. Same exception family the caller already collapses into one
|
|
462
760
|
// opaque failure, so this does not become an account-state oracle.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
761
|
+
// duration-aware: a user whose ban has expired can log in again, so they
|
|
762
|
+
// must be able to reset their password too
|
|
763
|
+
if (u.IsBanned) {
|
|
764
|
+
throw new exceptions_js_1.UserIsBanned(`Password reset refused: user is banned`, { user: u.Uuid });
|
|
765
|
+
}
|
|
766
|
+
if (!u.IsActive || u.DeletedAt) {
|
|
767
|
+
throw new exceptions_js_1.UserNotActive(`Password reset refused: user is not active`, { user: u.Uuid });
|
|
768
|
+
}
|
|
769
|
+
const dueDate = await getUserMeta(u, User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_START_DATE);
|
|
770
|
+
const waitTime = await getUserMeta(u, User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_WAIT_TIME);
|
|
771
|
+
if (dueDate.plus({ seconds: waitTime }) < luxon_1.DateTime.now()) {
|
|
772
|
+
throw new exceptions_js_1.TokenExpired(`Password change token expired, token expiration date is: ${dueDate.toISO()}`, {
|
|
773
|
+
dueDate,
|
|
774
|
+
waitTime,
|
|
775
|
+
time: luxon_1.DateTime.now(),
|
|
776
|
+
user: u.Uuid,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
const resetToken = await getUserMeta(u, User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_TOKEN);
|
|
780
|
+
if (!secureCompare(String(resetToken), token)) {
|
|
781
|
+
// the STORED token is a live bearer credential and the submitted one may
|
|
782
|
+
// be a near miss of it - neither belongs in a payload that gets logged
|
|
783
|
+
throw new exceptions_js_1.TokenInvalid(`Password change token invalid, operation not permitted`, {
|
|
784
|
+
user: u.Uuid,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
await changeUserPassword(u, newPassword);
|
|
488
788
|
// Burn the token. Validating it and leaving it in place made it a
|
|
489
789
|
// multi-use credential for the whole `passwordResetWaitTime` window:
|
|
490
790
|
// anyone who saw the reset mail once could keep re-taking the account.
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}));
|
|
791
|
+
await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_TOKEN);
|
|
792
|
+
await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_START_DATE);
|
|
793
|
+
await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_WAIT_TIME);
|
|
794
|
+
await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET);
|
|
795
|
+
return u;
|
|
497
796
|
}
|
|
498
797
|
/**
|
|
499
798
|
* Length-independent, constant-time string comparison for secrets.
|
|
@@ -505,7 +804,7 @@ async function confirmPasswordReset(identifier, newPassword, token) {
|
|
|
505
804
|
* @param a - value read from storage
|
|
506
805
|
* @param b - value supplied by the caller
|
|
507
806
|
*/
|
|
508
|
-
function
|
|
807
|
+
function secureCompare(a, b) {
|
|
509
808
|
const ha = (0, crypto_1.createHash)('sha256')
|
|
510
809
|
.update(a ?? '')
|
|
511
810
|
.digest();
|
|
@@ -517,63 +816,106 @@ function _secure_compare(a, b) {
|
|
|
517
816
|
return (0, crypto_1.timingSafeEqual)(ha, hb);
|
|
518
817
|
}
|
|
519
818
|
/**
|
|
520
|
-
*
|
|
819
|
+
* Changes a user's password.
|
|
521
820
|
* The new password is validated against the configured {@link PasswordValidationProvider},
|
|
522
821
|
* hashed via the configured {@link PasswordProvider}, persisted, and a
|
|
523
822
|
* {@link UserPasswordChanged} event is emitted.
|
|
524
823
|
*
|
|
824
|
+
* @param u - user to change the password for
|
|
525
825
|
* @param password - new plain-text password
|
|
526
|
-
* @returns a function that receives a {@link User} and returns the updated user
|
|
527
826
|
*/
|
|
528
|
-
function
|
|
827
|
+
async function changeUserPassword(u, password) {
|
|
529
828
|
password = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)())(password, 'password');
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
829
|
+
const pwd = await (0, configuration_1.service)('rbac.password', interfaces_js_1.PasswordProvider);
|
|
830
|
+
const validator = await (0, configuration_1.service)('rbac.password.validation', interfaces_js_1.PasswordValidationProvider);
|
|
831
|
+
if (!validator.check(password)) {
|
|
832
|
+
// `InvalidArgument`, not a bare `Error`: a password the caller typed is
|
|
833
|
+
// invalid INPUT, and @spinajs/http maps this class to 400 ( BadRequestResponse
|
|
834
|
+
// via `@HandleException` ) while an unmapped error becomes a 500. Every route
|
|
835
|
+
// that lets a user pick a password - `PATCH /user/password`, the reset flow -
|
|
836
|
+
// answered "internal server error" for a password that was merely too weak,
|
|
837
|
+
// which reads to the user as a broken screen rather than as a rule they can
|
|
838
|
+
// satisfy. The field name and error code travel in the response body ( the
|
|
839
|
+
// error handler spreads the exception's own enumerable props ), so a client
|
|
840
|
+
// can point at the field and branch on the code instead of matching English.
|
|
841
|
+
throw new exceptions_1.InvalidArgument('Password does not meet requirements', 'password', 'E_PASSWORD_DOES_NOT_MEET_REQUIREMENTS');
|
|
842
|
+
}
|
|
843
|
+
const hPassword = await pwd.hash(password);
|
|
844
|
+
await (0, orm_1.updateModel)(u, { Password: hPassword });
|
|
845
|
+
await setUserMeta(u, [
|
|
846
|
+
{ key: User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_LAST_ATTEMPT, value: luxon_1.DateTime.now().toISO() },
|
|
847
|
+
// a successful password change clears the login throttle: the
|
|
848
|
+
// credential the failures were counted against no longer exists
|
|
849
|
+
{ key: User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS, value: 0 },
|
|
850
|
+
{ key: User_js_1.USER_COMMON_METADATA.USER_LOGIN_LOCKED_UNTIL, value: null },
|
|
851
|
+
]);
|
|
852
|
+
// Every session was authorized by the OLD password. Whoever holds one
|
|
853
|
+
// — including whoever the user is changing the password because of —
|
|
854
|
+
// loses it here. Callers that want the acting user to stay logged in
|
|
855
|
+
// ( eg. PATCH /user/password ) mint a fresh session afterwards.
|
|
856
|
+
await revokeUserSessions(u);
|
|
857
|
+
await (0, queue_1.ev)(new index_js_1.UserPasswordChanged(u));
|
|
858
|
+
return u;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Chain step form of {@link changeUserPassword}.
|
|
862
|
+
*
|
|
863
|
+
* @param password - new plain-text password
|
|
864
|
+
*/
|
|
865
|
+
function changePassword(password) {
|
|
866
|
+
return (u) => changeUserPassword(u, password);
|
|
550
867
|
}
|
|
551
868
|
/**
|
|
869
|
+
* Expire password for user.
|
|
552
870
|
*
|
|
553
|
-
*
|
|
871
|
+
* The stored credential is replaced with a freshly generated random one, so the
|
|
872
|
+
* expired password stops working even if the account is re-activated without a
|
|
873
|
+
* reset. The account is deactivated, {@link UserPasswordExpired} is emitted and
|
|
874
|
+
* the 'passwordExpired' mail is sent.
|
|
554
875
|
*
|
|
555
|
-
* @param identifier
|
|
876
|
+
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
|
|
556
877
|
*/
|
|
557
878
|
async function expirePassword(identifier) {
|
|
558
|
-
|
|
879
|
+
const u = await getUser(identifier);
|
|
880
|
+
const sPassword = await (0, configuration_1.service)('rbac.password', interfaces_js_1.PasswordProvider);
|
|
881
|
+
const hPassword = await sPassword.hash(sPassword.generate());
|
|
882
|
+
await (0, orm_1.updateModel)(u, { Password: hPassword });
|
|
883
|
+
await deactivate(u);
|
|
884
|
+
await (0, queue_1.ev)(new UserPasswordExpired_js_1.UserPasswordExpired(u));
|
|
885
|
+
await sendUserEmail(u, 'passwordExpired');
|
|
559
886
|
}
|
|
560
887
|
/**
|
|
561
|
-
*
|
|
888
|
+
* Sends the 'passwordWillExpire' warning mail. No account state changes -
|
|
889
|
+
* this is the notification half of the expiry flow, meant to be called by an
|
|
890
|
+
* application scheduler ahead of {@link expirePassword}.
|
|
562
891
|
*
|
|
563
|
-
* @param identifier
|
|
564
|
-
* @param password
|
|
565
|
-
* @returns
|
|
892
|
+
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
|
|
893
|
+
* @param expiresAt - optional instant the password expires, passed to the template
|
|
566
894
|
*/
|
|
567
|
-
function
|
|
895
|
+
async function notifyPasswordWillExpire(identifier, expiresAt) {
|
|
896
|
+
const u = await getUser(identifier);
|
|
897
|
+
await sendUserEmail(u, 'passwordWillExpire', () => ({ ExpiresAt: expiresAt?.toISO() ?? null }));
|
|
898
|
+
return u;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Checks if password matches the user's password stored in db.
|
|
902
|
+
*
|
|
903
|
+
* @param u - user to check against
|
|
904
|
+
* @param password - plain-text password to verify
|
|
905
|
+
*/
|
|
906
|
+
async function verifyPassword(u, password) {
|
|
568
907
|
password = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)())(password, 'password');
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
908
|
+
const sPwd = await (0, configuration_1.service)('rbac.password', interfaces_js_1.PasswordProvider);
|
|
909
|
+
return sPwd.verify(u.Password, password);
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Chain step form of {@link verifyPassword}. The user is taken from the step
|
|
913
|
+
* argument - a regression once read it from a never-passed second parameter.
|
|
914
|
+
*
|
|
915
|
+
* @param password - plain-text password to verify
|
|
916
|
+
*/
|
|
917
|
+
function passwordMatch(password) {
|
|
918
|
+
return (u) => verifyPassword(u, password);
|
|
577
919
|
}
|
|
578
920
|
/**
|
|
579
921
|
* Authenticates a user with the given password.
|
|
@@ -587,63 +929,67 @@ function passwordMatch(password) {
|
|
|
587
929
|
*/
|
|
588
930
|
async function login(identifier, password) {
|
|
589
931
|
password = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)())(password, 'password');
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
932
|
+
// A lookup failure ( unknown account ) is NOT counted as a failed login -
|
|
933
|
+
// there is no account to count it against.
|
|
934
|
+
const u = await loginLookup(identifier);
|
|
935
|
+
try {
|
|
936
|
+
// Refuse before the password is even checked, so a locked account
|
|
937
|
+
// cannot be probed at all during the lockout window.
|
|
938
|
+
assertNotLocked(u);
|
|
939
|
+
const sAuth = await (0, configuration_1.service)('rbac.auth', interfaces_js_1.AuthProvider);
|
|
940
|
+
// the authenticated user is a FRESH row read by the auth provider ( with
|
|
941
|
+
// metadata populated ) - every step below acts on it, not on the lookup
|
|
942
|
+
const authenticated = await sAuth.authenticate(u.Email, password);
|
|
943
|
+
await (0, orm_1.updateModel)(authenticated, { LastLoginAt: luxon_1.DateTime.now() });
|
|
944
|
+
await clearLoginThrottle(authenticated);
|
|
945
|
+
await (0, queue_1.ev)(new index_js_1.UserLogged(authenticated));
|
|
946
|
+
return authenticated;
|
|
947
|
+
}
|
|
948
|
+
catch (err) {
|
|
599
949
|
// count the failure and lock the account once the configured
|
|
600
|
-
// threshold is reached
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
() => {
|
|
606
|
-
throw err;
|
|
607
|
-
});
|
|
608
|
-
}));
|
|
950
|
+
// threshold is reached, then notify and rethrow for the caller
|
|
951
|
+
await registerFailedLogin(u, err);
|
|
952
|
+
await (0, queue_1.ev)(new UserLoginFailed_js_1.UserLoginFailed(u, err));
|
|
953
|
+
throw err;
|
|
954
|
+
}
|
|
609
955
|
}
|
|
610
956
|
/**
|
|
611
957
|
* Resolves the user a login attempt names, answering an authentication failure
|
|
612
958
|
* rather than an orm one when no such account exists.
|
|
613
959
|
*
|
|
614
|
-
* {@link
|
|
615
|
-
* neither
|
|
960
|
+
* {@link getUserUnsafe} ends in `firstOrFail()`, whose `OrmNotFoundException` is
|
|
961
|
+
* neither a rbac exception nor `InvalidArgument`: the login controller cannot read it
|
|
616
962
|
* as an authentication failure, so it rethrows and `@spinajs/orm-http` maps it to
|
|
617
963
|
* a 404 while a wrong password answers 401. That difference is an
|
|
618
964
|
* account-enumeration oracle — the status code alone tells a caller whether an
|
|
619
|
-
* address is registered. Both cases
|
|
965
|
+
* address is registered. Both cases throw {@link InvalidCredentials}, exactly as
|
|
620
966
|
* {@link SimpleDbAuthProvider.authenticate} already does for the password it
|
|
621
967
|
* cannot verify.
|
|
622
968
|
*
|
|
623
969
|
* @param identifier - numeric id, uuid / email / login string, or an existing {@link User}
|
|
624
970
|
*/
|
|
625
|
-
function
|
|
971
|
+
async function loginLookup(identifier) {
|
|
626
972
|
const id = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_nil)())(identifier, 'identifier');
|
|
627
973
|
if (id instanceof User_js_1.UserBase) {
|
|
628
|
-
return
|
|
974
|
+
return id;
|
|
629
975
|
}
|
|
630
|
-
return
|
|
976
|
+
return User_js_1.UserBase.query().whereAnything(id).populate('Metadata').firstOrThrow(new exceptions_js_1.InvalidCredentials('no user with given email'));
|
|
631
977
|
}
|
|
632
978
|
/**
|
|
633
979
|
* Throws when the account is inside a lockout window opened by
|
|
634
|
-
* {@link
|
|
980
|
+
* {@link registerFailedLogin}.
|
|
635
981
|
*
|
|
636
982
|
* @param u - user attempting to authenticate
|
|
637
983
|
*/
|
|
638
|
-
|
|
984
|
+
function assertNotLocked(u) {
|
|
639
985
|
const raw = u?.Metadata?.[User_js_1.USER_COMMON_METADATA.USER_LOGIN_LOCKED_UNTIL];
|
|
640
986
|
if (!raw) {
|
|
641
987
|
return;
|
|
642
988
|
}
|
|
643
989
|
const lockedUntil = raw instanceof luxon_1.DateTime ? raw : luxon_1.DateTime.fromISO(String(raw));
|
|
644
990
|
if (lockedUntil.isValid && lockedUntil > luxon_1.DateTime.now()) {
|
|
645
|
-
throw new
|
|
646
|
-
user: u,
|
|
991
|
+
throw new exceptions_js_1.LoginAttemptsExceeded(`Too many failed login attempts, account is temporarily locked until ${lockedUntil.toISO()}`, {
|
|
992
|
+
user: u.Uuid,
|
|
647
993
|
lockedUntil,
|
|
648
994
|
});
|
|
649
995
|
}
|
|
@@ -651,21 +997,18 @@ async function _assert_not_locked(u) {
|
|
|
651
997
|
/**
|
|
652
998
|
* Clears the failure counter and any expired lock after a successful login.
|
|
653
999
|
*/
|
|
654
|
-
function
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
await meta.delete(User_js_1.USER_COMMON_METADATA.USER_LOGIN_LOCKED_UNTIL);
|
|
667
|
-
return u;
|
|
668
|
-
};
|
|
1000
|
+
async function clearLoginThrottle(u) {
|
|
1001
|
+
const meta = u?.Metadata;
|
|
1002
|
+
if (!meta) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const hasAttempts = Number(meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS] ?? 0) > 0;
|
|
1006
|
+
const hasLock = Boolean(meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_LOCKED_UNTIL]);
|
|
1007
|
+
if (!hasAttempts && !hasLock) {
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
await meta.delete(User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS);
|
|
1011
|
+
await meta.delete(User_js_1.USER_COMMON_METADATA.USER_LOGIN_LOCKED_UNTIL);
|
|
669
1012
|
}
|
|
670
1013
|
/**
|
|
671
1014
|
* Records one failed authentication and, at `rbac.password.blockAfterAttempts`
|
|
@@ -676,32 +1019,30 @@ function _clear_login_throttle() {
|
|
|
676
1019
|
* itself the lockout is not counted — otherwise hammering a locked account
|
|
677
1020
|
* would keep extending the lock indefinitely.
|
|
678
1021
|
*
|
|
1022
|
+
* @param u - user whose failed attempt is recorded
|
|
679
1023
|
* @param err - the error that ended the login attempt
|
|
680
1024
|
*/
|
|
681
|
-
function
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
await meta.update();
|
|
704
|
-
return u;
|
|
705
|
-
};
|
|
1025
|
+
async function registerFailedLogin(u, err) {
|
|
1026
|
+
const meta = u?.Metadata;
|
|
1027
|
+
if (!meta) {
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (err instanceof exceptions_js_1.LoginAttemptsExceeded) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const blockAfter = (0, configuration_1.cfg)('rbac.password.blockAfterAttempts', 5);
|
|
1034
|
+
const lockoutTime = (0, configuration_1.cfg)('rbac.password.lockoutTime', 15 * 60);
|
|
1035
|
+
if (!blockAfter || blockAfter <= 0) {
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const attempts = Number(meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS] ?? 0) + 1;
|
|
1039
|
+
if (attempts >= blockAfter) {
|
|
1040
|
+
meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS] = 0;
|
|
1041
|
+
meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_LOCKED_UNTIL] = luxon_1.DateTime.now().plus({ seconds: lockoutTime }).toISO();
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS] = attempts;
|
|
1045
|
+
}
|
|
1046
|
+
await meta.update();
|
|
706
1047
|
}
|
|
707
1048
|
//# sourceMappingURL=actions.js.map
|