• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

u-wave / core / 12197565170

06 Dec 2024 10:51AM UTC coverage: 84.38% (+0.02%) from 84.36%
12197565170

Pull #682

github

goto-bus-stop
Continue to support existing uwsession tokens
Pull Request #682: Improve session handling

911 of 1089 branches covered (83.65%)

Branch coverage included in aggregate %.

81 of 116 new or added lines in 10 files covered. (69.83%)

100 existing lines in 4 files now uncovered.

9882 of 11702 relevant lines covered (84.45%)

92.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

52.23
/src/controllers/authenticate.js
1
import { promisify } from 'node:util';
1✔
2
import cookie from 'cookie';
1✔
3
import jwt from 'jsonwebtoken';
1✔
4
import randomString from 'random-string';
1✔
5
import nodeFetch from 'node-fetch';
1✔
6
import ms from 'ms';
1✔
7
import htmlescape from 'htmlescape';
1✔
8
import httpErrors from 'http-errors';
1✔
9
import nodemailer from 'nodemailer';
1✔
10
import {
1✔
11
  BannedError,
1✔
12
  ReCaptchaError,
1✔
13
  InvalidResetTokenError,
1✔
14
  UserNotFoundError,
1✔
15
} from '../errors/index.js';
1✔
16
import toItemResponse from '../utils/toItemResponse.js';
1✔
17
import toListResponse from '../utils/toListResponse.js';
1✔
18
import { serializeCurrentUser } from '../utils/serialize.js';
1✔
19

1✔
20
const { BadRequest } = httpErrors;
1✔
21

1✔
22
/**
1✔
23
 * @typedef {import('../schema').UserID} UserID
1✔
24
 */
1✔
25

1✔
26
/**
1✔
27
 * @typedef {object} AuthenticateOptions
1✔
28
 * @prop {string|Buffer} secret
1✔
29
 * @prop {string} [origin]
1✔
30
 * @prop {import('nodemailer').Transport} [mailTransport]
1✔
31
 * @prop {{ secret: string }} [recaptcha]
1✔
32
 * @prop {(options: { token: string, requestUrl: string }) =>
1✔
33
 *   import('nodemailer').SendMailOptions} createPasswordResetEmail
1✔
34
 * @prop {boolean} [cookieSecure]
1✔
35
 * @prop {string} [cookiePath]
1✔
36
 * @typedef {object} WithAuthOptions
1✔
37
 * @prop {AuthenticateOptions} authOptions
1✔
38
 */
1✔
39

1✔
40
/**
1✔
41
 * @param {string} str
1✔
42
 */
1✔
UNCOV
43
function seconds(str) {
×
UNCOV
44
  return Math.floor(ms(str) / 1000);
×
UNCOV
45
}
×
46

1✔
47
/**
1✔
48
 * @type {import('../types.js').Controller}
1✔
49
 */
1✔
50
async function getCurrentUser(req) {
4✔
51
  return toItemResponse(req.user != null ? serializeCurrentUser(req.user) : null, {
4✔
52
    url: req.fullUrl,
4✔
53
  });
4✔
54
}
4✔
55

1✔
56
/**
1✔
57
 * @type {import('../types.js').Controller}
1✔
58
 */
1✔
59
async function getAuthStrategies(req) {
2✔
60
  const { passport } = req.uwave;
2✔
61

2✔
62
  const strategies = passport.strategies();
2✔
63

2✔
64
  return toListResponse(
2✔
65
    strategies,
2✔
66
    { url: req.fullUrl },
2✔
67
  );
2✔
68
}
2✔
69

1✔
70
/**
1✔
71
 * @param {import('../HttpApi.js').HttpApi} api
1✔
72
 * @param {import('../schema.js').User} user
1✔
73
 * @param {string} sessionID
1✔
74
 * @param {AuthenticateOptions & { session: 'cookie' | 'token' }} options
1✔
75
 */
