@spinajs/rbac 2.0.521 → 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.
Files changed (81) hide show
  1. package/lib/cjs/actions.d.ts +269 -93
  2. package/lib/cjs/actions.d.ts.map +1 -1
  3. package/lib/cjs/actions.js +695 -354
  4. package/lib/cjs/actions.js.map +1 -1
  5. package/lib/cjs/auth.d.ts.map +1 -1
  6. package/lib/cjs/auth.js +6 -6
  7. package/lib/cjs/auth.js.map +1 -1
  8. package/lib/cjs/cli/ChangeUserPassword.d.ts.map +1 -1
  9. package/lib/cjs/cli/ChangeUserPassword.js +2 -2
  10. package/lib/cjs/cli/ChangeUserPassword.js.map +1 -1
  11. package/lib/cjs/cli/CreateUser.d.ts.map +1 -1
  12. package/lib/cjs/cli/CreateUser.js +8 -1
  13. package/lib/cjs/cli/CreateUser.js.map +1 -1
  14. package/lib/cjs/config/rbac.d.ts +40 -0
  15. package/lib/cjs/config/rbac.d.ts.map +1 -1
  16. package/lib/cjs/config/rbac.js +40 -0
  17. package/lib/cjs/config/rbac.js.map +1 -1
  18. package/lib/cjs/events/UserCreated.d.ts +2 -1
  19. package/lib/cjs/events/UserCreated.d.ts.map +1 -1
  20. package/lib/cjs/events/UserCreated.js +13 -3
  21. package/lib/cjs/events/UserCreated.js.map +1 -1
  22. package/lib/cjs/exceptions.d.ts +69 -0
  23. package/lib/cjs/exceptions.d.ts.map +1 -0
  24. package/lib/cjs/exceptions.js +86 -0
  25. package/lib/cjs/exceptions.js.map +1 -0
  26. package/lib/cjs/index.d.ts +1 -0
  27. package/lib/cjs/index.d.ts.map +1 -1
  28. package/lib/cjs/index.js +1 -0
  29. package/lib/cjs/index.js.map +1 -1
  30. package/lib/cjs/interfaces.d.ts +2 -7
  31. package/lib/cjs/interfaces.d.ts.map +1 -1
  32. package/lib/cjs/interfaces.js +1 -8
  33. package/lib/cjs/interfaces.js.map +1 -1
  34. package/lib/cjs/password.d.ts +18 -1
  35. package/lib/cjs/password.d.ts.map +1 -1
  36. package/lib/cjs/password.js +61 -5
  37. package/lib/cjs/password.js.map +1 -1
  38. package/lib/cjs/profile.js +1 -1
  39. package/lib/cjs/profile.js.map +1 -1
  40. package/lib/mjs/actions.d.ts +269 -93
  41. package/lib/mjs/actions.d.ts.map +1 -1
  42. package/lib/mjs/actions.js +682 -358
  43. package/lib/mjs/actions.js.map +1 -1
  44. package/lib/mjs/auth.d.ts.map +1 -1
  45. package/lib/mjs/auth.js +7 -7
  46. package/lib/mjs/auth.js.map +1 -1
  47. package/lib/mjs/cli/ChangeUserPassword.d.ts.map +1 -1
  48. package/lib/mjs/cli/ChangeUserPassword.js +3 -3
  49. package/lib/mjs/cli/ChangeUserPassword.js.map +1 -1
  50. package/lib/mjs/cli/CreateUser.d.ts.map +1 -1
  51. package/lib/mjs/cli/CreateUser.js +8 -1
  52. package/lib/mjs/cli/CreateUser.js.map +1 -1
  53. package/lib/mjs/config/rbac.d.ts +40 -0
  54. package/lib/mjs/config/rbac.d.ts.map +1 -1
  55. package/lib/mjs/config/rbac.js +40 -0
  56. package/lib/mjs/config/rbac.js.map +1 -1
  57. package/lib/mjs/events/UserCreated.d.ts +2 -1
  58. package/lib/mjs/events/UserCreated.d.ts.map +1 -1
  59. package/lib/mjs/events/UserCreated.js +13 -3
  60. package/lib/mjs/events/UserCreated.js.map +1 -1
  61. package/lib/mjs/exceptions.d.ts +69 -0
  62. package/lib/mjs/exceptions.d.ts.map +1 -0
  63. package/lib/mjs/exceptions.js +71 -0
  64. package/lib/mjs/exceptions.js.map +1 -0
  65. package/lib/mjs/index.d.ts +1 -0
  66. package/lib/mjs/index.d.ts.map +1 -1
  67. package/lib/mjs/index.js +1 -0
  68. package/lib/mjs/index.js.map +1 -1
  69. package/lib/mjs/interfaces.d.ts +2 -7
  70. package/lib/mjs/interfaces.d.ts.map +1 -1
  71. package/lib/mjs/interfaces.js +0 -7
  72. package/lib/mjs/interfaces.js.map +1 -1
  73. package/lib/mjs/password.d.ts +18 -1
  74. package/lib/mjs/password.d.ts.map +1 -1
  75. package/lib/mjs/password.js +62 -6
  76. package/lib/mjs/password.js.map +1 -1
  77. package/lib/mjs/profile.js +2 -2
  78. package/lib/mjs/profile.js.map +1 -1
  79. package/lib/tsconfig.cjs.tsbuildinfo +1 -1
  80. package/lib/tsconfig.mjs.tsbuildinfo +1 -1
  81. package/package.json +11 -11
