@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.
- package/lib/cjs/actions.d.ts +269 -93
- package/lib/cjs/actions.d.ts.map +1 -1
- package/lib/cjs/actions.js +695 -354
- package/lib/cjs/actions.js.map +1 -1
- package/lib/cjs/auth.d.ts.map +1 -1
- package/lib/cjs/auth.js +6 -6
- package/lib/cjs/auth.js.map +1 -1
- package/lib/cjs/cli/ChangeUserPassword.d.ts.map +1 -1
- package/lib/cjs/cli/ChangeUserPassword.js +2 -2
- package/lib/cjs/cli/ChangeUserPassword.js.map +1 -1
- package/lib/cjs/cli/CreateUser.d.ts.map +1 -1
- package/lib/cjs/cli/CreateUser.js +8 -1
- package/lib/cjs/cli/CreateUser.js.map +1 -1
- package/lib/cjs/config/rbac.d.ts +40 -0
- package/lib/cjs/config/rbac.d.ts.map +1 -1
- package/lib/cjs/config/rbac.js +40 -0
- package/lib/cjs/config/rbac.js.map +1 -1
- package/lib/cjs/events/UserCreated.d.ts +2 -1
- package/lib/cjs/events/UserCreated.d.ts.map +1 -1
- package/lib/cjs/events/UserCreated.js +13 -3
- package/lib/cjs/events/UserCreated.js.map +1 -1
- package/lib/cjs/exceptions.d.ts +69 -0
- package/lib/cjs/exceptions.d.ts.map +1 -0
- package/lib/cjs/exceptions.js +86 -0
- package/lib/cjs/exceptions.js.map +1 -0
- package/lib/cjs/index.d.ts +1 -0
- package/lib/cjs/index.d.ts.map +1 -1
- package/lib/cjs/index.js +1 -0
- package/lib/cjs/index.js.map +1 -1
- package/lib/cjs/interfaces.d.ts +2 -7
- package/lib/cjs/interfaces.d.ts.map +1 -1
- package/lib/cjs/interfaces.js +1 -8
- package/lib/cjs/interfaces.js.map +1 -1
- package/lib/cjs/password.d.ts +18 -1
- package/lib/cjs/password.d.ts.map +1 -1
- package/lib/cjs/password.js +61 -5
- package/lib/cjs/password.js.map +1 -1
- package/lib/cjs/profile.js +1 -1
- package/lib/cjs/profile.js.map +1 -1
- package/lib/mjs/actions.d.ts +269 -93
- package/lib/mjs/actions.d.ts.map +1 -1
- package/lib/mjs/actions.js +682 -358
- package/lib/mjs/actions.js.map +1 -1
- package/lib/mjs/auth.d.ts.map +1 -1
- package/lib/mjs/auth.js +7 -7
- package/lib/mjs/auth.js.map +1 -1
- package/lib/mjs/cli/ChangeUserPassword.d.ts.map +1 -1
- package/lib/mjs/cli/ChangeUserPassword.js +3 -3
- package/lib/mjs/cli/ChangeUserPassword.js.map +1 -1
- package/lib/mjs/cli/CreateUser.d.ts.map +1 -1
- package/lib/mjs/cli/CreateUser.js +8 -1
- package/lib/mjs/cli/CreateUser.js.map +1 -1
- package/lib/mjs/config/rbac.d.ts +40 -0
- package/lib/mjs/config/rbac.d.ts.map +1 -1
- package/lib/mjs/config/rbac.js +40 -0
- package/lib/mjs/config/rbac.js.map +1 -1
- package/lib/mjs/events/UserCreated.d.ts +2 -1
- package/lib/mjs/events/UserCreated.d.ts.map +1 -1
- package/lib/mjs/events/UserCreated.js +13 -3
- package/lib/mjs/events/UserCreated.js.map +1 -1
- package/lib/mjs/exceptions.d.ts +69 -0
- package/lib/mjs/exceptions.d.ts.map +1 -0
- package/lib/mjs/exceptions.js +71 -0
- package/lib/mjs/exceptions.js.map +1 -0
- package/lib/mjs/index.d.ts +1 -0
- package/lib/mjs/index.d.ts.map +1 -1
- package/lib/mjs/index.js +1 -0
- package/lib/mjs/index.js.map +1 -1
- package/lib/mjs/interfaces.d.ts +2 -7
- package/lib/mjs/interfaces.d.ts.map +1 -1
- package/lib/mjs/interfaces.js +0 -7
- package/lib/mjs/interfaces.js.map +1 -1
- package/lib/mjs/password.d.ts +18 -1
- package/lib/mjs/password.d.ts.map +1 -1
- package/lib/mjs/password.js +62 -6
- package/lib/mjs/password.js.map +1 -1
- package/lib/mjs/profile.js +2 -2
- package/lib/mjs/profile.js.map +1 -1
- package/lib/tsconfig.cjs.tsbuildinfo +1 -1
- package/lib/tsconfig.mjs.tsbuildinfo +1 -1
- package/package.json +11 -11
package/lib/mjs/actions.js
CHANGED
|
@@ -1,198 +1,154 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import { USER_COMMON_METADATA, User, UserBase } from './models/User.js';
|
|
7
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
*
|
|
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
|
|
59
|
-
|
|
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
|
-
*
|
|
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
|
|
69
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
*
|
|
116
|
-
* Templates are defined in rbac configuration
|
|
105
|
+
* Sends a user notification email. Templates are defined in rbac configuration.
|
|
117
106
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
147
|
-
* @param
|
|
148
|
-
* @
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
173
|
+
const provider = await service('rbac.session', SessionProvider);
|
|
218
174
|
await provider.deleteByUser(userId);
|
|
219
175
|
}
|
|
220
176
|
/**
|
|
221
|
-
*
|
|
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
|
|
238
|
+
export function _user_ev(event, ...args) {
|
|
224
239
|
return async (u) => {
|
|
225
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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 ( `
|
|
287
|
+
* "no middleware" result ( `cfg` accepts empty arrays ).
|
|
258
288
|
*/
|
|
259
|
-
function
|
|
260
|
-
const mw =
|
|
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,
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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
|
|
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,
|
|
279
|
-
const sPassword = await
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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,
|
|
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 =
|
|
412
|
-
|
|
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:
|
|
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
|
-
])
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
779
|
+
export async function changeUserPassword(u, password) {
|
|
498
780
|
password = _check_arg(_trim(), _non_empty())(password, 'password');
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
584
|
-
* neither
|
|
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
|
|
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
|
|
923
|
+
async function loginLookup(identifier) {
|
|
595
924
|
const id = _check_arg(_trim(), _non_nil())(identifier, 'identifier');
|
|
596
925
|
if (id instanceof UserBase) {
|
|
597
|
-
return
|
|
926
|
+
return id;
|
|
598
927
|
}
|
|
599
|
-
return
|
|
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
|
|
932
|
+
* {@link registerFailedLogin}.
|
|
604
933
|
*
|
|
605
934
|
* @param u - user attempting to authenticate
|
|
606
935
|
*/
|
|
607
|
-
|
|
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
|
|
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
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|