1✔
NEW
76
async function refreshSession(api, user, sessionID, options) {
×
77
  const token = jwt.sign(
×
78
    { id: user.id },
×
79
    options.secret,
×
80
    { expiresIn: '31d' },
×
81
  );
×
82

×
NEW
83
  const socketToken = await api.authRegistry.createAuthToken(user, sessionID);
×
84

×
85
  if (options.session === 'cookie') {
×
UNCOV
86
    return { token: 'cookie', socketToken };
×
UNCOV
87
  }
×
UNCOV
88

×
UNCOV
89
  return { token, socketToken };
×
UNCOV
90
}
×
91

1✔
92
/**
1✔
93
 * The login controller is called once a user has logged in successfully using Passport;
1✔
94
 * we only have to assign the JWT.
1✔
95
 *
1✔
96
 * @typedef {object} LoginQuery
1✔
97
 * @prop {'cookie'|'token'} [session]
1✔
98
 * @param {import('../types.js').AuthenticatedRequest<{}, LoginQuery, {}> & WithAuthOptions} req
1✔
99
 */
1✔
NEW
100
async function login(req) {
×
101
  const options = req.authOptions;
×
102
  const { user } = req;
×
103
  const { session } = req.query;
×
104
  const { bans } = req.uwave;
×
105

×
106
  const sessionType = session === 'cookie' ? 'cookie' : 'token';
×
107

×
108
  if (await bans.isBanned(user)) {
×
109
    throw new BannedError();
×
110
  }
×
111

×
NEW
112
  const { token, socketToken } = await refreshSession(req.uwaveHttp, user, req.sessionID, {
×
113
    ...options,
×
114
    session: sessionType,
×
115
  });
×
116

×
117
  return toItemResponse(serializeCurrentUser(user), {
×
118
    meta: {
×
119
      jwt: sessionType === 'token' ? token : 'cookie',
×
UNCOV
120
      socketToken,
×
UNCOV
121
    },
×
UNCOV
122
  });
×
UNCOV
123
}
×
124

1✔
125
/**
1✔
126
 * @param {import('../Uwave.js').default} uw
1✔
127
 * @param {import('../schema.js').User} user
1✔
128
 * @param {string} service
1✔
129
 */
1✔
130
async function getSocialAvatar(uw, user, service) {
×
131
  const auth = await uw.db.selectFrom('authServices')
×
132
    .where('userID', '=', user.id)
×
133
    .where('service', '=', service)
×
134
    .select(['serviceAvatar'])
×
UNCOV
135
    .executeTakeFirst();
×
UNCOV
136

×
UNCOV
137
  return auth?.serviceAvatar ?? null;
×
UNCOV
138
}
×
139

1✔
140
/**
1✔
141
 * @param {string} service
1✔
142
 * @param {import('../types.js').AuthenticatedRequest & WithAuthOptions} req
1✔
143
 * @param {import('express').Response} res
1✔
144
 */
1✔
145
async function socialLoginCallback(service, req, res) {
×
146
  const { user } = req;
×
147
  const { bans, locale } = req.uwave;
×
148
  const { origin } = req.authOptions;
×
149

×
150
  if (await bans.isBanned(user)) {
×
151
    throw new BannedError();
×
152
  }
×
153

×
154
  /**
×
155
   * @type {{ pending: boolean, id?: string, type?: string, avatars?: Record<string, string> }}
×
156
   */
×
157
  let activationData = { pending: false };
×
158
  if (user.pendingActivation) {
×
159
    const socialAvatar = await getSocialAvatar(req.uwave, user, service);
×
160

×
161
    /** @type {Record<string, string>} */
×
162
    const avatars = {
×
163
      sigil: `https://sigil.u-wave.net/${user.id}`,
×
164
    };
×
165
    if (socialAvatar) {
×
166
      avatars[service] = socialAvatar;
×
167
    }
×
168
    activationData = {
×
169
      pending: true,
×
170
      id: user.id,
×
171
      avatars,
×
172
      type: service,
×
173
    };
×
174
  }
×
175

×
176
  const script = `
×
177
    var opener = window.opener;
×
178
    if (opener) {
×
179
      opener.postMessage(${htmlescape(activationData)}, ${htmlescape(origin)});
×
180
    }
×
181
    window.close();
×
182
  `;
×
183

×
NEW
184
  await refreshSession(req.uwaveHttp, user, req.sessionID, {
×
185
    ...req.authOptions,
×
186
    session: 'cookie',
×
187
  });
×
188

×
189
  res.end(`
×
190
    <!DOCTYPE html>
×
191
    <html>
×
192
      <head>
×
193
        <meta charset="utf-8">
×
194
        <title>${locale.t('authentication.successTitle')}</title>
×
195
      </head>
×
UNCOV
196
      <body style="background: #151515; color: #fff; font: 12pt 'Open Sans', sans-serif">
×
UNCOV
197
        ${locale.t('authentication.closeThisWindow')}
×
UNCOV
198
        <script>${script}</script>
×
UNCOV
199
      </body>
×
UNCOV
200
    </html>
×
UNCOV
201
  `);
×
UNCOV
202
}
×
203

