@xeplr/auth 1.0.0 → 1.0.1
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 +43 -24
- package/bin/server.js +12 -30
- package/index.js +135 -51
- package/lib/adminRouter.js +20 -92
- package/lib/authHelper.js +26 -3
- package/lib/authMiddleware.js +39 -13
- package/lib/authRouter.js +140 -11
- package/lib/authService.js +327 -9
- 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/authMiddleware.js
CHANGED
|
@@ -1,27 +1,53 @@
|
|
|
1
|
-
const {
|
|
2
|
-
const { validateAccessToken } = require('./sessionService');
|
|
1
|
+
const { decodeVerified, getToleranceSeconds } = require('./authHelper');
|
|
2
|
+
const { validateAccessToken, slideAccessToken } = require('./sessionService');
|
|
3
|
+
const { decide } = require('./tokenDecision');
|
|
3
4
|
|
|
5
|
+
// The ONE place token logic lives. Order (from tokenDecision): signature →
|
|
6
|
+
// revocation → expiry. Tolerance (ACCESS_TOKEN_TOLERANCE_SECONDS, default 0)
|
|
7
|
+
// turns on the sliding refresh: an expired-but-recent token is served AND a
|
|
8
|
+
// fresh token is attached to the response (X-New-Token), set BEFORE the handler
|
|
9
|
+
// runs so it rides every response path. With tolerance 0 this is exactly the
|
|
10
|
+
// old behavior (reject on expiry).
|
|
4
11
|
async function authMiddleware(req, res, next) {
|
|
5
12
|
const authHeader = req.headers.authorization;
|
|
6
|
-
|
|
7
13
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
8
14
|
return res.status(401).json({ error: 'No token provided' });
|
|
9
15
|
}
|
|
10
|
-
|
|
11
16
|
const token = authHeader.split(' ')[1];
|
|
12
17
|
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
// Verify signature, ignore expiry (we decide on expiry ourselves).
|
|
19
|
+
const { decoded, signatureValid } = decodeVerified(token);
|
|
20
|
+
|
|
21
|
+
// Session liveness = the whitelist entry still exists (a logout deletes it).
|
|
22
|
+
// The entry outlives the token by `tolerance`, so it's present during grace.
|
|
23
|
+
const session = signatureValid ? await validateAccessToken(token) : null;
|
|
24
|
+
|
|
25
|
+
const decision = decide({
|
|
26
|
+
exp: decoded && decoded.exp,
|
|
27
|
+
now: Math.floor(Date.now() / 1000),
|
|
28
|
+
toleranceSeconds: getToleranceSeconds(),
|
|
29
|
+
sessionLive: !!session,
|
|
30
|
+
signatureValid: signatureValid
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (decision.action === 'reject') {
|
|
34
|
+
if (req.log) req.log.debug('auth reject: ' + decision.reason);
|
|
18
35
|
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
19
36
|
}
|
|
20
37
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
38
|
+
if (decision.action === 'slide') {
|
|
39
|
+
// Serve the request but hand back a fresh token. Header set BEFORE next()
|
|
40
|
+
// → present on every response. A slide failure must not block a live
|
|
41
|
+
// session — fall through and serve with the (still-tolerated) old token.
|
|
42
|
+
try {
|
|
43
|
+
const newToken = await slideAccessToken(token, decoded);
|
|
44
|
+
if (newToken) {
|
|
45
|
+
res.setHeader('X-New-Token', newToken);
|
|
46
|
+
res.setHeader('Access-Control-Expose-Headers', 'X-New-Token');
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (req.log) req.log.error('slide failed: ' + err.message);
|
|
50
|
+
}
|
|
25
51
|
}
|
|
26
52
|
|
|
27
53
|
req.user = decoded;
|
package/lib/authRouter.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
|
-
const
|
|
2
|
+
const FileUploader = require('@xeplr/utils/lib/fileUploader');
|
|
3
|
+
const { register, activate, previewInvite, acceptInvite, login, forgotPassword, resetPassword, requestPhoneOtp, verifyPhoneOtp, getProfile, updateProfile, setProfilePic, changePassword } = require('./authService');
|
|
3
4
|
const { getUserAccess } = require('./accessService');
|
|
4
5
|
const { getSession, rotateSession, destroySession } = require('./sessionService');
|
|
6
|
+
const { issueTicket } = require('./ticketService');
|
|
5
7
|
const authMiddleware = require('./authMiddleware');
|
|
6
8
|
const User = require('../models/User');
|
|
7
9
|
|
|
10
|
+
// Avatar uploads — local disk by default (FileUploader's own default:
|
|
11
|
+
// process.env.UPLOAD_DIR or ./uploads). Auth only persists the resulting
|
|
12
|
+
// path on the user; serving it back over HTTP is the consuming app's concern
|
|
13
|
+
// (same as any other uploaded asset), so wire a static route to the same
|
|
14
|
+
// directory wherever it makes sense for your deployment.
|
|
15
|
+
const avatarUploader = new FileUploader({ allowedTypes: ['image/*'], maxSize: 2 * 1024 * 1024 });
|
|
16
|
+
|
|
8
17
|
function logError(req, err) {
|
|
9
18
|
if (req.log) req.log.error(err.message, { stack: err.stack });
|
|
10
19
|
}
|
|
@@ -47,6 +56,32 @@ function createAuthRouter(options = {}) {
|
|
|
47
56
|
}
|
|
48
57
|
});
|
|
49
58
|
|
|
59
|
+
router.get('/invite-preview', async function(req, res) {
|
|
60
|
+
try {
|
|
61
|
+
var token = req.query.token;
|
|
62
|
+
if (!token) return res.status(400).json({ error: 'Token is required' });
|
|
63
|
+
var info = await previewInvite(token);
|
|
64
|
+
res.json(info);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
logError(req, err);
|
|
67
|
+
res.status(400).json({ error: err.message });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
router.post('/invite-accept', async function(req, res) {
|
|
72
|
+
try {
|
|
73
|
+
const { token, name, password } = req.body;
|
|
74
|
+
if (!token || !password) {
|
|
75
|
+
return res.status(400).json({ error: 'Token and password are required' });
|
|
76
|
+
}
|
|
77
|
+
const result = await acceptInvite({ token, name, password });
|
|
78
|
+
res.json({ message: 'Invite accepted', user: result });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logError(req, err);
|
|
81
|
+
res.status(400).json({ error: err.message });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
50
85
|
router.post('/login', async function(req, res) {
|
|
51
86
|
try {
|
|
52
87
|
const { email, password } = req.body;
|
|
@@ -115,12 +150,20 @@ function createAuthRouter(options = {}) {
|
|
|
115
150
|
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
|
116
151
|
}
|
|
117
152
|
|
|
118
|
-
const user = await User.query().findById(session.userId);
|
|
153
|
+
const user = await User.query().findById(session.userId).withGraphFetched('roles');
|
|
119
154
|
if (!user || !user.isActive) {
|
|
120
155
|
return res.status(401).json({ error: 'Account not found or deactivated' });
|
|
121
156
|
}
|
|
122
157
|
|
|
123
|
-
|
|
158
|
+
// Pack role names so refreshed tokens carry the same role claims as login.
|
|
159
|
+
const tokenUser = {
|
|
160
|
+
id: user.id,
|
|
161
|
+
email: user.email,
|
|
162
|
+
name: user.name,
|
|
163
|
+
roles: (user.roles || []).map(function(r) { return r.name; })
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const result = await rotateSession(refreshToken, tokenUser);
|
|
124
167
|
if (!result) {
|
|
125
168
|
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
|
126
169
|
}
|
|
@@ -172,17 +215,103 @@ function createAuthRouter(options = {}) {
|
|
|
172
215
|
}
|
|
173
216
|
});
|
|
174
217
|
|
|
175
|
-
|
|
176
|
-
router.get('/my-tenants', authMiddleware, async function(req, res) {
|
|
218
|
+
router.get('/profile', authMiddleware, async function(req, res) {
|
|
177
219
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
res.json(tenants.map(function(t) {
|
|
181
|
-
return { id: t.id, name: t.name, code: t.code, description: t.description };
|
|
182
|
-
}));
|
|
220
|
+
const profile = await getProfile(req.user.id);
|
|
221
|
+
res.json(profile);
|
|
183
222
|
} catch (err) {
|
|
184
223
|
logError(req, err);
|
|
185
|
-
res.status(
|
|
224
|
+
res.status(400).json({ error: err.message });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
router.put('/profile', authMiddleware, async function(req, res) {
|
|
229
|
+
try {
|
|
230
|
+
const profile = await updateProfile(req.user.id, req.body);
|
|
231
|
+
res.json(profile);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
logError(req, err);
|
|
234
|
+
res.status(400).json({ error: err.message });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
router.post('/profile/avatar', authMiddleware, ...avatarUploader.single('avatar'), async function(req, res) {
|
|
239
|
+
try {
|
|
240
|
+
if (!req.file) return res.status(400).json({ error: 'avatar file is required' });
|
|
241
|
+
// Store just the generated filename — never the server's filesystem
|
|
242
|
+
// path (leaks server layout, and isn't meaningful once you swap disk
|
|
243
|
+
// storage for S3/etc). The app knows its own UPLOAD_DIR and however
|
|
244
|
+
// it serves it; it builds the actual URL from this filename.
|
|
245
|
+
const profile = await setProfilePic(req.user.id, req.file.filename);
|
|
246
|
+
res.json(profile);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
logError(req, err);
|
|
249
|
+
res.status(400).json({ error: err.message });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
router.post('/change-password', authMiddleware, async function(req, res) {
|
|
254
|
+
try {
|
|
255
|
+
const { oldPassword, newPassword } = req.body;
|
|
256
|
+
if (!oldPassword || !newPassword) {
|
|
257
|
+
return res.status(400).json({ error: 'oldPassword and newPassword are required' });
|
|
258
|
+
}
|
|
259
|
+
await changePassword(req.user.id, { oldPassword, newPassword });
|
|
260
|
+
res.json({ message: 'Password changed. Please log in again.' });
|
|
261
|
+
} catch (err) {
|
|
262
|
+
logError(req, err);
|
|
263
|
+
res.status(400).json({ error: err.message });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Additive phone verification — like email activation, but for a phone
|
|
268
|
+
// number attached to an already-authenticated account. Body: { phoneNumber }
|
|
269
|
+
// optional (omit to re-send to the number already on file).
|
|
270
|
+
router.post('/request-phone-otp', authMiddleware, async function(req, res) {
|
|
271
|
+
try {
|
|
272
|
+
const result = await requestPhoneOtp(req.user.id, req.body.phoneNumber);
|
|
273
|
+
res.json(result);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
logError(req, err);
|
|
276
|
+
res.status(400).json({ error: err.message });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
router.post('/verify-phone-otp', authMiddleware, async function(req, res) {
|
|
281
|
+
try {
|
|
282
|
+
if (!req.body.code) return res.status(400).json({ error: 'code is required' });
|
|
283
|
+
const result = await verifyPhoneOtp(req.user.id, req.body.code);
|
|
284
|
+
res.json(result);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
logError(req, err);
|
|
287
|
+
res.status(400).json({ error: err.message });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── SSE ticket ────────────────────────────────────────────────────────
|
|
292
|
+
//
|
|
293
|
+
// Mints a short-lived, single-use ticket so a browser can open an
|
|
294
|
+
// EventSource without leaking the primary session token in the URL.
|
|
295
|
+
//
|
|
296
|
+
// Body (optional):
|
|
297
|
+
// { scopes: [...] } — client-requested scopes. If omitted, defaults
|
|
298
|
+
// to the user's own namespace (['user:me:*']).
|
|
299
|
+
// Whatever is minted here is what the SSE server
|
|
300
|
+
// will enforce at subscribe time.
|
|
301
|
+
//
|
|
302
|
+
// NOTE the scopes are advisory intent — the SSE handler owns the final
|
|
303
|
+
// authorization check (see xeplr-base-apis/lib/sse.js#createHandler).
|
|
304
|
+
router.post('/sse-ticket', authMiddleware, async function(req, res) {
|
|
305
|
+
try {
|
|
306
|
+
var requested = (req.body && Array.isArray(req.body.scopes)) ? req.body.scopes : ['user:me:*'];
|
|
307
|
+
var { ticket, expiresIn } = await issueTicket({
|
|
308
|
+
userId: req.user.id,
|
|
309
|
+
scopes: requested
|
|
310
|
+
});
|
|
311
|
+
res.json({ ticket: ticket, expiresIn: expiresIn });
|
|
312
|
+
} catch (err) {
|
|
313
|
+
logError(req, err);
|
|
314
|
+
res.status(500).json({ error: 'Failed to mint SSE ticket' });
|
|
186
315
|
}
|
|
187
316
|
});
|
|
188
317
|
|
package/lib/authService.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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');
|
|
8
9
|
|
|
9
10
|
let _activationBaseUrl = null;
|
|
10
11
|
|
|
@@ -12,10 +13,11 @@ function configureActivation(baseUrl) {
|
|
|
12
13
|
_activationBaseUrl = baseUrl;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
async function register({ email, phoneNumber, name, password }) {
|
|
16
|
+
async function register({ email, phoneNumber, name, password, knex }) {
|
|
17
|
+
const UserM = knex ? User.bindKnex(knex) : User;
|
|
16
18
|
const normalized = normalizeEmail(email);
|
|
17
19
|
|
|
18
|
-
const existing = await
|
|
20
|
+
const existing = await UserM.query().findOne({ normalizedEmail: normalized });
|
|
19
21
|
if (existing) {
|
|
20
22
|
throw new Error('Email already registered');
|
|
21
23
|
}
|
|
@@ -25,7 +27,7 @@ async function register({ email, phoneNumber, name, password }) {
|
|
|
25
27
|
const now = formatDbDateTime();
|
|
26
28
|
const activationToken = crypto.randomBytes(32).toString('hex');
|
|
27
29
|
|
|
28
|
-
const user = await
|
|
30
|
+
const user = await UserM.query().insert({
|
|
29
31
|
id,
|
|
30
32
|
email: email.trim().toLowerCase(),
|
|
31
33
|
normalizedEmail: normalized,
|
|
@@ -45,7 +47,7 @@ async function register({ email, phoneNumber, name, password }) {
|
|
|
45
47
|
});
|
|
46
48
|
|
|
47
49
|
// Send activation email
|
|
48
|
-
const baseUrl = _activationBaseUrl || process.env.
|
|
50
|
+
const baseUrl = _activationBaseUrl || process.env.AUTH_ACTIVATION_BASE_URL;
|
|
49
51
|
if (!baseUrl) {
|
|
50
52
|
throw new Error('ACTIVATION_BASE_URL is required. Cannot register without email activation.');
|
|
51
53
|
}
|
|
@@ -58,6 +60,8 @@ async function register({ email, phoneNumber, name, password }) {
|
|
|
58
60
|
|
|
59
61
|
await sendEmail(email, 'Activate your account', html);
|
|
60
62
|
|
|
63
|
+
await hooks.fire('user', 'create', user.id);
|
|
64
|
+
|
|
61
65
|
return {
|
|
62
66
|
id: user.id,
|
|
63
67
|
email: user.email,
|
|
@@ -86,12 +90,16 @@ async function activate(token) {
|
|
|
86
90
|
recordModifiedDate: now
|
|
87
91
|
});
|
|
88
92
|
|
|
93
|
+
await hooks.fire('user', 'update', user.id);
|
|
94
|
+
|
|
89
95
|
return { id: user.id, email: user.email, name: user.name };
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
async function login({ email, password }) {
|
|
93
99
|
const normalized = normalizeEmail(email);
|
|
94
|
-
const user = await User.query()
|
|
100
|
+
const user = await User.query()
|
|
101
|
+
.findOne({ normalizedEmail: normalized })
|
|
102
|
+
.withGraphFetched('roles');
|
|
95
103
|
if (!user) {
|
|
96
104
|
throw new Error('Invalid email or password');
|
|
97
105
|
}
|
|
@@ -109,11 +117,22 @@ async function login({ email, password }) {
|
|
|
109
117
|
throw new Error('NOT_ACTIVATED');
|
|
110
118
|
}
|
|
111
119
|
|
|
112
|
-
|
|
120
|
+
// Pack role names into the JWT so middleware (e.g. requireSuperAdmin)
|
|
121
|
+
// can gate without a per-request DB lookup.
|
|
122
|
+
const tokenUser = {
|
|
123
|
+
id: user.id,
|
|
124
|
+
email: user.email,
|
|
125
|
+
name: user.name,
|
|
126
|
+
roles: (user.roles || []).map(function(r) { return r.name; })
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const { accessToken, refreshToken } = await createSession(tokenUser);
|
|
113
130
|
|
|
114
131
|
await clearUserAccess(user.id);
|
|
115
132
|
const access = await getUserAccess(user.id);
|
|
116
133
|
|
|
134
|
+
await hooks.fire('user', 'login', user.id);
|
|
135
|
+
|
|
117
136
|
return {
|
|
118
137
|
accessToken,
|
|
119
138
|
refreshToken,
|
|
@@ -127,6 +146,124 @@ async function login({ email, password }) {
|
|
|
127
146
|
};
|
|
128
147
|
}
|
|
129
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Invite a user. Creates a User row with no password, isActivated=false, and an
|
|
151
|
+
* activation token. Sends an "accept invite" email pointing the user to a page
|
|
152
|
+
* where they set their own password and activate.
|
|
153
|
+
*
|
|
154
|
+
* Idempotent on email: if the user already exists, returns the existing user
|
|
155
|
+
* (no email sent, no password reset). The caller can then layer additional
|
|
156
|
+
* roles/mappings on top.
|
|
157
|
+
*/
|
|
158
|
+
async function invite({ email, name, phoneNumber, invitedBy, inviterName, appName, knex }) {
|
|
159
|
+
const UserM = knex ? User.bindKnex(knex) : User;
|
|
160
|
+
const normalized = normalizeEmail(email);
|
|
161
|
+
|
|
162
|
+
const existing = await UserM.query().findOne({ normalizedEmail: normalized });
|
|
163
|
+
if (existing) {
|
|
164
|
+
return {
|
|
165
|
+
id: existing.id,
|
|
166
|
+
email: existing.email,
|
|
167
|
+
name: existing.name,
|
|
168
|
+
isNew: false,
|
|
169
|
+
isActivated: existing.isActivated
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const id = generateId();
|
|
174
|
+
const now = formatDbDateTime();
|
|
175
|
+
const activationToken = crypto.randomBytes(32).toString('hex');
|
|
176
|
+
const inviter = invitedBy || id;
|
|
177
|
+
|
|
178
|
+
const user = await UserM.query().insert({
|
|
179
|
+
id,
|
|
180
|
+
email: email.trim().toLowerCase(),
|
|
181
|
+
normalizedEmail: normalized,
|
|
182
|
+
phoneNumber: phoneNumber || null,
|
|
183
|
+
name: name || null,
|
|
184
|
+
pwd: null,
|
|
185
|
+
pwdSalt: null,
|
|
186
|
+
isActive: true,
|
|
187
|
+
isActivated: false,
|
|
188
|
+
activationToken,
|
|
189
|
+
activatedOn: null,
|
|
190
|
+
activatedBy: null,
|
|
191
|
+
recordCreatedDate: now,
|
|
192
|
+
recordModifiedDate: now,
|
|
193
|
+
recordCreatedBy: inviter,
|
|
194
|
+
recordModifiedBy: inviter
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const baseUrl = _activationBaseUrl || process.env.AUTH_ACTIVATION_BASE_URL;
|
|
198
|
+
if (!baseUrl) {
|
|
199
|
+
throw new Error('ACTIVATION_BASE_URL is required to send invite emails.');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const acceptLink = baseUrl + '/auth/accept-invite?token=' + activationToken;
|
|
203
|
+
const app = appName || 'the app';
|
|
204
|
+
const subject = 'You have been invited to ' + app;
|
|
205
|
+
const html = '<h2>You have been invited</h2>'
|
|
206
|
+
+ '<p>Hi ' + (name || 'there') + ',</p>'
|
|
207
|
+
+ (inviterName ? '<p>' + inviterName + ' has invited you to join ' + app + '.</p>' : '<p>You have been invited to join ' + app + '.</p>')
|
|
208
|
+
+ '<p>Click below to set your password and accept the invite:</p>'
|
|
209
|
+
+ '<p><a href="' + acceptLink + '">' + acceptLink + '</a></p>';
|
|
210
|
+
|
|
211
|
+
await sendEmail(email, subject, html);
|
|
212
|
+
|
|
213
|
+
await hooks.fire('user', 'create', user.id);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
id: user.id,
|
|
217
|
+
email: user.email,
|
|
218
|
+
name: user.name,
|
|
219
|
+
isNew: true,
|
|
220
|
+
isActivated: false
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Preview an invite — returns invitee info if the token matches a pending
|
|
226
|
+
* invite (user exists, is not activated, and has no password set yet).
|
|
227
|
+
*/
|
|
228
|
+
async function previewInvite(token, options = {}) {
|
|
229
|
+
const UserM = options.knex ? User.bindKnex(options.knex) : User;
|
|
230
|
+
const user = await UserM.query().findOne({ activationToken: token });
|
|
231
|
+
if (!user) throw new Error('Invalid or expired invite');
|
|
232
|
+
if (user.isActivated) throw new Error('Invite already accepted');
|
|
233
|
+
if (user.pwd) throw new Error('This account already has a password — use the activation link instead');
|
|
234
|
+
|
|
235
|
+
return { email: user.email, name: user.name };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Accept an invite — sets the user's password and name, then activates.
|
|
240
|
+
*/
|
|
241
|
+
async function acceptInvite({ token, name, password, knex }) {
|
|
242
|
+
if (!password) throw new Error('Password is required');
|
|
243
|
+
|
|
244
|
+
const UserM = knex ? User.bindKnex(knex) : User;
|
|
245
|
+
const user = await UserM.query().findOne({ activationToken: token });
|
|
246
|
+
if (!user) throw new Error('Invalid or expired invite');
|
|
247
|
+
if (user.isActivated) throw new Error('Invite already accepted');
|
|
248
|
+
|
|
249
|
+
const { hash, salt } = await hashPassword(password);
|
|
250
|
+
const now = formatDbDateTime();
|
|
251
|
+
|
|
252
|
+
await UserM.query().findById(user.id).patch({
|
|
253
|
+
name: name || user.name,
|
|
254
|
+
pwd: hash,
|
|
255
|
+
pwdSalt: salt,
|
|
256
|
+
isActivated: true,
|
|
257
|
+
activatedOn: now,
|
|
258
|
+
activationToken: null,
|
|
259
|
+
recordModifiedDate: now
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await hooks.fire('user', 'update', user.id);
|
|
263
|
+
|
|
264
|
+
return { id: user.id, email: user.email, name: name || user.name };
|
|
265
|
+
}
|
|
266
|
+
|
|
130
267
|
async function forgotPassword({ email }, resetBaseUrl) {
|
|
131
268
|
const normalized = normalizeEmail(email);
|
|
132
269
|
const user = await User.query().findOne({ normalizedEmail: normalized });
|
|
@@ -135,7 +272,7 @@ async function forgotPassword({ email }, resetBaseUrl) {
|
|
|
135
272
|
}
|
|
136
273
|
|
|
137
274
|
const resetToken = crypto.randomBytes(32).toString('hex');
|
|
138
|
-
const expiryMinutes = parseInt(process.env.
|
|
275
|
+
const expiryMinutes = parseInt(process.env.AUTH_RESET_TOKEN_EXPIRY_MINUTES) || 30;
|
|
139
276
|
const resetTokenExpiry = formatDbDateTime(new Date(Date.now() + expiryMinutes * 60 * 1000));
|
|
140
277
|
|
|
141
278
|
await User.query().findById(user.id).patch({
|
|
@@ -156,6 +293,8 @@ async function forgotPassword({ email }, resetBaseUrl) {
|
|
|
156
293
|
`;
|
|
157
294
|
|
|
158
295
|
await sendEmail(user.email, 'Password Reset', html);
|
|
296
|
+
|
|
297
|
+
await hooks.fire('user', 'update', user.id);
|
|
159
298
|
}
|
|
160
299
|
|
|
161
300
|
async function resetPassword({ token, newPassword }) {
|
|
@@ -181,6 +320,176 @@ async function resetPassword({ token, newPassword }) {
|
|
|
181
320
|
|
|
182
321
|
await destroyAllUserSessions(user.id);
|
|
183
322
|
await clearUserAccess(user.id);
|
|
323
|
+
|
|
324
|
+
await hooks.fire('user', 'update', user.id);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Send an OTP to verify a phone number on an already-authenticated user's
|
|
329
|
+
* account (additive — like email activation, not a replacement for password
|
|
330
|
+
* login). Delivery + code storage are @xeplr/utils's otp module — register an
|
|
331
|
+
* sms provider (see @xeplr/utils sms.register()) from your own boot script
|
|
332
|
+
* before calling boot(), otherwise this throws "provider not registered".
|
|
333
|
+
*
|
|
334
|
+
* @param {string} userId
|
|
335
|
+
* @param {string} [phoneNumber] - set/replace the number on file; omit to
|
|
336
|
+
* re-send a code to the number already on the account.
|
|
337
|
+
*/
|
|
338
|
+
async function requestPhoneOtp(userId, phoneNumber) {
|
|
339
|
+
const user = await User.query().findById(userId);
|
|
340
|
+
if (!user) throw new Error('User not found');
|
|
341
|
+
|
|
342
|
+
const targetPhone = phoneNumber || user.phoneNumber;
|
|
343
|
+
if (!targetPhone) throw new Error('phoneNumber is required');
|
|
344
|
+
|
|
345
|
+
if (phoneNumber && phoneNumber !== user.phoneNumber) {
|
|
346
|
+
// Changing the number on file invalidates any prior verification.
|
|
347
|
+
await User.query().findById(userId).patch({
|
|
348
|
+
phoneNumber: targetPhone,
|
|
349
|
+
phoneVerified: false,
|
|
350
|
+
recordModifiedDate: formatDbDateTime()
|
|
351
|
+
});
|
|
352
|
+
await hooks.fire('user', 'update', userId);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await otp.requestOtp(targetPhone);
|
|
356
|
+
return { sent: true };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Verify a code against the phone number currently on file for this user.
|
|
361
|
+
*/
|
|
362
|
+
async function verifyPhoneOtp(userId, code) {
|
|
363
|
+
const user = await User.query().findById(userId);
|
|
364
|
+
if (!user) throw new Error('User not found');
|
|
365
|
+
if (!user.phoneNumber) throw new Error('No phone number on file — request a code first');
|
|
366
|
+
|
|
367
|
+
const result = await otp.verifyOtp(user.phoneNumber, code);
|
|
368
|
+
if (!result.success) {
|
|
369
|
+
const messages = {
|
|
370
|
+
expired: 'Code expired — request a new one',
|
|
371
|
+
too_many_attempts: 'Too many attempts — request a new code',
|
|
372
|
+
invalid: 'Invalid code'
|
|
373
|
+
};
|
|
374
|
+
throw new Error(messages[result.reason] || 'Invalid code');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const now = formatDbDateTime();
|
|
378
|
+
await User.query().findById(userId).patch({
|
|
379
|
+
phoneVerified: true,
|
|
380
|
+
phoneVerifiedOn: now,
|
|
381
|
+
recordModifiedDate: now
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await hooks.fire('user', 'update', userId);
|
|
385
|
+
|
|
386
|
+
return { phoneVerified: true };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function shapeProfile(user) {
|
|
390
|
+
return {
|
|
391
|
+
id: user.id,
|
|
392
|
+
email: user.email,
|
|
393
|
+
name: user.name,
|
|
394
|
+
phoneNumber: user.phoneNumber,
|
|
395
|
+
phoneVerified: user.phoneVerified,
|
|
396
|
+
profilePicUrl: user.profilePicUrl,
|
|
397
|
+
isActivated: user.isActivated
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function getProfile(userId) {
|
|
402
|
+
const user = await User.query().findById(userId);
|
|
403
|
+
if (!user) throw new Error('User not found');
|
|
404
|
+
return shapeProfile(user);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Update the current user's own name/phoneNumber/email. Changing phoneNumber
|
|
409
|
+
* clears phoneVerified (same rule as requestPhoneOtp — a new number needs its
|
|
410
|
+
* own verification). Changing email re-checks uniqueness (same as register())
|
|
411
|
+
* but does NOT require re-activation — that's a bigger flow this doesn't touch.
|
|
412
|
+
*/
|
|
413
|
+
async function updateProfile(userId, fields) {
|
|
414
|
+
const user = await User.query().findById(userId);
|
|
415
|
+
if (!user) throw new Error('User not found');
|
|
416
|
+
|
|
417
|
+
const data = {};
|
|
418
|
+
|
|
419
|
+
if (fields.name !== undefined) {
|
|
420
|
+
data.name = fields.name;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (fields.phoneNumber !== undefined && fields.phoneNumber !== user.phoneNumber) {
|
|
424
|
+
data.phoneNumber = fields.phoneNumber;
|
|
425
|
+
data.phoneVerified = false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (fields.email !== undefined) {
|
|
429
|
+
const email = fields.email.trim().toLowerCase();
|
|
430
|
+
if (email !== user.email) {
|
|
431
|
+
const normalized = normalizeEmail(email);
|
|
432
|
+
const existing = await User.query().findOne({ normalizedEmail: normalized });
|
|
433
|
+
if (existing && existing.id !== userId) {
|
|
434
|
+
throw new Error('Email already in use');
|
|
435
|
+
}
|
|
436
|
+
data.email = email;
|
|
437
|
+
data.normalizedEmail = normalized;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
data.recordModifiedDate = formatDbDateTime();
|
|
442
|
+
|
|
443
|
+
await User.query().findById(userId).patch(data);
|
|
444
|
+
await hooks.fire('user', 'update', userId);
|
|
445
|
+
|
|
446
|
+
const updated = await User.query().findById(userId);
|
|
447
|
+
return shapeProfile(updated);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Store an already-uploaded avatar's path/URL on the user's profile. The
|
|
452
|
+
* upload itself happens in the router via @xeplr/utils's FileUploader — this
|
|
453
|
+
* just persists the result, same as any other profile field.
|
|
454
|
+
*/
|
|
455
|
+
async function setProfilePic(userId, profilePicUrl) {
|
|
456
|
+
await User.query().findById(userId).patch({
|
|
457
|
+
profilePicUrl: profilePicUrl,
|
|
458
|
+
recordModifiedDate: formatDbDateTime()
|
|
459
|
+
});
|
|
460
|
+
await hooks.fire('user', 'update', userId);
|
|
461
|
+
const updated = await User.query().findById(userId);
|
|
462
|
+
return shapeProfile(updated);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Self-service password change for an already-authenticated user (distinct
|
|
467
|
+
* from resetPassword, which is for a user who's locked out and uses an
|
|
468
|
+
* emailed token instead of their current session). Requires the current
|
|
469
|
+
* password. Destroys all other sessions afterward — same as resetPassword —
|
|
470
|
+
* so the caller should expect to need to log back in.
|
|
471
|
+
*/
|
|
472
|
+
async function changePassword(userId, { oldPassword, newPassword }) {
|
|
473
|
+
if (!oldPassword || !newPassword) throw new Error('oldPassword and newPassword are required');
|
|
474
|
+
|
|
475
|
+
const user = await User.query().findById(userId);
|
|
476
|
+
if (!user) throw new Error('User not found');
|
|
477
|
+
|
|
478
|
+
const isMatch = await comparePassword(oldPassword, user.pwd);
|
|
479
|
+
if (!isMatch) throw new Error('Current password is incorrect');
|
|
480
|
+
|
|
481
|
+
const { hash, salt } = await hashPassword(newPassword);
|
|
482
|
+
|
|
483
|
+
await User.query().findById(userId).patch({
|
|
484
|
+
pwd: hash,
|
|
485
|
+
pwdSalt: salt,
|
|
486
|
+
recordModifiedDate: formatDbDateTime()
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await destroyAllUserSessions(userId);
|
|
490
|
+
await clearUserAccess(userId);
|
|
491
|
+
|
|
492
|
+
await hooks.fire('user', 'update', userId);
|
|
184
493
|
}
|
|
185
494
|
|
|
186
495
|
module.exports = {
|
|
@@ -188,7 +497,16 @@ module.exports = {
|
|
|
188
497
|
configureEmail,
|
|
189
498
|
register,
|
|
190
499
|
activate,
|
|
500
|
+
invite,
|
|
501
|
+
previewInvite,
|
|
502
|
+
acceptInvite,
|
|
191
503
|
login,
|
|
192
504
|
forgotPassword,
|
|
193
|
-
resetPassword
|
|
505
|
+
resetPassword,
|
|
506
|
+
requestPhoneOtp,
|
|
507
|
+
verifyPhoneOtp,
|
|
508
|
+
getProfile,
|
|
509
|
+
updateProfile,
|
|
510
|
+
setProfilePic,
|
|
511
|
+
changePassword
|
|
194
512
|
};
|