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