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

u-wave / core / 12197793619

06 Dec 2024 11:12AM UTC coverage: 84.385% (+0.03%) from 84.36%
12197793619

Pull #682

github

goto-bus-stop
lint
Pull Request #682: Improve session handling

911 of 1089 branches covered (83.65%)

Branch coverage included in aggregate %.

85 of 122 new or added lines in 10 files covered. (69.67%)

50 existing lines in 2 files now uncovered.

9881 of 11700 relevant lines covered (84.45%)

92.82 hits per line

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

52.06
/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 htmlescape from 'htmlescape';
1✔
7
import httpErrors from 'http-errors';
1✔
8
import nodemailer from 'nodemailer';
1✔
9
import {
1✔
10
  BannedError,
1✔
11
  ReCaptchaError,
1✔
12
  InvalidResetTokenError,
1✔
13
  UserNotFoundError,
1✔
14
} from '../errors/index.js';
1✔
15
import toItemResponse from '../utils/toItemResponse.js';
1✔
16
import toListResponse from '../utils/toListResponse.js';
1✔
17
import { serializeCurrentUser } from '../utils/serialize.js';
1✔
18

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

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

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

1✔
39
/**
1✔
40
 * @type {import('../types.js').Controller}
1✔
41
 */
1✔
42
async function getCurrentUser(req) {
4✔
43
  return toItemResponse(req.user != null ? serializeCurrentUser(req.user) : null, {
4✔
44
    url: req.fullUrl,
4✔
45
  });
4✔
46
}
4✔
47

1✔
48
/**
1✔
49
 * @type {import('../types.js').Controller}
1✔
50
 */
1✔
51
async function getAuthStrategies(req) {
2✔
52
  const { passport } = req.uwave;
2✔
53

2✔
54
  const strategies = passport.strategies();
2✔
55

2✔
56
  return toListResponse(
2✔
57
    strategies,
2✔
58
    { url: req.fullUrl },
2✔
59
  );
2✔
60
}
2✔
61

1✔
62
/**
1✔
63
 * @param {import('../HttpApi.js').HttpApi} api
1✔
64
 * @param {import('../schema.js').User} user
1✔
65
 * @param {string} sessionID
1✔
66
 * @param {AuthenticateOptions & { session: 'cookie' | 'token' }} options
1✔
67
 */
1✔
NEW
68
async function refreshSession(api, user, sessionID, options) {
×
69
  const token = jwt.sign(
×
70
    { id: user.id },
×
71
    options.secret,
×
72
    { expiresIn: '31d' },
×
73
  );
×
74

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

×
77
  if (options.session === 'cookie') {
×
78
    return { token: 'cookie', socketToken };
×
79
  }
×
80

×
81
  return { token, socketToken };
×
82
}
×
83

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

×
98
  const sessionType = session === 'cookie' ? 'cookie' : 'token';
×
99

×
100
  if (await bans.isBanned(user)) {
×
101
    throw new BannedError();
×
102
  }
×
103

×
NEW
104
  const { token, socketToken } = await refreshSession(req.uwaveHttp, user, req.sessionID, {
×
105
    ...options,
×
106
    session: sessionType,
×
107
  });
×
108

×
109
  return toItemResponse(serializeCurrentUser(user), {
×
110
    meta: {
×
111
      jwt: sessionType === 'token' ? token : 'cookie',
×
112
      socketToken,
×
113
    },
×
114
  });
×
115
}
×
116

1✔
117
/**
1✔
118
 * @param {import('../Uwave.js').default} uw
1✔
119
 * @param {import('../schema.js').User} user
1✔
120
 * @param {string} service
1✔
121
 */
1✔
UNCOV
122
async function getSocialAvatar(uw, user, service) {
×
UNCOV
123
  const auth = await uw.db.selectFrom('authServices')
×
UNCOV
124
    .where('userID', '=', user.id)
×
UNCOV
125
    .where('service', '=', service)
×
126
    .select(['serviceAvatar'])
×
127
    .executeTakeFirst();
×
128

×
129
  return auth?.serviceAvatar ?? null;
×
130
}
×
131

