@xeplr/auth 1.0.0 → 1.0.2
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/bin/migrate.js +58 -25
- package/bin/server.js +12 -30
- package/index.js +188 -48
- package/lib/adminRouter.js +20 -92
- package/lib/authHelper.js +26 -3
- package/lib/authMiddleware.js +39 -13
- package/lib/authRouter.js +144 -12
- package/lib/authService.js +448 -16
- package/lib/hooks.js +50 -0
- package/lib/mtMembershipMiddleware.js +61 -0
- package/lib/seed.js +74 -0
- package/lib/sessionService.js +54 -6
- package/lib/ticketService.js +88 -0
- package/lib/tokenDecision.js +45 -0
- package/migrations/0001_extensions.sql +9 -0
- package/migrations/0002_users.sql +31 -0
- package/migrations/0003_catalog_tables.sql +82 -0
- package/migrations/0004_role_mappings.sql +78 -0
- package/migrations/0005_user_tenants_mapping.sql +39 -0
- package/migrations/0006_seed_catalog.sql +120 -0
- package/models/Api.js +3 -0
- package/models/ApisRolesMapping.js +3 -0
- package/models/Menu.js +3 -0
- package/models/MenuRolesMapping.js +3 -0
- package/models/Role.js +8 -0
- package/models/UiElement.js +3 -0
- package/models/UiElementsRolesMapping.js +3 -0
- package/models/UiPage.js +3 -0
- package/models/UiPagesRolesMapping.js +3 -0
- package/models/User.js +3 -13
- package/models/UserRolesMapping.js +3 -0
- package/models/UserTenantsMapping.js +20 -2
- package/models/index.js +0 -2
- package/package.json +26 -5
- package/migrations/0001_users.js +0 -22
- package/migrations/0002_menus.js +0 -16
- package/migrations/0003_apis.js +0 -16
- package/migrations/0004_uiPages.js +0 -16
- package/migrations/0005_uiElements.js +0 -16
- package/migrations/0006_roles.js +0 -15
- package/migrations/0007_apisRolesMapping.js +0 -16
- package/migrations/0008_uiPagesRolesMapping.js +0 -16
- package/migrations/0009_uiElementsRolesMapping.js +0 -16
- package/migrations/0010_menuRolesMapping.js +0 -16
- package/migrations/0011_userRolesMapping.js +0 -16
- package/migrations/0012_users_add_reset_token.js +0 -13
- package/migrations/0013_add_isPublic.js +0 -27
- package/migrations/0014_users_add_activation_token.js +0 -13
- package/migrations/0015_add_mt_columns.js +0 -41
- package/migrations/0016_tenants.js +0 -23
- package/migrations/0017_userTenantsMapping.js +0 -20
- package/models/Tenant.js +0 -63
- package/seeds/001_roles.js +0 -26
- package/seeds/002_apis.js +0 -70
- package/seeds/003_pages.js +0 -41
- package/seeds/004_elements.js +0 -46
- package/seeds/005_menus.js +0 -35
- package/seeds/006_default_tenant.js +0 -50
- package/seeds/zzz_admin_access.js +0 -49
package/lib/authService.js
CHANGED
|
@@ -1,21 +1,102 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
|
-
const { sendEmail, configureEmail, formatDbDateTime } = require('@xeplr/utils');
|
|
2
|
+
const { sendEmail, configureEmail, formatDbDateTime, otp } = require('@xeplr/utils');
|
|
3
3
|
const { normalizeEmail } = require('@xeplr/utils/isomorphic');
|
|
4
4
|
const { generateId, hashPassword, comparePassword } = require('./authHelper');
|
|
5
5
|
const User = require('../models/User');
|
|
6
6
|
const { getUserAccess, clearUserAccess } = require('./accessService');
|
|
7
7
|
const { createSession, destroyAllUserSessions } = require('./sessionService');
|
|
8
|
+
const hooks = require('./hooks');
|
|
9
|
+
|
|
10
|
+
// WHERE THE LINKS IN OUR EMAILS POINT.
|
|
11
|
+
//
|
|
12
|
+
// FULLY QUALIFIED, one per link, because these strings do not only end up in a
|
|
13
|
+
// string concatenation here — they are handed to the email/template layer,
|
|
14
|
+
// where the token can travel as a variable but the URL cannot. A template that
|
|
15
|
+
// has to know to append '/auth/activate' to something called a "base" is a
|
|
16
|
+
// template that knows about this package's routing.
|
|
17
|
+
//
|
|
18
|
+
// The old AUTH_ACTIVATION_BASE_URL still works: given an origin, the default
|
|
19
|
+
// path for each link is appended, exactly as before. It served both the
|
|
20
|
+
// activation and the invite page, which is precisely why one fully-qualified
|
|
21
|
+
// value could not replace it.
|
|
22
|
+
var _links = { activationUrl: null, inviteUrl: null, baseUrl: null };
|
|
23
|
+
|
|
24
|
+
var DEFAULT_PATHS = { activation: '/auth/activate', invite: '/auth/accept-invite' };
|
|
25
|
+
|
|
26
|
+
// RESUMING A WORKFLOW ON ACTIVATION.
|
|
27
|
+
//
|
|
28
|
+
// A registration can be one step of a longer process — "invite them, wait for
|
|
29
|
+
// them to activate, then grant the roles" — and a workflow step that waits is
|
|
30
|
+
// released by its resumeKey (@xeplr-workflow/api's resumeByKey).
|
|
31
|
+
//
|
|
32
|
+
// INJECTED, not required. This package sits underneath every product here and
|
|
33
|
+
// must not depend on one of them; a host that runs workflows hands the
|
|
34
|
+
// function in at boot, and an install without workflow simply never sets it
|
|
35
|
+
// and never sees the feature. Same shape as configureEmail above.
|
|
36
|
+
var _resumeWorkflowByKey = null;
|
|
37
|
+
|
|
38
|
+
function configureWorkflowResume(fn) {
|
|
39
|
+
_resumeWorkflowByKey = typeof fn === 'function' ? fn : null;
|
|
40
|
+
}
|
|
8
41
|
|
|
9
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Best-effort, and deliberately so.
|
|
44
|
+
*
|
|
45
|
+
* The account IS activated by the time this runs. If the workflow cannot be
|
|
46
|
+
* resumed — no such key, the run already moved on, workflow is down — that is
|
|
47
|
+
* a problem with the workflow, not with the person who just clicked the link
|
|
48
|
+
* in their email. Failing the activation over it would lock them out of an
|
|
49
|
+
* account that is already open.
|
|
50
|
+
*/
|
|
51
|
+
async function resumeWorkflow(workflowKey, output) {
|
|
52
|
+
if (!workflowKey || !_resumeWorkflowByKey) return null;
|
|
53
|
+
try {
|
|
54
|
+
return await _resumeWorkflowByKey(workflowKey, output);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// Through @xeplr/logs, which is what routes an error onto the host's
|
|
57
|
+
// problem list (see xeplr-bi's errorEvents) — a workflow that silently
|
|
58
|
+
// never resumed is exactly the kind of thing nobody goes looking for.
|
|
59
|
+
require('@xeplr/logs').createLogger('auth')
|
|
60
|
+
.error('could not resume workflow "' + workflowKey + '": ' + err.message);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string|object} config - a legacy origin string, or
|
|
67
|
+
* { activationUrl, inviteUrl, baseUrl }.
|
|
68
|
+
*/
|
|
69
|
+
function configureActivation(config) {
|
|
70
|
+
if (typeof config === 'string') { _links.baseUrl = config; return; }
|
|
71
|
+
if (!config) return;
|
|
72
|
+
if (config.activationUrl !== undefined) _links.activationUrl = config.activationUrl;
|
|
73
|
+
if (config.inviteUrl !== undefined) _links.inviteUrl = config.inviteUrl;
|
|
74
|
+
if (config.baseUrl !== undefined) _links.baseUrl = config.baseUrl;
|
|
75
|
+
}
|
|
10
76
|
|
|
11
|
-
function
|
|
12
|
-
|
|
77
|
+
function linkFor(kind) {
|
|
78
|
+
var explicit = kind === 'activation'
|
|
79
|
+
? (_links.activationUrl || process.env.AUTH_ACTIVATION_URL)
|
|
80
|
+
: (_links.inviteUrl || process.env.AUTH_INVITE_URL);
|
|
81
|
+
if (explicit) return explicit;
|
|
82
|
+
|
|
83
|
+
var base = _links.baseUrl || process.env.AUTH_ACTIVATION_BASE_URL;
|
|
84
|
+
if (!base) return null;
|
|
85
|
+
// Trailing slashes are the most common way a hand-edited .env produces a
|
|
86
|
+
// double slash in a link somebody then reports as broken.
|
|
87
|
+
return String(base).replace(/\/+$/, '') + DEFAULT_PATHS[kind];
|
|
13
88
|
}
|
|
14
89
|
|
|
15
|
-
|
|
90
|
+
/** Append the token, respecting a URL that already carries a query string. */
|
|
91
|
+
function withToken(url, token) {
|
|
92
|
+
return url + (url.indexOf('?') === -1 ? '?' : '&') + 'token=' + token;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function register({ email, phoneNumber, name, password, workflowKey, knex }) {
|
|
96
|
+
const UserM = knex ? User.bindKnex(knex) : User;
|
|
16
97
|
const normalized = normalizeEmail(email);
|
|
17
98
|
|
|
18
|
-
const existing = await
|
|
99
|
+
const existing = await UserM.query().findOne({ normalizedEmail: normalized });
|
|
19
100
|
if (existing) {
|
|
20
101
|
throw new Error('Email already registered');
|
|
21
102
|
}
|
|
@@ -25,7 +106,7 @@ async function register({ email, phoneNumber, name, password }) {
|
|
|
25
106
|
const now = formatDbDateTime();
|
|
26
107
|
const activationToken = crypto.randomBytes(32).toString('hex');
|
|
27
108
|
|
|
28
|
-
const user = await
|
|
109
|
+
const user = await UserM.query().insert({
|
|
29
110
|
id,
|
|
30
111
|
email: email.trim().toLowerCase(),
|
|
31
112
|
normalizedEmail: normalized,
|
|
@@ -45,12 +126,17 @@ async function register({ email, phoneNumber, name, password }) {
|
|
|
45
126
|
});
|
|
46
127
|
|
|
47
128
|
// Send activation email
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
50
|
-
throw new Error('
|
|
129
|
+
const activationUrl = linkFor('activation');
|
|
130
|
+
if (!activationUrl) {
|
|
131
|
+
throw new Error('AUTH_ACTIVATION_URL is required (the full address of your activation page, ' +
|
|
132
|
+
'e.g. http://localhost:19100/auth/activate). Cannot register without email activation.');
|
|
51
133
|
}
|
|
52
134
|
|
|
53
|
-
|
|
135
|
+
// The key rides along in the link so it comes back to us when the user
|
|
136
|
+
// clicks — there is nowhere else to keep it: activation happens in a
|
|
137
|
+
// different browser, days later, with no session.
|
|
138
|
+
var activationLink = withToken(activationUrl, activationToken);
|
|
139
|
+
if (workflowKey) activationLink += '&workflowKey=' + encodeURIComponent(workflowKey);
|
|
54
140
|
const html = '<h2>Activate your account</h2>'
|
|
55
141
|
+ '<p>Hi ' + (name || 'there') + ',</p>'
|
|
56
142
|
+ '<p>Click below to activate your account:</p>'
|
|
@@ -58,6 +144,8 @@ async function register({ email, phoneNumber, name, password }) {
|
|
|
58
144
|
|
|
59
145
|
await sendEmail(email, 'Activate your account', html);
|
|
60
146
|
|
|
147
|
+
await hooks.fire('user', 'create', user.id);
|
|
148
|
+
|
|
61
149
|
return {
|
|
62
150
|
id: user.id,
|
|
63
151
|
email: user.email,
|
|
@@ -68,16 +156,29 @@ async function register({ email, phoneNumber, name, password }) {
|
|
|
68
156
|
};
|
|
69
157
|
}
|
|
70
158
|
|
|
71
|
-
async function activate(token) {
|
|
159
|
+
async function activate(token, options) {
|
|
160
|
+
var workflowKey = options && options.workflowKey;
|
|
161
|
+
var log = require('@xeplr/logs').createLogger('auth');
|
|
162
|
+
|
|
163
|
+
// THE TOKEN IS NEVER LOGGED. It is a bearer credential — anyone holding the
|
|
164
|
+
// string can activate that account — and a log line is the one place it
|
|
165
|
+
// would sit in plain text long after the token itself was nulled. That a
|
|
166
|
+
// token arrived, and how it ended, is what is worth recording.
|
|
167
|
+
log.info('activation: token received' + (workflowKey ? ', workflowKey "' + workflowKey + '"' : ', no workflowKey'));
|
|
168
|
+
|
|
72
169
|
const user = await User.query().findOne({ activationToken: token });
|
|
73
170
|
if (!user) {
|
|
171
|
+
log.warn('activation: no user holds that token — expired, already used, or wrong link');
|
|
74
172
|
throw new Error('Invalid activation token');
|
|
75
173
|
}
|
|
76
174
|
|
|
77
175
|
if (user.isActivated) {
|
|
176
|
+
log.info('activation: ' + user.email + ' is already activated — nothing to do');
|
|
78
177
|
throw new Error('Account already activated');
|
|
79
178
|
}
|
|
80
179
|
|
|
180
|
+
log.info('activation: activating ' + user.email);
|
|
181
|
+
|
|
81
182
|
const now = formatDbDateTime();
|
|
82
183
|
await User.query().findById(user.id).patch({
|
|
83
184
|
isActivated: true,
|
|
@@ -86,12 +187,31 @@ async function activate(token) {
|
|
|
86
187
|
recordModifiedDate: now
|
|
87
188
|
});
|
|
88
189
|
|
|
190
|
+
log.important('activation: ' + user.email + ' activated');
|
|
191
|
+
await hooks.fire('user', 'update', user.id);
|
|
192
|
+
|
|
193
|
+
// AFTER the patch, never before: a workflow released by this step may go on
|
|
194
|
+
// to grant roles or send a welcome mail, and it must not act on an account
|
|
195
|
+
// that has not actually been activated yet.
|
|
196
|
+
if (workflowKey) {
|
|
197
|
+
log.info('activation: resuming workflow "' + workflowKey + '"');
|
|
198
|
+
var resumed = await resumeWorkflow(workflowKey,
|
|
199
|
+
{ userId: user.id, email: user.email, name: user.name });
|
|
200
|
+
if (resumed) {
|
|
201
|
+
log.important('activation: workflow "' + workflowKey + '" resumed (run ' +
|
|
202
|
+
(resumed.runId || '?') + ', step ' + (resumed.stepKey || '?') + ')');
|
|
203
|
+
}
|
|
204
|
+
// The failure path already logged its own reason inside resumeWorkflow.
|
|
205
|
+
}
|
|
206
|
+
|
|
89
207
|
return { id: user.id, email: user.email, name: user.name };
|
|
90
208
|
}
|
|
91
209
|
|
|
92
210
|
async function login({ email, password }) {
|
|
93
211
|
const normalized = normalizeEmail(email);
|
|
94
|
-
const user = await User.query()
|
|
212
|
+
const user = await User.query()
|
|
213
|
+
.findOne({ normalizedEmail: normalized })
|
|
214
|
+
.withGraphFetched('roles');
|
|
95
215
|
if (!user) {
|
|
96
216
|
throw new Error('Invalid email or password');
|
|
97
217
|
}
|
|
@@ -109,11 +229,22 @@ async function login({ email, password }) {
|
|
|
109
229
|
throw new Error('NOT_ACTIVATED');
|
|
110
230
|
}
|
|
111
231
|
|
|
112
|
-
|
|
232
|
+
// Pack role names into the JWT so middleware (e.g. requireSuperAdmin)
|
|
233
|
+
// can gate without a per-request DB lookup.
|
|
234
|
+
const tokenUser = {
|
|
235
|
+
id: user.id,
|
|
236
|
+
email: user.email,
|
|
237
|
+
name: user.name,
|
|
238
|
+
roles: (user.roles || []).map(function(r) { return r.name; })
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const { accessToken, refreshToken } = await createSession(tokenUser);
|
|
113
242
|
|
|
114
243
|
await clearUserAccess(user.id);
|
|
115
244
|
const access = await getUserAccess(user.id);
|
|
116
245
|
|
|
246
|
+
await hooks.fire('user', 'login', user.id);
|
|
247
|
+
|
|
117
248
|
return {
|
|
118
249
|
accessToken,
|
|
119
250
|
refreshToken,
|
|
@@ -127,6 +258,125 @@ async function login({ email, password }) {
|
|
|
127
258
|
};
|
|
128
259
|
}
|
|
129
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Invite a user. Creates a User row with no password, isActivated=false, and an
|
|
263
|
+
* activation token. Sends an "accept invite" email pointing the user to a page
|
|
264
|
+
* where they set their own password and activate.
|
|
265
|
+
*
|
|
266
|
+
* Idempotent on email: if the user already exists, returns the existing user
|
|
267
|
+
* (no email sent, no password reset). The caller can then layer additional
|
|
268
|
+
* roles/mappings on top.
|
|
269
|
+
*/
|
|
270
|
+
async function invite({ email, name, phoneNumber, invitedBy, inviterName, appName, knex }) {
|
|
271
|
+
const UserM = knex ? User.bindKnex(knex) : User;
|
|
272
|
+
const normalized = normalizeEmail(email);
|
|
273
|
+
|
|
274
|
+
const existing = await UserM.query().findOne({ normalizedEmail: normalized });
|
|
275
|
+
if (existing) {
|
|
276
|
+
return {
|
|
277
|
+
id: existing.id,
|
|
278
|
+
email: existing.email,
|
|
279
|
+
name: existing.name,
|
|
280
|
+
isNew: false,
|
|
281
|
+
isActivated: existing.isActivated
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const id = generateId();
|
|
286
|
+
const now = formatDbDateTime();
|
|
287
|
+
const activationToken = crypto.randomBytes(32).toString('hex');
|
|
288
|
+
const inviter = invitedBy || id;
|
|
289
|
+
|
|
290
|
+
const user = await UserM.query().insert({
|
|
291
|
+
id,
|
|
292
|
+
email: email.trim().toLowerCase(),
|
|
293
|
+
normalizedEmail: normalized,
|
|
294
|
+
phoneNumber: phoneNumber || null,
|
|
295
|
+
name: name || null,
|
|
296
|
+
pwd: null,
|
|
297
|
+
pwdSalt: null,
|
|
298
|
+
isActive: true,
|
|
299
|
+
isActivated: false,
|
|
300
|
+
activationToken,
|
|
301
|
+
activatedOn: null,
|
|
302
|
+
activatedBy: null,
|
|
303
|
+
recordCreatedDate: now,
|
|
304
|
+
recordModifiedDate: now,
|
|
305
|
+
recordCreatedBy: inviter,
|
|
306
|
+
recordModifiedBy: inviter
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const inviteUrl = linkFor('invite');
|
|
310
|
+
if (!inviteUrl) {
|
|
311
|
+
throw new Error('AUTH_INVITE_URL is required to send invite emails (the full address of your ' +
|
|
312
|
+
'accept-invite page, e.g. http://localhost:19100/auth/accept-invite).');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const acceptLink = withToken(inviteUrl, activationToken);
|
|
316
|
+
const app = appName || 'the app';
|
|
317
|
+
const subject = 'You have been invited to ' + app;
|
|
318
|
+
const html = '<h2>You have been invited</h2>'
|
|
319
|
+
+ '<p>Hi ' + (name || 'there') + ',</p>'
|
|
320
|
+
+ (inviterName ? '<p>' + inviterName + ' has invited you to join ' + app + '.</p>' : '<p>You have been invited to join ' + app + '.</p>')
|
|
321
|
+
+ '<p>Click below to set your password and accept the invite:</p>'
|
|
322
|
+
+ '<p><a href="' + acceptLink + '">' + acceptLink + '</a></p>';
|
|
323
|
+
|
|
324
|
+
await sendEmail(email, subject, html);
|
|
325
|
+
|
|
326
|
+
await hooks.fire('user', 'create', user.id);
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
id: user.id,
|
|
330
|
+
email: user.email,
|
|
331
|
+
name: user.name,
|
|
332
|
+
isNew: true,
|
|
333
|
+
isActivated: false
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Preview an invite — returns invitee info if the token matches a pending
|
|
339
|
+
* invite (user exists, is not activated, and has no password set yet).
|
|
340
|
+
*/
|
|
341
|
+
async function previewInvite(token, options = {}) {
|
|
342
|
+
const UserM = options.knex ? User.bindKnex(options.knex) : User;
|
|
343
|
+
const user = await UserM.query().findOne({ activationToken: token });
|
|
344
|
+
if (!user) throw new Error('Invalid or expired invite');
|
|
345
|
+
if (user.isActivated) throw new Error('Invite already accepted');
|
|
346
|
+
if (user.pwd) throw new Error('This account already has a password — use the activation link instead');
|
|
347
|
+
|
|
348
|
+
return { email: user.email, name: user.name };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Accept an invite — sets the user's password and name, then activates.
|
|
353
|
+
*/
|
|
354
|
+
async function acceptInvite({ token, name, password, knex }) {
|
|
355
|
+
if (!password) throw new Error('Password is required');
|
|
356
|
+
|
|
357
|
+
const UserM = knex ? User.bindKnex(knex) : User;
|
|
358
|
+
const user = await UserM.query().findOne({ activationToken: token });
|
|
359
|
+
if (!user) throw new Error('Invalid or expired invite');
|
|
360
|
+
if (user.isActivated) throw new Error('Invite already accepted');
|
|
361
|
+
|
|
362
|
+
const { hash, salt } = await hashPassword(password);
|
|
363
|
+
const now = formatDbDateTime();
|
|
364
|
+
|
|
365
|
+
await UserM.query().findById(user.id).patch({
|
|
366
|
+
name: name || user.name,
|
|
367
|
+
pwd: hash,
|
|
368
|
+
pwdSalt: salt,
|
|
369
|
+
isActivated: true,
|
|
370
|
+
activatedOn: now,
|
|
371
|
+
activationToken: null,
|
|
372
|
+
recordModifiedDate: now
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await hooks.fire('user', 'update', user.id);
|
|
376
|
+
|
|
377
|
+
return { id: user.id, email: user.email, name: name || user.name };
|
|
378
|
+
}
|
|
379
|
+
|
|
130
380
|
async function forgotPassword({ email }, resetBaseUrl) {
|
|
131
381
|
const normalized = normalizeEmail(email);
|
|
132
382
|
const user = await User.query().findOne({ normalizedEmail: normalized });
|
|
@@ -135,7 +385,7 @@ async function forgotPassword({ email }, resetBaseUrl) {
|
|
|
135
385
|
}
|
|
136
386
|
|
|
137
387
|
const resetToken = crypto.randomBytes(32).toString('hex');
|
|
138
|
-
const expiryMinutes = parseInt(process.env.
|
|
388
|
+
const expiryMinutes = parseInt(process.env.AUTH_RESET_TOKEN_EXPIRY_MINUTES) || 30;
|
|
139
389
|
const resetTokenExpiry = formatDbDateTime(new Date(Date.now() + expiryMinutes * 60 * 1000));
|
|
140
390
|
|
|
141
391
|
await User.query().findById(user.id).patch({
|
|
@@ -156,6 +406,8 @@ async function forgotPassword({ email }, resetBaseUrl) {
|
|
|
156
406
|
`;
|
|
157
407
|
|
|
158
408
|
await sendEmail(user.email, 'Password Reset', html);
|
|
409
|
+
|
|
410
|
+
await hooks.fire('user', 'update', user.id);
|
|
159
411
|
}
|
|
160
412
|
|
|
161
413
|
async function resetPassword({ token, newPassword }) {
|
|
@@ -181,14 +433,194 @@ async function resetPassword({ token, newPassword }) {
|
|
|
181
433
|
|
|
182
434
|
await destroyAllUserSessions(user.id);
|
|
183
435
|
await clearUserAccess(user.id);
|
|
436
|
+
|
|
437
|
+
await hooks.fire('user', 'update', user.id);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Send an OTP to verify a phone number on an already-authenticated user's
|
|
442
|
+
* account (additive — like email activation, not a replacement for password
|
|
443
|
+
* login). Delivery + code storage are @xeplr/utils's otp module — register an
|
|
444
|
+
* sms provider (see @xeplr/utils sms.register()) from your own boot script
|
|
445
|
+
* before calling boot(), otherwise this throws "provider not registered".
|
|
446
|
+
*
|
|
447
|
+
* @param {string} userId
|
|
448
|
+
* @param {string} [phoneNumber] - set/replace the number on file; omit to
|
|
449
|
+
* re-send a code to the number already on the account.
|
|
450
|
+
*/
|
|
451
|
+
async function requestPhoneOtp(userId, phoneNumber) {
|
|
452
|
+
const user = await User.query().findById(userId);
|
|
453
|
+
if (!user) throw new Error('User not found');
|
|
454
|
+
|
|
455
|
+
const targetPhone = phoneNumber || user.phoneNumber;
|
|
456
|
+
if (!targetPhone) throw new Error('phoneNumber is required');
|
|
457
|
+
|
|
458
|
+
if (phoneNumber && phoneNumber !== user.phoneNumber) {
|
|
459
|
+
// Changing the number on file invalidates any prior verification.
|
|
460
|
+
await User.query().findById(userId).patch({
|
|
461
|
+
phoneNumber: targetPhone,
|
|
462
|
+
phoneVerified: false,
|
|
463
|
+
recordModifiedDate: formatDbDateTime()
|
|
464
|
+
});
|
|
465
|
+
await hooks.fire('user', 'update', userId);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
await otp.requestOtp(targetPhone);
|
|
469
|
+
return { sent: true };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Verify a code against the phone number currently on file for this user.
|
|
474
|
+
*/
|
|
475
|
+
async function verifyPhoneOtp(userId, code) {
|
|
476
|
+
const user = await User.query().findById(userId);
|
|
477
|
+
if (!user) throw new Error('User not found');
|
|
478
|
+
if (!user.phoneNumber) throw new Error('No phone number on file — request a code first');
|
|
479
|
+
|
|
480
|
+
const result = await otp.verifyOtp(user.phoneNumber, code);
|
|
481
|
+
if (!result.success) {
|
|
482
|
+
const messages = {
|
|
483
|
+
expired: 'Code expired — request a new one',
|
|
484
|
+
too_many_attempts: 'Too many attempts — request a new code',
|
|
485
|
+
invalid: 'Invalid code'
|
|
486
|
+
};
|
|
487
|
+
throw new Error(messages[result.reason] || 'Invalid code');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const now = formatDbDateTime();
|
|
491
|
+
await User.query().findById(userId).patch({
|
|
492
|
+
phoneVerified: true,
|
|
493
|
+
phoneVerifiedOn: now,
|
|
494
|
+
recordModifiedDate: now
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await hooks.fire('user', 'update', userId);
|
|
498
|
+
|
|
499
|
+
return { phoneVerified: true };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function shapeProfile(user) {
|
|
503
|
+
return {
|
|
504
|
+
id: user.id,
|
|
505
|
+
email: user.email,
|
|
506
|
+
name: user.name,
|
|
507
|
+
phoneNumber: user.phoneNumber,
|
|
508
|
+
phoneVerified: user.phoneVerified,
|
|
509
|
+
profilePicUrl: user.profilePicUrl,
|
|
510
|
+
isActivated: user.isActivated
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function getProfile(userId) {
|
|
515
|
+
const user = await User.query().findById(userId);
|
|
516
|
+
if (!user) throw new Error('User not found');
|
|
517
|
+
return shapeProfile(user);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Update the current user's own name/phoneNumber/email. Changing phoneNumber
|
|
522
|
+
* clears phoneVerified (same rule as requestPhoneOtp — a new number needs its
|
|
523
|
+
* own verification). Changing email re-checks uniqueness (same as register())
|
|
524
|
+
* but does NOT require re-activation — that's a bigger flow this doesn't touch.
|
|
525
|
+
*/
|
|
526
|
+
async function updateProfile(userId, fields) {
|
|
527
|
+
const user = await User.query().findById(userId);
|
|
528
|
+
if (!user) throw new Error('User not found');
|
|
529
|
+
|
|
530
|
+
const data = {};
|
|
531
|
+
|
|
532
|
+
if (fields.name !== undefined) {
|
|
533
|
+
data.name = fields.name;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (fields.phoneNumber !== undefined && fields.phoneNumber !== user.phoneNumber) {
|
|
537
|
+
data.phoneNumber = fields.phoneNumber;
|
|
538
|
+
data.phoneVerified = false;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (fields.email !== undefined) {
|
|
542
|
+
const email = fields.email.trim().toLowerCase();
|
|
543
|
+
if (email !== user.email) {
|
|
544
|
+
const normalized = normalizeEmail(email);
|
|
545
|
+
const existing = await User.query().findOne({ normalizedEmail: normalized });
|
|
546
|
+
if (existing && existing.id !== userId) {
|
|
547
|
+
throw new Error('Email already in use');
|
|
548
|
+
}
|
|
549
|
+
data.email = email;
|
|
550
|
+
data.normalizedEmail = normalized;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
data.recordModifiedDate = formatDbDateTime();
|
|
555
|
+
|
|
556
|
+
await User.query().findById(userId).patch(data);
|
|
557
|
+
await hooks.fire('user', 'update', userId);
|
|
558
|
+
|
|
559
|
+
const updated = await User.query().findById(userId);
|
|
560
|
+
return shapeProfile(updated);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Store an already-uploaded avatar's path/URL on the user's profile. The
|
|
565
|
+
* upload itself happens in the router via @xeplr/utils's FileUploader — this
|
|
566
|
+
* just persists the result, same as any other profile field.
|
|
567
|
+
*/
|
|
568
|
+
async function setProfilePic(userId, profilePicUrl) {
|
|
569
|
+
await User.query().findById(userId).patch({
|
|
570
|
+
profilePicUrl: profilePicUrl,
|
|
571
|
+
recordModifiedDate: formatDbDateTime()
|
|
572
|
+
});
|
|
573
|
+
await hooks.fire('user', 'update', userId);
|
|
574
|
+
const updated = await User.query().findById(userId);
|
|
575
|
+
return shapeProfile(updated);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Self-service password change for an already-authenticated user (distinct
|
|
580
|
+
* from resetPassword, which is for a user who's locked out and uses an
|
|
581
|
+
* emailed token instead of their current session). Requires the current
|
|
582
|
+
* password. Destroys all other sessions afterward — same as resetPassword —
|
|
583
|
+
* so the caller should expect to need to log back in.
|
|
584
|
+
*/
|
|
585
|
+
async function changePassword(userId, { oldPassword, newPassword }) {
|
|
586
|
+
if (!oldPassword || !newPassword) throw new Error('oldPassword and newPassword are required');
|
|
587
|
+
|
|
588
|
+
const user = await User.query().findById(userId);
|
|
589
|
+
if (!user) throw new Error('User not found');
|
|
590
|
+
|
|
591
|
+
const isMatch = await comparePassword(oldPassword, user.pwd);
|
|
592
|
+
if (!isMatch) throw new Error('Current password is incorrect');
|
|
593
|
+
|
|
594
|
+
const { hash, salt } = await hashPassword(newPassword);
|
|
595
|
+
|
|
596
|
+
await User.query().findById(userId).patch({
|
|
597
|
+
pwd: hash,
|
|
598
|
+
pwdSalt: salt,
|
|
599
|
+
recordModifiedDate: formatDbDateTime()
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
await destroyAllUserSessions(userId);
|
|
603
|
+
await clearUserAccess(userId);
|
|
604
|
+
|
|
605
|
+
await hooks.fire('user', 'update', userId);
|
|
184
606
|
}
|
|
185
607
|
|
|
186
608
|
module.exports = {
|
|
187
609
|
configureActivation,
|
|
610
|
+
configureWorkflowResume,
|
|
188
611
|
configureEmail,
|
|
189
612
|
register,
|
|
190
613
|
activate,
|
|
614
|
+
invite,
|
|
615
|
+
previewInvite,
|
|
616
|
+
acceptInvite,
|
|
191
617
|
login,
|
|
192
618
|
forgotPassword,
|
|
193
|
-
resetPassword
|
|
619
|
+
resetPassword,
|
|
620
|
+
requestPhoneOtp,
|
|
621
|
+
verifyPhoneOtp,
|
|
622
|
+
getProfile,
|
|
623
|
+
updateProfile,
|
|
624
|
+
setProfilePic,
|
|
625
|
+
changePassword
|
|
194
626
|
};
|
package/lib/hooks.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// One generic hook dispatcher for the whole library. Every mutating operation
|
|
2
|
+
// in xeplr-auth (register, activate, role CRUD, mapping toggles, master CRUD,
|
|
3
|
+
// ...) fires through here, tagged with an identifier ('user', 'roles', 'apis',
|
|
4
|
+
// 'uiPages', 'uiElements', 'menus', 'userRolesMapping', ...) and a fixed event
|
|
5
|
+
// ('create' | 'update' | 'delete', plus 'login' — not a CRUD event, but kept
|
|
6
|
+
// on 'user' since it's a meaningful lifecycle moment too).
|
|
7
|
+
//
|
|
8
|
+
// The identifiers are internal to the library (see the fire() call sites for
|
|
9
|
+
// the authoritative list) — you don't need a pre-built namespace object to use
|
|
10
|
+
// one, just the string, documented here and at each call site:
|
|
11
|
+
// require('@xeplr/auth').hooks.on('user', 'create', function(id) { ... });
|
|
12
|
+
// require('@xeplr/auth').hooks.on('roles', 'create', function(id) { ... });
|
|
13
|
+
//
|
|
14
|
+
// One handler per (identifier, event) — registering again REPLACES the
|
|
15
|
+
// previous one, not an accumulating list. No handler registered = silent
|
|
16
|
+
// no-op. A failing handler is caught + logged, never breaks the operation
|
|
17
|
+
// that fired it.
|
|
18
|
+
//
|
|
19
|
+
// setHooksEnabled(false) is a bulk kill-switch (e.g. for tests) — defaults to
|
|
20
|
+
// enabled (AUTH_ENABLE_HOOKS=false to default it off via env instead).
|
|
21
|
+
//
|
|
22
|
+
// NOTE on process boundaries: auth normally runs as its own service (AUTH_PORT),
|
|
23
|
+
// a separate process from your app's API. A hook registered from the API
|
|
24
|
+
// process is never seen here — register()/login()/etc. run in the auth
|
|
25
|
+
// service's own process. Assign hooks in the SAME script that calls boot():
|
|
26
|
+
// write your own tiny boot file (copy bin/server.js) that does
|
|
27
|
+
// require('@xeplr/auth').hooks.on('user', 'create', fn);
|
|
28
|
+
// require('@xeplr/auth').boot();
|
|
29
|
+
// and point your start-auth script at that file instead of the packaged bin.
|
|
30
|
+
|
|
31
|
+
var _enabled = process.env.AUTH_ENABLE_HOOKS !== 'false';
|
|
32
|
+
var _handlers = {};
|
|
33
|
+
|
|
34
|
+
function key(identifier, event) { return identifier + ':' + event; }
|
|
35
|
+
|
|
36
|
+
function setHooksEnabled(enabled) { _enabled = !!enabled; }
|
|
37
|
+
|
|
38
|
+
function on(identifier, event, fn) {
|
|
39
|
+
_handlers[key(identifier, event)] = fn;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fire(identifier, event, id) {
|
|
43
|
+
if (!_enabled) return;
|
|
44
|
+
var fn = _handlers[key(identifier, event)];
|
|
45
|
+
if (!fn) return;
|
|
46
|
+
try { await fn(id); }
|
|
47
|
+
catch (err) { console.error('[xeplr-auth] hook (' + identifier + '.' + event + ') failed:', err.message); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { on, fire, setHooksEnabled };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { getMtConfig } = require('@xeplr/db');
|
|
2
|
+
const UserTenantsMapping = require('../models/UserTenantsMapping');
|
|
3
|
+
|
|
4
|
+
var SLOT_KEYS = ['l1', 'l2', 'l3', 'l4'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rejects a request whose MT header values don't match a real membership
|
|
8
|
+
* grant for the caller — mtMiddleware (in @xeplr/db) trusts any header value
|
|
9
|
+
* to filter/stamp rows, this is what actually checks the caller is allowed to
|
|
10
|
+
* use it. Mount AFTER authMiddleware (needs req.user.id) and AFTER
|
|
11
|
+
* @xeplr/db's mtMiddleware() (reads the same headers).
|
|
12
|
+
*
|
|
13
|
+
* A header that's simply absent is left alone — the existing fail-closed
|
|
14
|
+
* query modifier in BaseModel already handles "missing" (empty result set).
|
|
15
|
+
* This only rejects a PRESENT but unauthorized value (403).
|
|
16
|
+
*
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {typeof UserTenantsMapping} [options.userTenantsMapping] - a bound
|
|
19
|
+
* model class, e.g. `auth.attach().model('UserTenantsMapping')` when auth
|
|
20
|
+
* runs as its own standalone service. Omit if you called auth.init() in
|
|
21
|
+
* this same process (Model.knex is already globally bound there).
|
|
22
|
+
*
|
|
23
|
+
* var { mtMembershipMiddleware } = require('@xeplr/auth');
|
|
24
|
+
* app.use(mtMiddleware()); // @xeplr/db — sets mtId context
|
|
25
|
+
* app.use(mtMembershipMiddleware()); // this — rejects forged values
|
|
26
|
+
*/
|
|
27
|
+
function mtMembershipMiddleware(options) {
|
|
28
|
+
options = options || {};
|
|
29
|
+
var Model = options.userTenantsMapping || UserTenantsMapping;
|
|
30
|
+
|
|
31
|
+
return async function(req, res, next) {
|
|
32
|
+
try {
|
|
33
|
+
var mtConfig = getMtConfig();
|
|
34
|
+
if (!mtConfig.enabled || !req.user || !req.user.id) return next();
|
|
35
|
+
|
|
36
|
+
for (var i = 0; i < SLOT_KEYS.length; i++) {
|
|
37
|
+
var key = SLOT_KEYS[i];
|
|
38
|
+
var slot = mtConfig.slots[key];
|
|
39
|
+
if (!slot) continue;
|
|
40
|
+
|
|
41
|
+
var value = req.headers[slot.header];
|
|
42
|
+
if (!value) continue;
|
|
43
|
+
|
|
44
|
+
var grant = await Model.query()
|
|
45
|
+
.where({ userId: req.user.id, level: key, value: value, isActive: true })
|
|
46
|
+
.first();
|
|
47
|
+
|
|
48
|
+
if (!grant) {
|
|
49
|
+
return res.status(403).json({ error: 'Not authorized for ' + (slot.name || key) + ' "' + value + '"' });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
next();
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (req.log) req.log.error(err.message, { stack: err.stack });
|
|
56
|
+
res.status(500).json({ error: 'Something went wrong' });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = mtMembershipMiddleware;
|