1✔
204
/**
1✔
205
 * @typedef {object} SocialLoginFinishQuery
1✔
206
 * @prop {'cookie'|'token'} [session]
1✔
207
 * @typedef {object} SocialLoginFinishBody
1✔
208
 * @prop {string} username
1✔
209
 * @prop {string} avatar
1✔
210
 */
1✔
211

1✔
212
/**
1✔
213
 * @param {string} service
1✔
214
 * @param {import('../types.js').Request<{}, SocialLoginFinishQuery, SocialLoginFinishBody> &
1✔
215
 *         WithAuthOptions} req
1✔
216
 */
1✔
NEW
217
async function socialLoginFinish(service, req) {
×
218
  const options = req.authOptions;
×
219
  const { pendingUser: user } = req;
×
220
  const sessionType = req.query.session === 'cookie' ? 'cookie' : 'token';
×
221
  const { db, bans } = req.uwave;
×
222

×
223
  if (!user) {
×
224
    // Should never happen so not putting much effort into
×
225
    // localising the error message.
×
226
    throw new BadRequest('This account has already been set up');
×
227
  }
×
228

×
229
  if (await bans.isBanned(user)) {
×
230
    throw new BannedError();
×
231
  }
×
232

×
233
  const { username, avatar } = req.body;
×
234

×
235
  // TODO Use the avatars plugin for this stuff later.
×
236
  let avatarUrl;
×
237
  if (avatar !== 'sigil') {
×
238
    avatarUrl = await getSocialAvatar(req.uwave, user, service);
×
239
  }
×
240
  if (!avatarUrl) {
×
241
    avatarUrl = `https://sigil.u-wave.net/${user.id}`;
×
242
  }
×
243

×
244
  const updates = await db.updateTable('users')
×
245
    .where('id', '=', user.id)
×
246
    .set({
×
247
      username,
×
248
      avatar: avatarUrl,
×
249
      pendingActivation: false,
×
250
    })
×
251
    .returning(['username', 'avatar', 'pendingActivation'])
×
252
    .executeTakeFirst();
×
253

×
254
  Object.assign(user, updates);
×
255

×
NEW
256
  const { token, socketToken } = await refreshSession(req.uwaveHttp, user, req.sessionID, {
×
257
    ...options,
×
258
    session: sessionType,
×
259
  });
×
260

×
261
  return toItemResponse(user, {
×
262
    meta: {
×
263
      jwt: sessionType === 'token' ? token : 'cookie',
×
264
      socketToken,
×
265
    },
×
266
  });
×
267
}
×
268

1✔
269
/**
1✔
270
 * @type {import('../types.js').AuthenticatedController}
1✔
271
 */
1✔
272
async function getSocketToken(req) {
×
NEW
273
  const { user, sessionID } = req;
×
UNCOV
274
  const { authRegistry } = req.uwaveHttp;
×
UNCOV
275

×
NEW
UNCOV
276
  const socketToken = await authRegistry.createAuthToken(user, sessionID);
×
UNCOV
277

×
278
  return toItemResponse({ socketToken }, {
×
279
    url: req.fullUrl,
×
280
  });
×
281
}
×
282