1✔
132
/**
1✔
133
 * @param {string} service
1✔
134
 * @param {import('../types.js').AuthenticatedRequest & WithAuthOptions} req
1✔
135
 * @param {import('express').Response} res
1✔
136
 */
1✔
UNCOV
137
async function socialLoginCallback(service, req, res) {
×
UNCOV
138
  const { user } = req;
×
UNCOV
139
  const { bans, locale } = req.uwave;
×
UNCOV
140
  const { origin } = req.authOptions;
×
141

×
142
  if (await bans.isBanned(user)) {
×
143
    throw new BannedError();
×
144
  }
×
145

×
146
  /**
×
147
   * @type {{ pending: boolean, id?: string, type?: string, avatars?: Record<string, string> }}
×
148
   */
×
149
  let activationData = { pending: false };
×
150
  if (user.pendingActivation) {
×
151
    const socialAvatar = await getSocialAvatar(req.uwave, user, service);
×
152

×
153
    /** @type {Record<string, string>} */
×
154
    const avatars = {
×
155
      sigil: `https://sigil.u-wave.net/${user.id}`,
×
156
    };
×
157
    if (socialAvatar) {
×
158
      avatars[service] = socialAvatar;
×
159
    }
×
160
    activationData = {
×
161
      pending: true,
×
162
      id: user.id,
×
163
      avatars,
×
164
      type: service,
×
165
    };
×
166
  }
×
167

×
168
  const script = `
×
169
    var opener = window.opener;
×
170
    if (opener) {
×
171
      opener.postMessage(${htmlescape(activationData)}, ${htmlescape(origin)});
×
172
    }
×
173
    window.close();
×
174
  `;
×
175

×
NEW
176
  await refreshSession(req.uwaveHttp, user, req.sessionID, {
×
177
    ...req.authOptions,
×
178
    session: 'cookie',
×
179
  });
×
180

×
181
  res.end(`
×
182
    <!DOCTYPE html>
×
183
    <html>
×
184
      <head>
×
185
        <meta charset="utf-8">
×
186
        <title>${locale.t('authentication.successTitle')}</title>
×
187
      </head>
×
188
      <body style="background: #151515; color: #fff; font: 12pt 'Open Sans', sans-serif">
×
189
        ${locale.t('authentication.closeThisWindow')}
×
190
        <script>${script}</script>
×
191
      </body>
×
192
    </html>
×
193
  `);
×
194
}
×
195

1✔
196
/**
1✔
197
 * @typedef {object} SocialLoginFinishQuery
1✔
198
 * @prop {'cookie'|'token'} [session]
1✔
199
 * @typedef {object} SocialLoginFinishBody
1✔
200
 * @prop {string} username
1✔
201
 * @prop {string} avatar
1✔
202
 */
1✔
203

1✔
204
/**
1✔
205
 * @param {string} service
1✔
206
 * @param {import('../types.js').Request<{}, SocialLoginFinishQuery, SocialLoginFinishBody> &
1✔
207
 *         WithAuthOptions} req
1✔
208
 */
1✔
NEW
UNCOV
209
async function socialLoginFinish(service, req) {
×
210
  const options = req.authOptions;
×
211
  const { pendingUser: user } = req;
×
212
  const sessionType = req.query.session === 'cookie' ? 'cookie' : 'token';
×
213
  const { db, bans } = req.uwave;
×
214

×
215
  if (!user) {
×
216
    // Should never happen so not putting much effort into
×
217
    // localising the error message.
×
218
    throw new BadRequest('This account has already been set up');
×
219
  }
×
220

×
221
  if (await bans.isBanned(user)) {
×
222
    throw new BannedError();
×
223
  }
×
224

×
225
  const { username, avatar } = req.body;
×
226

×
227
  // TODO Use the avatars plugin for this stuff later.
×
228
  let avatarUrl;
×
229
  if (avatar !== 'sigil') {
×
230
    avatarUrl = await getSocialAvatar(req.uwave, user, service);
×
231
  }
×
232
  if (!avatarUrl) {
×
233
    avatarUrl = `https://sigil.u-wave.net/${user.id}`;
×
234
  }
×
235

×
236
  const updates = await db.updateTable('users')
×
237
    .where('id', '=', user.id)
×
238
    .set({
×
239
      username,
×
240
      avatar: avatarUrl,
×
241
      pendingActivation: false,
×
242
    })
×
243
    .returning(['username', 'avatar', 'pendingActivation'])
×
244
    .executeTakeFirst();
×
245

×
246
  Object.assign(user, updates);
×
247

×
NEW
248
  const { token, socketToken } = await refreshSession(req.uwaveHttp, user, req.sessionID, {
×
249
    ...options,
×
250
    session: sessionType,
×
251
  });
×
252

×
253
  return toItemResponse(user, {
×
254
    meta: {
×
255
      jwt: sessionType === 'token' ? token : 'cookie',
×
256
      socketToken,
×
257
    },
×
258
  });
×
259
}
×
260