@@ -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.E_CODES = void 0;
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
- * HELPER FUNCTIONS
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
- * Gets system user account
72
- *
73
- * @returns system user
79
+ * @param identifier - numeric id, uuid / email / login string, or an existing {@link User} instance
74
80
  */
75
- function _get_system_user() {
76
- return (0, util_1._chain)((0, util_1._zip)((0, configuration_1._cfg)('rbac.systemRole'), (0, configuration_1._cfg)('rbac.roleColumn')), ([systemRole, roleColumn]) => {
77
- const s = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)())(systemRole, 'rbac.systemRole');
78
- const c = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._non_empty)())(roleColumn, 'rbac.roleColumn');
79
- return [s, c];
80
- }, ([systemRole, roleColumn]) => User_js_1.User.query().where(roleColumn, systemRole).firstOrFail());
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
- * Gets users by role helper func.
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 _get_users_by_role(role) {
90
- return () => (0, model_token_js_1.userModel)().select().withRole(role);
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
- * Gets rbac user model
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 _get_user(user) {
100
- if (lodash_1.default.isString(user)) {
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 _set_user_meta(meta, value = null) {
118
- return async (u) => {
119
- const mArgs = (0, util_1._check_arg)((0, util_1._non_nil)(new exceptions_1.ErrorCode(E_CODES.E_METADATA_NOT_POPULATED, 'User metadata not loaded', { user: u })), (0, util_1._to_array)())(meta, 'Metadata');
120
- mArgs.forEach((m) => {
121
- lodash_1.default.isString(m) ? (u.Metadata[m] = value) : (u.Metadata[m.key] = m.value);
122
- });
123
- await (0, util_1._chain)(u, (0, util_1._tap)(() => u.Metadata.update()), _user_ev(UserMetadataChange_js_1.UserMetadataChange, () => {
124
- return mArgs.map((m) => {
125
- return lodash_1.default.isString(m) ? { key: m, value } : m;
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 _get_user_meta(key) {
139
- return async (u) => {
140
- (0, util_1._check_arg)((0, util_1._non_nil)(new exceptions_1.ErrorCode(E_CODES.E_METADATA_NOT_POPULATED, 'User metadata not loaded', { user: u, key })))(u.Metadata, 'Metadata');
141
- (0, util_1._check_arg)((0, util_1._non_nil)(new exceptions_1.ErrorCode(E_CODES.E_METADATA_NOT_FOUND, 'Metadata not found in user data', { user: u, key })))(u.Metadata[key], `Metadata.${key}`);
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
- * Helper function for sending user notification emails
147
- * Templates are defined in rbac configuration
153
+ * Sends a user notification email. Templates are defined in rbac configuration.
148
154
  *
149
- * @param cfgTemplate
150
- * @returns
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 event - constructor of the {@link UserEvent} subclass to emit
178
- * @param args - additional arguments forwarded to the event constructor
179
- * @returns a function that receives a {@link User}, emits the event, and returns the user
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 _user_ev(event, ...args) {
182
- return async (u) => {
183
- await (0, queue_1._ev)(new event(u, ...args))();
184
- return u;
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 _user_update(data) {
194
- return async (u) => {
195
- await (0, util_1._chain)(u, (0, orm_1._update)(data), _user_ev(index_js_1.UserChanged));
196
- return u;
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._service)('rbac.session', interfaces_js_1.SessionProvider)();
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
- * Chain step form of {@link revokeUserSessions} — revokes and forwards the user.
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 _revoke_sessions() {
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 revokeUserSessions(u);
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
- return (0, util_1._chain)(_user(identifier), _user_update({ IsActive: true }), _user_ev(index_js_1.UserActivated), _user_email('activated'));
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
- return (0, util_1._chain)(_user(identifier), _user_update({ IsActive: false }), _revoke_sessions(), _user_ev(index_js_1.UserDeactivated), _user_email('deactivated'));
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 ( `_cfg` accepts empty arrays ).
335
+ * "no middleware" result ( `cfg` accepts empty arrays ).
289
336
  */
290
- function _create_middleware(path) {
291
- const mw = (0, configuration_1._cfg)(path, [])();
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, hashes the password, inserts the user record,
298
- * optionally sets metadata, runs configured `beforeCreate` / `afterCreate` middleware,
299
- * emits a {@link UserCreated} event, and sends the "created" email.
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 id - optional explicit user id (useful when migrating from another system)
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, password, roles, id, metadata) {
310
- const sPassword = await (0, configuration_1._service)('rbac.password', interfaces_js_1.PasswordProvider)();
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
- password = (0, util_1._check_arg)((0, util_1._trim)(), (0, util_1._default)(() => sPassword.generate()))(password, 'password');
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
- return (0, util_1._chain)(
316
- // create user
317
- () => Promise.resolve(new User_js_1.User({
318
- Id: id,
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: roles,
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
- return (0, util_1._chain)(_user(identifier), (0, util_1._tap)((u) => u.destroy()),
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
- _revoke_sessions(), _user_ev(index_js_1.UserDeleted), _user_email('deleted'));
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
- return (0, util_1._chain)(_user(identifier), (0, util_1._tap)(async (u) => (u.Role = lodash_1.default.uniq([...u.Role, role]))), _user_update(), _user_ev(index_js_1.UserRoleGranted, role));
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
- return (0, util_1._chain)(_user(identifier), (0, util_1._tap)(async (u) => (u.Role = u.Role.filter((r) => r !== role))), _user_update(), _user_ev(index_js_1.UserRoleRevoked, role));
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 reson for ban
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
- return (0, util_1._chain)(_user(identifier), (u) => {
395
- if (u.Metadata[User_js_1.USER_COMMON_METADATA.USER_BAN_IS_BANNED]) {
396
- throw new exceptions_1.ErrorCode(E_CODES.E_USER_BANNED, `User is already banned`, { user: u });
397
- }
398
- return u;
399
- }, _set_user_meta([
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
- _revoke_sessions(), _user_ev(index_js_1.UserBanned), _user_email('banned'));
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
- return (0, util_1._chain)(_user(identifier),
419
- // guard must return the user so the chain can keep flowing it downstream
420
- (0, util_1._tap)(async (u) => {
421
- if (!u.Metadata[User_js_1.USER_COMMON_METADATA.USER_BAN_IS_BANNED]) {
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
- (0, util_1._tap)(async (u) => {
428
- await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_BAN_IS_BANNED);
429
- await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_BAN_START_DATE);
430
- await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_BAN_DURATION);
431
- await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_BAN_REASON);
432
- }), _user_ev(index_js_1.UserUnbanned));
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, and emits a {@link UserPasswordChangeRequest} event.
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 = await (0, configuration_1._cfg)('rbac.password.passwordResetWaitTime')();
443
- return (0, util_1._chain)(_user(identifier), _set_user_meta([
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: (0, uuid_1.v4)() },
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
- ]), _user_ev(index_js_1.UserPasswordChangeRequest));
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
- return (0, util_1._chain)(_user(identifier),
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 ErrorCode family the caller already collapses into one
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
- (0, util_1._tap)(async (u) => {
464
- if (u.Metadata[User_js_1.USER_COMMON_METADATA.USER_BAN_IS_BANNED]) {
465
- throw new exceptions_1.ErrorCode(E_CODES.E_USER_BANNED, `Password reset refused: user is banned`, { user: u });
466
- }
467
- if (!u.IsActive || u.DeletedAt) {
468
- throw new exceptions_1.ErrorCode(E_CODES.E_USER_NOT_ACTIVE, `Password reset refused: user is not active`, { user: u });
469
- }
470
- }), (0, util_1._tap)((u) => (0, util_1._chain)(u, (0, util_1._zip)(_get_user_meta(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_START_DATE), _get_user_meta(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_WAIT_TIME)), ([dueDate, waitTime]) => {
471
- if (dueDate.plus({ seconds: waitTime }) < luxon_1.DateTime.now()) {
472
- throw new exceptions_1.ErrorCode(E_CODES.E_TOKEN_EXPIRED, `Password change token expired, token expiration date is: ${dueDate.toISO()}`, {
473
- dueDate,
474
- waitTime,
475
- time: luxon_1.DateTime.now(),
476
- user: u,
477
- });
478
- }
479
- })), (0, util_1._tap)((u) => (0, util_1._chain)(u, _get_user_meta(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_TOKEN), async (resetToken) => {
480
- if (!_secure_compare(String(resetToken), token)) {
481
- throw new exceptions_1.ErrorCode(E_CODES.E_TOKEN_INVALID, `Password change token invalid, operation not permitted`, {
482
- token,
483
- resetToken,
484
- user: u,
485
- });
486
- }
487
- })), changePassword(newPassword),
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
- (0, util_1._tap)(async (u) => {
492
- await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_TOKEN);
493
- await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_START_DATE);
494
- await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_WAIT_TIME);
495
- await u.Metadata.delete(User_js_1.USER_COMMON_METADATA.USER_PWD_RESET);
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 _secure_compare(a, b) {
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
- * Returns a function that changes a user's password.
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 changePassword(password) {
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
- return async (u) => {
531
- return (0, util_1._chain)((0, util_1._use)((0, configuration_1._service)('rbac.password', interfaces_js_1.PasswordProvider), 'pwd'), (0, util_1._use)((0, configuration_1._service)('rbac.password.validation', interfaces_js_1.PasswordValidationProvider), 'validator'), (0, util_1._tap)(async ({ validator }) => {
532
- if (!validator.check(password)) {
533
- throw new Error('Password does not meet requirements');
534
- }
535
- }),
536
- // update password
537
- ({ pwd }) => pwd.hash(password), (hPassword) => (0, util_1._chain)(u, (0, orm_1._update)({ Password: hPassword }), _set_user_meta([
538
- { key: User_js_1.USER_COMMON_METADATA.USER_PWD_RESET_LAST_ATTEMPT, value: luxon_1.DateTime.now().toISO() },
539
- // a successful password change clears the login throttle: the
540
- // credential the failures were counted against no longer exists
541
- { key: User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS, value: 0 },
542
- { key: User_js_1.USER_COMMON_METADATA.USER_LOGIN_LOCKED_UNTIL, value: null },
543
- ]),
544
- // Every session was authorized by the OLD password. Whoever holds one
545
- // including whoever the user is changing the password because of —
546
- // loses it here. Callers that want the acting user to stay logged in
547
- // ( eg. PATCH /user/password ) mint a fresh session afterwards.
548
- _revoke_sessions(), _user_ev(index_js_1.UserPasswordChanged)));
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
- * Expire password for user
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
- return await (0, util_1._chain)(_user(identifier), (user) => deactivate(user), _user_ev(UserPasswordExpired_js_1.UserPasswordExpired));
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
- * Check if password match user password stored in db
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 passwordMatch(password) {
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
- return async (u) => {
570
- // NOTE: _chain forwards exactly ONE value from step to step, so the second
571
- // parameter of the last step was always undefined and every call died with
572
- // "Cannot read properties of undefined (reading 'Password')" — including
573
- // the happy path of PATCH /user/password. The user is taken from the
574
- // closure instead.
575
- return await (0, util_1._chain)((0, configuration_1._service)('rbac.password', interfaces_js_1.PasswordProvider), async (sPwd) => sPwd.verify(u.Password, password));
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
- return await (0, util_1._chain)(_login_lookup(identifier), (0, util_1._catch)((u) => {
591
- return (0, util_1._chain)(async () => {
592
- // Refuse before the password is even checked, so a locked account
593
- // cannot be probed at all during the lockout window.
594
- await _assert_not_locked(u);
595
- return (0, configuration_1._service)('rbac.auth', interfaces_js_1.AuthProvider)();
596
- }, async (sAuth) => sAuth.authenticate(u.Email, password), (0, orm_1._update)({ LastLoginAt: luxon_1.DateTime.now() }), _clear_login_throttle(), _user_ev(index_js_1.UserLogged));
597
- }, (err, u) => {
598
- return (0, util_1._chain)(() => u,
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
- _register_failed_login(err),
602
- // send event of failed login
603
- _user_ev(UserLoginFailed_js_1.UserLoginFailed, err),
604
- // rethrow error for caller
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 _user_unsafe} ends in `firstOrFail()`, whose `OrmNotFoundException` is
615
- * neither `ErrorCode` nor `InvalidArgument`: the login controller cannot read it
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 carry `E_INVALID_CREDENTIALS`, exactly as
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 _login_lookup(identifier) {
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 () => Promise.resolve(id);
974
+ return id;
629
975
  }
630
- return () => User_js_1.UserBase.query().whereAnything(id).populate('Metadata').firstOrThrow(new exceptions_1.ErrorCode(interfaces_js_1.AthenticationErrorCodes.E_INVALID_CREDENTIALS, 'no user with given email'));
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 _register_failed_login}.
980
+ * {@link registerFailedLogin}.
635
981
  *
636
982
  * @param u - user attempting to authenticate
637
983
  */
638
- async function _assert_not_locked(u) {
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 exceptions_1.ErrorCode(interfaces_js_1.AthenticationErrorCodes.E_LOGIN_ATTEMPTS_EXCEEDED, `Too many failed login attempts, account is temporarily locked until ${lockedUntil.toISO()}`, {
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 _clear_login_throttle() {
655
- return async (u) => {
656
- const meta = u?.Metadata;
657
- if (!meta) {
658
- return u;
659
- }
660
- const hasAttempts = Number(meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS] ?? 0) > 0;
661
- const hasLock = Boolean(meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_LOCKED_UNTIL]);
662
- if (!hasAttempts && !hasLock) {
663
- return u;
664
- }
665
- await meta.delete(User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS);
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 _register_failed_login(err) {
682
- return async (u) => {
683
- const meta = u?.Metadata;
684
- if (!meta) {
685
- return u;
686
- }
687
- if (err instanceof exceptions_1.ErrorCode && err.code === interfaces_js_1.AthenticationErrorCodes.E_LOGIN_ATTEMPTS_EXCEEDED) {
688
- return u;
689
- }
690
- const blockAfter = await (0, configuration_1._cfg)('rbac.password.blockAfterAttempts', 5)();
691
- const lockoutTime = await (0, configuration_1._cfg)('rbac.password.lockoutTime', 15 * 60)();
692
- if (!blockAfter || blockAfter <= 0) {
693
- return u;
694
- }
695
- const attempts = Number(meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS] ?? 0) + 1;
696
- if (attempts >= blockAfter) {
697
- meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS] = 0;
698
- meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_LOCKED_UNTIL] = luxon_1.DateTime.now().plus({ seconds: lockoutTime }).toISO();
699
- }
700
- else {
701
- meta[User_js_1.USER_COMMON_METADATA.USER_LOGIN_ATTEMPTS] = attempts;
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