@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.
Files changed (59) hide show
  1. package/bin/migrate.js +58 -25
  2. package/bin/server.js +12 -30
  3. package/index.js +188 -48
  4. package/lib/adminRouter.js +20 -92
  5. package/lib/authHelper.js +26 -3
  6. package/lib/authMiddleware.js +39 -13
  7. package/lib/authRouter.js +144 -12
  8. package/lib/authService.js +448 -16
  9. package/lib/hooks.js +50 -0
  10. package/lib/mtMembershipMiddleware.js +61 -0
  11. package/lib/seed.js +74 -0
  12. package/lib/sessionService.js +54 -6
  13. package/lib/ticketService.js +88 -0
  14. package/lib/tokenDecision.js +45 -0
  15. package/migrations/0001_extensions.sql +9 -0
  16. package/migrations/0002_users.sql +31 -0
  17. package/migrations/0003_catalog_tables.sql +82 -0
  18. package/migrations/0004_role_mappings.sql +78 -0
  19. package/migrations/0005_user_tenants_mapping.sql +39 -0
  20. package/migrations/0006_seed_catalog.sql +120 -0
  21. package/models/Api.js +3 -0
  22. package/models/ApisRolesMapping.js +3 -0
  23. package/models/Menu.js +3 -0
  24. package/models/MenuRolesMapping.js +3 -0
  25. package/models/Role.js +8 -0
  26. package/models/UiElement.js +3 -0
  27. package/models/UiElementsRolesMapping.js +3 -0
  28. package/models/UiPage.js +3 -0
  29. package/models/UiPagesRolesMapping.js +3 -0
  30. package/models/User.js +3 -13
  31. package/models/UserRolesMapping.js +3 -0
  32. package/models/UserTenantsMapping.js +20 -2
  33. package/models/index.js +0 -2
  34. package/package.json +26 -5
  35. package/migrations/0001_users.js +0 -22
  36. package/migrations/0002_menus.js +0 -16
  37. package/migrations/0003_apis.js +0 -16
  38. package/migrations/0004_uiPages.js +0 -16
  39. package/migrations/0005_uiElements.js +0 -16
  40. package/migrations/0006_roles.js +0 -15
  41. package/migrations/0007_apisRolesMapping.js +0 -16
  42. package/migrations/0008_uiPagesRolesMapping.js +0 -16
  43. package/migrations/0009_uiElementsRolesMapping.js +0 -16
  44. package/migrations/0010_menuRolesMapping.js +0 -16
  45. package/migrations/0011_userRolesMapping.js +0 -16
  46. package/migrations/0012_users_add_reset_token.js +0 -13
  47. package/migrations/0013_add_isPublic.js +0 -27
  48. package/migrations/0014_users_add_activation_token.js +0 -13
  49. package/migrations/0015_add_mt_columns.js +0 -41
  50. package/migrations/0016_tenants.js +0 -23
  51. package/migrations/0017_userTenantsMapping.js +0 -20
  52. package/models/Tenant.js +0 -63
  53. package/seeds/001_roles.js +0 -26
  54. package/seeds/002_apis.js +0 -70
  55. package/seeds/003_pages.js +0 -41
  56. package/seeds/004_elements.js +0 -46
  57. package/seeds/005_menus.js +0 -35
  58. package/seeds/006_default_tenant.js +0 -50
  59. package/seeds/zzz_admin_access.js +0 -49
@@ -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
- let _activationBaseUrl = null;
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 configureActivation(baseUrl) {
12
- _activationBaseUrl = baseUrl;
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
- async function register({ email, phoneNumber, name, password }) {
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 User.query().findOne({ normalizedEmail: normalized });
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 User.query().insert({
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 baseUrl = _activationBaseUrl || process.env.ACTIVATION_BASE_URL;
49
- if (!baseUrl) {
50
- throw new Error('ACTIVATION_BASE_URL is required. Cannot register without email activation.');
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
- const activationLink = baseUrl + '/auth/activate?token=' + activationToken;
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().findOne({ normalizedEmail: normalized });
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
- const { accessToken, refreshToken } = await createSession(user);
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.RESET_TOKEN_EXPIRY_MINUTES) || 30;
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;