1✔
283
/**
1✔
284
 * @param {string} responseString
1✔
285
 * @param {{ secret: string, logger?: import('pino').Logger }} options
1✔
286
 */
1✔
287
async function verifyCaptcha(responseString, options) {
1✔
288
  options.logger?.info('recaptcha: sending siteverify request');
1✔
289
  const response = await nodeFetch('https://www.google.com/recaptcha/api/siteverify', {
1✔
290
    method: 'post',
1✔
291
    headers: {
1✔
292
      'content-type': 'application/x-www-form-urlencoded',
1✔
293
      accept: 'application/json',
1✔
294
    },
1✔
295
    body: new URLSearchParams({
1✔
296
      response: responseString,
1✔
297
      secret: options.secret,
1✔
298
    }),
1✔
299
  });
1✔
300
  const body = /** @type {{ success: boolean }} */ (await response.json());
1✔
301

1✔
302
  if (!body.success) {
1!
UNCOV
303
    options.logger?.warn(body, 'recaptcha: validation failure');
×
UNCOV
304
    throw new ReCaptchaError();
×
305
  } else {
1✔
306
    options.logger?.info('recaptcha: ok');
1✔
307
  }
1✔
308
}
1✔
309

1✔
310
/**
1✔
311
 * @typedef {object} RegisterBody
1✔
312
 * @prop {string} email
1✔
313
 * @prop {string} username
1✔
314
 * @prop {string} password
1✔
315
 * @prop {string} [grecaptcha]
1✔
316
 */
1✔
317

1✔
318
/**
1✔
319
 * @param {import('../types.js').Request<{}, {}, RegisterBody> & WithAuthOptions} req
1✔
320
 */
1✔
321
async function register(req) {
9✔
322
  const { users } = req.uwave;
9✔
323
  const {
9✔
324
    grecaptcha, email, username, password,
9✔
325
  } = req.body;
9✔
326

9✔
327
  const recaptchaOptions = req.authOptions.recaptcha;
9✔
328
  if (recaptchaOptions && recaptchaOptions.secret) {
9✔
329
    if (grecaptcha) {
2✔
330
      await verifyCaptcha(grecaptcha, {
1✔
331
        secret: recaptchaOptions.secret,
1✔
332
        logger: req.log,
1✔
333
      });
1✔
334
    } else {
1✔
335
      req.log.warn('missing client-side captcha response');
1✔
336
      throw new ReCaptchaError();
1✔
337
    }
1✔
338
  }
2✔
339

8✔
340
  const user = await users.createUser({
8✔
341
    email,
8✔
342
    username,
8✔
343
    password,
8✔
344
  });
8✔
345

6✔
346
  return toItemResponse(serializeCurrentUser(user));
6✔
347
}
9✔
348

1✔
349
/**
1✔
350
 * @typedef {object} RequestPasswordResetBody
1✔
351
 * @prop {string} email
1✔
352
 */
1✔
353

1✔
354
/**
1✔
355
 * @param {import('../types.js').Request<{}, {}, RequestPasswordResetBody> & WithAuthOptions} req
1✔
356
 */
1✔
357
async function reset(req) {
2✔
358
  const { db, redis } = req.uwave;
2✔
359
  const { email } = req.body;
2✔
360
  const { mailTransport, createPasswordResetEmail } = req.authOptions;
2✔
361

2✔
362
  const user = await db.selectFrom('users')
2✔
363
    .where('email', '=', email)
2✔
364
    .select(['id'])
2✔
365
    .executeTakeFirst();
2✔
366
  if (!user) {
2!
UNCOV
367
    throw new UserNotFoundError({ email });
×
UNCOV
368
  }
×
369

2✔
370
  const token = randomString({ length: 35, special: false });
2✔
371

2✔
372
  await redis.set(`reset:${token}`, user.id);
2✔
373
  await redis.expire(`reset:${token}`, 24 * 60 * 60);
2✔
374

2✔
375
  const message = createPasswordResetEmail({
2✔
376
    token,
2✔
377
    requestUrl: req.fullUrl,
2✔
378
  });
2✔
379

2✔
380
  const transporter = nodemailer.createTransport(mailTransport ?? {
2!
UNCOV
381
    host: 'localhost',
×
UNCOV
382
    port: 25,
×
UNCOV
383
    debug: true,
×
UNCOV
384
    tls: {
×
UNCOV
385
      rejectUnauthorized: false,
×
UNCOV
386
    },
×
387
  });
2✔
388

2✔
389
  await transporter.sendMail({ to: email, ...message });
2✔
390

2✔
391
  return toItemResponse({});
2✔
392
}
2✔
393