1✔
261
/**
1✔
262
 * @type {import('../types.js').AuthenticatedController}
1✔
263
 */
1✔
264
async function getSocketToken(req) {
×
NEW
265
  const { user, sessionID } = req;
×
266
  const { authRegistry } = req.uwaveHttp;
×
267

×
NEW
268
  const socketToken = await authRegistry.createAuthToken(user, sessionID);
×
269

×
270
  return toItemResponse({ socketToken }, {
×
271
    url: req.fullUrl,
×
272
  });
×
273
}
×
274

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

1✔
294
  if (!body.success) {
1!
UNCOV
295
    options.logger?.warn(body, 'recaptcha: validation failure');
×
UNCOV
296
    throw new ReCaptchaError();
×
297
  } else {
1✔
298
    options.logger?.info('recaptcha: ok');
1✔
299
  }
1✔
300
}
1✔
301

1✔
302
/**
1✔
303
 * @typedef {object} RegisterBody
1✔
304
 * @prop {string} email
1✔
305
 * @prop {string} username
1✔
306
 * @prop {string} password
1✔
307
 * @prop {string} [grecaptcha]
1✔
308
 */
1✔
309

1✔
310
/**
1✔
311
 * @param {import('../types.js').Request<{}, {}, RegisterBody> & WithAuthOptions} req
1✔
312
 */
1✔
313
async function register(req) {
9✔
314
  const { users } = req.uwave;
9✔
315
  const {
9✔
316
    grecaptcha, email, username, password,
9✔
317
  } = req.body;
9✔
318

9✔
319
  const recaptchaOptions = req.authOptions.recaptcha;
9✔
320
  if (recaptchaOptions && recaptchaOptions.secret) {
9✔
321
    if (grecaptcha) {
2✔
322
      await verifyCaptcha(grecaptcha, {
1✔
323
        secret: recaptchaOptions.secret,
1✔
324
        logger: req.log,
1✔
325
      });
1✔
326
    } else {
1✔
327
      req.log.warn('missing client-side captcha response');
1✔
328
      throw new ReCaptchaError();
1✔
329
    }
1✔
330
  }
2✔
331

8✔
332
  const user = await users.createUser({
8✔
333
    email,
8✔
334
    username,
8✔
335
    password,
8✔
336
  });
8✔
337

6✔
338
  return toItemResponse(serializeCurrentUser(user));
6✔
339
}
9✔
340

1✔
341
/**
1✔
342
 * @typedef {object} RequestPasswordResetBody
1✔
343
 * @prop {string} email
1✔
344
 */
1✔
345

1✔
346
/**
1✔
347
 * @param {import('../types.js').Request<{}, {}, RequestPasswordResetBody> & WithAuthOptions} req
1✔
348
 */
1✔
349
async function reset(req) {
2✔
350
  const { db, redis } = req.uwave;
2✔
351
  const { email } = req.body;
2✔
352
  const { mailTransport, createPasswordResetEmail } = req.authOptions;
2✔
353

2✔
354
  const user = await db.selectFrom('users')
2✔
355
    .where('email', '=', email)
2✔
356
    .select(['id'])
2✔
357
    .executeTakeFirst();
2✔
358
  if (!user) {
2!
UNCOV
359
    throw new UserNotFoundError({ email });
×
UNCOV
360
  }
×
361

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

2✔
364
  await redis.set(`reset:${token}`, user.id);
2✔
365
  await redis.expire(`reset:${token}`, 24 * 60 * 60);
2✔
366

2✔
367
  const message = createPasswordResetEmail({
2✔
368
    token,
2✔
369
    requestUrl: req.fullUrl,
2✔
370
  });
2✔
371

2✔
372
  const transporter = nodemailer.createTransport(mailTransport ?? {
2!
373
    host: 'localhost',
×
374
    port: 25,
×
UNCOV
375
    debug: true,
×
UNCOV
376
    tls: {
×
UNCOV
377
      rejectUnauthorized: false,
×
UNCOV
378
    },
×
379
  });
2✔
380

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

2✔
383
  return toItemResponse({});
2✔
384
}
2✔
385

1✔
386
/**
1✔
387
 * @typedef {object} ChangePasswordParams
1✔
388
 * @prop {string} reset
1✔
389
 * @typedef {object} ChangePasswordBody
1✔
390
 * @prop {string} password
1✔
391
 */
1✔
392

1✔
393
/**
1✔
394
 * @type {import('../types.js').Controller<ChangePasswordParams, {}, ChangePasswordBody>}
1✔
395
 */
1✔
UNCOV
396
async function changePassword(req) {
×
UNCOV
397
  const { users, redis } = req.uwave;
×
UNCOV
398
  const { reset: resetToken } = req.params;
×
UNCOV
399
  const { password } = req.body;
×
UNCOV
400

×
UNCOV
401
  const userID = /** @type {UserID} */ (await redis.get(`reset:${resetToken}`));
×
UNCOV
402
  if (!userID) {
×
UNCOV
403
    throw new InvalidResetTokenError();
×
UNCOV
404
  }
×
UNCOV
405

×
UNCOV
406
  const user = await users.getUser(userID);
×
UNCOV
407
  if (!user) {
×
UNCOV
408
    throw new UserNotFoundError({ id: userID });
×
UNCOV
409
  }
×
410

×
411
  await users.updatePassword(user.id, password);
×
412

×
413
  await redis.del(`reset:${resetToken}`);
×
414

×
415
  return toItemResponse({}, {
×
416
    meta: {
×
417
      message: `Updated password for ${user.username}`,
×
418
    },
×
419
  });
×
420
}
×
421

1✔
422
/**
1✔
423
 * @param {import('../types.js').AuthenticatedRequest<{}, {}, {}> & WithAuthOptions} req
1✔
424
 * @param {import('express').Response} res
1✔
425
 */
1✔
426
async function logout(req, res) {
×
427
  const { user, cookies } = req;
×
428
  const { cookieSecure, cookiePath } = req.authOptions;
×
429
  const uw = req.uwave;
×
430

×
431
  uw.publish('user:logout', {
×
432
    userID: user.id,
×
433
  });
×
434

×
NEW
UNCOV
435
  // Clear the legacy `uwsession` cookie.
×
UNCOV
436
  if (cookies && cookies.uwsession) {
×
UNCOV
437
    const serialized = cookie.serialize('uwsession', '', {
×
UNCOV
438
      httpOnly: true,
×
UNCOV
439
      secure: !!cookieSecure,
×
440
      path: cookiePath ?? '/',
×
441
      maxAge: 0,
×
442
    });
×
443
    res.setHeader('Set-Cookie', serialized);
×
444
  }
×
445

×
446
  const passportLogout = promisify(req.logout.bind(req));
×
447
  await passportLogout();
×
448

×
449
  return toItemResponse({});
×
450
}
×
451

1✔
452
/**
1✔
453
 * @returns {Promise<import('type-fest').JsonObject>}
1✔
454
 */
1✔
455
async function removeSession() {
×
456
  throw new Error('Unimplemented');
×
457
}
×
458

1✔
459
export {
1✔
460
  changePassword,
1✔
461
  getAuthStrategies,
1✔
462
  getCurrentUser,
1✔
463
  getSocketToken,
1✔
464
  login,
1✔
465
  logout,
1✔
466
  register,
1✔
467
  removeSession,
1✔
468
  reset,
1✔
469
  socialLoginCallback,
1✔
470
  socialLoginFinish,
1✔
471
};
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

© 2025 Coveralls, Inc