1✔
394
/**
1✔
395
 * @typedef {object} ChangePasswordParams
1✔
396
 * @prop {string} reset
1✔
397
 * @typedef {object} ChangePasswordBody
1✔
398
 * @prop {string} password
1✔
399
 */
1✔
400

1✔
401
/**
1✔
402
 * @type {import('../types.js').Controller<ChangePasswordParams, {}, ChangePasswordBody>}
1✔
403
 */
1✔
UNCOV
404
async function changePassword(req) {
×
UNCOV
405
  const { users, redis } = req.uwave;
×
UNCOV
406
  const { reset: resetToken } = req.params;
×
UNCOV
407
  const { password } = req.body;
×
UNCOV
408

×
UNCOV
409
  const userID = /** @type {UserID} */ (await redis.get(`reset:${resetToken}`));
×
410
  if (!userID) {
×
411
    throw new InvalidResetTokenError();
×
412
  }
×
413

×
414
  const user = await users.getUser(userID);
×
415
  if (!user) {
×
416
    throw new UserNotFoundError({ id: userID });
×
417
  }
×
418

×
419
  await users.updatePassword(user.id, password);
×
420

×
421
  await redis.del(`reset:${resetToken}`);
×
422

×
423
  return toItemResponse({}, {
×
424
    meta: {
×
425
      message: `Updated password for ${user.username}`,
×
426
    },
×
427
  });
×
428
}
×
429

1✔
430
/**
1✔
431
 * @param {import('../types.js').AuthenticatedRequest<{}, {}, {}> & WithAuthOptions} req
1✔
432
 * @param {import('express').Response} res
1✔
433
 */
1✔
434
async function logout(req, res) {
×
UNCOV
435
  const { user, cookies } = req;
×
UNCOV
436
  const { cookieSecure, cookiePath } = req.authOptions;
×
UNCOV
437
  const uw = req.uwave;
×
UNCOV
438

×
UNCOV
439
  uw.publish('user:logout', {
×
440
    userID: user.id,
×
441
  });
×
442

×
NEW
443
  // Clear the legacy `uwsession` cookie.
×
444
  if (cookies && cookies.uwsession) {
×
445
    const serialized = cookie.serialize('uwsession', '', {
×
446
      httpOnly: true,
×
447
      secure: !!cookieSecure,
×
448
      path: cookiePath ?? '/',
×
449
      maxAge: 0,
×
450
    });
×
451
    res.setHeader('Set-Cookie', serialized);
×
452
  }
×
453

×
454
  const passportLogout = promisify(req.logout.bind(req));
×
455
  await passportLogout();
×
456

×
457
  return toItemResponse({});
×
458
}
×
459

1✔
460
/**
1✔
461
 * @returns {Promise<import('type-fest').JsonObject>}
1✔
462
 */
1✔
463
async function removeSession() {
×
464
  throw new Error('Unimplemented');
×
UNCOV
465
}
×
466

1✔
467
export {
1✔
468
  changePassword,
1✔
469
  getAuthStrategies,
1✔
470
  getCurrentUser,
1✔
471
  getSocketToken,
1✔
472
  login,
1✔
473
  logout,
1✔
474
  register,
1✔
475
  removeSession,
1✔
476
  reset,
1✔
477
  socialLoginCallback,
1✔
478
  socialLoginFinish,
1✔
479
};
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc