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

u-wave / core / 12201136025

06 Dec 2024 03:06PM UTC coverage: 85.316% (+1.0%) from 84.36%
12201136025

Pull #682

github

goto-bus-stop
use off the shelf redis store
Pull Request #682: Improve session handling

933 of 1112 branches covered (83.9%)

Branch coverage included in aggregate %.

79 of 120 new or added lines in 11 files covered. (65.83%)

6 existing lines in 1 file now uncovered.

9984 of 11684 relevant lines covered (85.45%)

90.95 hits per line

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

50.6
/src/controllers/authenticate.js
1
import { randomUUID } from 'node:crypto';
1✔
2
import { promisify } from 'node:util';
1✔
3
import cookie from 'cookie';
1✔
4
import jwt from 'jsonwebtoken';
1✔
5
import randomString from 'random-string';
1✔
6
import nodeFetch from 'node-fetch';
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
 * @type {import('../types.js').Controller}
1✔
42
 */
1✔
43
async function getCurrentUser(req) {
4✔
44
  return toItemResponse(req.user != null ? serializeCurrentUser(req.user) : null, {
4✔
45
    url: req.fullUrl,
4✔
46
  });
4✔
47
}
4✔
48

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

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

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

1✔
63
/**
1✔
64
 * @param {import('../types.js').Request} req
1✔
65
 * @param {import('../schema').User} user
1✔
66
 * @param {AuthenticateOptions & { session: 'cookie' | 'token' }} options
1✔
67
 */
1✔
NEW
68
async function refreshSession(req, user, options) {
×
NEW
69
  const { authRegistry } = req.uwaveHttp;
×
NEW
70
  const sessionID = req.authInfo?.sessionID ?? req.sessionID;
×
NEW
71

×
72
  const token = jwt.sign(
×
NEW
73
    { id: user.id, sessionID: randomUUID() },
×
74
    options.secret,
×
75
    { expiresIn: '31d' },
×
76
  );
×
77

×
NEW
78
  const socketToken = await authRegistry.createAuthToken(user, sessionID);
×
79

×
80
  if (options.session === 'cookie') {
×
81
    return { token: 'cookie', socketToken };
×
82
  }
×
83

×
84
  return { token, socketToken };
×
85
}
×
86

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

×
101
  const sessionType = session === 'cookie' ? 'cookie' : 'token';
×
102

×
103
  if (await bans.isBanned(user)) {
×
104
    throw new BannedError();
×
105
  }
×
106

×
NEW
107
  const { token, socketToken } = await refreshSession(
×
NEW
108
    req,
×
NEW
109
    user,
×
NEW
110
    { ...options, session: sessionType },
×
NEW
111
  );
×
112

×
113
  return toItemResponse(serializeCurrentUser(user), {
×
114
    meta: {
×
115
      jwt: sessionType === 'token' ? token : 'cookie',
×
116
      socketToken,
×
117
    },
×
118
  });
×
119
}
×
120

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

×
133
  return auth?.serviceAvatar ?? null;
×
134
}
×
135

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

×
146
  if (await bans.isBanned(user)) {
×
147
    throw new BannedError();
×
148
  }
×
149

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

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

×
172
  const script = `
×
173
    var opener = window.opener;
×
174
    if (opener) {
×
175
      opener.postMessage(${htmlescape(activationData)}, ${htmlescape(origin)});
×
176
    }
×
177
    window.close();
×
178
  `;
×
179

×
NEW
180
  await refreshSession(req, user, { ...req.authOptions, session: 'cookie' });
×
181

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

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

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

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

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

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

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

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

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

×
NEW
249
  const passportLogin = promisify(
×
NEW
250
    /**
×
NEW
251
     * @type {(
×
NEW
252
     *   user: Express.User,
×
NEW
253
     *   options: import('passport').LogInOptions,
×
NEW
254
     *   callback: (err: any) => void,
×
NEW
255
     * ) => void}
×
NEW
256
     */
×
NEW
257
    (req.login),
×
NEW
258
  );
×
NEW
259
  await passportLogin(user, { session: sessionType === 'cookie' });
×
NEW
260

×
NEW
261
  const { token, socketToken } = await refreshSession(
×
NEW
262
    req,
×
NEW
263
    user,
×
NEW
264
    { ...options, session: sessionType },
×
NEW
265
  );
×
266

×
267
  return toItemResponse(user, {
×
268
    meta: {
×
269
      jwt: sessionType === 'token' ? token : 'cookie',
×
270
      socketToken,
×
271
    },
×
272
  });
×
273
}
×
274

1✔
275
/**
1✔
276
 * @type {import('../types.js').AuthenticatedController}
1✔
277
 */
1✔
278
async function getSocketToken(req) {
×
NEW
279
  const { user, sessionID } = req;
×
280
  const { authRegistry } = req.uwaveHttp;
×
281

×
NEW
282
  const socketToken = await authRegistry.createAuthToken(user, sessionID);
×
283

×
284
  return toItemResponse({ socketToken }, {
×
285
    url: req.fullUrl,
×
286
  });
×
287
}
×
288

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

1✔
308
  if (!body.success) {
1!
309
    options.logger?.warn(body, 'recaptcha: validation failure');
×
310
    throw new ReCaptchaError();
×
311
  } else {
1✔
312
    options.logger?.info('recaptcha: ok');
1✔
313
  }
1✔
314
}
1✔
315

1✔
316
/**
1✔
317
 * @typedef {object} RegisterBody
1✔
318
 * @prop {string} email
1✔
319
 * @prop {string} username
1✔
320
 * @prop {string} password
1✔
321
 * @prop {string} [grecaptcha]
1✔
322
 */
1✔
323

1✔
324
/**
1✔
325
 * @param {import('../types.js').Request<{}, {}, RegisterBody> & WithAuthOptions} req
1✔
326
 */
1✔
327
async function register(req) {
9✔
328
  const { users } = req.uwave;
9✔
329
  const {
9✔
330
    grecaptcha, email, username, password,
9✔
331
  } = req.body;
9✔
332

9✔
333
  const recaptchaOptions = req.authOptions.recaptcha;
9✔
334
  if (recaptchaOptions && recaptchaOptions.secret) {
9✔
335
    if (grecaptcha) {
2✔
336
      await verifyCaptcha(grecaptcha, {
1✔
337
        secret: recaptchaOptions.secret,
1✔
338
        logger: req.log,
1✔
339
      });
1✔
340
    } else {
1✔
341
      req.log.warn('missing client-side captcha response');
1✔
342
      throw new ReCaptchaError();
1✔
343
    }
1✔
344
  }
2✔
345

8✔
346
  const user = await users.createUser({
8✔
347
    email,
8✔
348
    username,
8✔
349
    password,
8✔
350
  });
8✔
351

6✔
352
  return toItemResponse(serializeCurrentUser(user));
6✔
353
}
9✔
354

1✔
355
/**
1✔
356
 * @typedef {object} RequestPasswordResetBody
1✔
357
 * @prop {string} email
1✔
358
 */
1✔
359

1✔
360
/**
1✔
361
 * @param {import('../types.js').Request<{}, {}, RequestPasswordResetBody> & WithAuthOptions} req
1✔
362
 */
1✔
363
async function reset(req) {
2✔
364
  const { db, redis } = req.uwave;
2✔
365
  const { email } = req.body;
2✔
366
  const { mailTransport, createPasswordResetEmail } = req.authOptions;
2✔
367

2✔
368
  const user = await db.selectFrom('users')
2✔
369
    .where('email', '=', email)
2✔
370
    .select(['id'])
2✔
371
    .executeTakeFirst();
2✔
372
  if (!user) {
2!
373
    throw new UserNotFoundError({ email });
×
374
  }
×
375

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

2✔
378
  await redis.set(`reset:${token}`, user.id);
2✔
379
  await redis.expire(`reset:${token}`, 24 * 60 * 60);
2✔
380

2✔
381
  const message = createPasswordResetEmail({
2✔
382
    token,
2✔
383
    requestUrl: req.fullUrl,
2✔
384
  });
2✔
385

2✔
386
  const transporter = nodemailer.createTransport(mailTransport ?? {
2!
387
    host: 'localhost',
×
388
    port: 25,
×
389
    debug: true,
×
390
    tls: {
×
391
      rejectUnauthorized: false,
×
392
    },
×
393
  });
2✔
394

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

2✔
397
  return toItemResponse({});
2✔
398
}
2✔
399

1✔
400
/**
1✔
401
 * @typedef {object} ChangePasswordParams
1✔
402
 * @prop {string} reset
1✔
403
 * @typedef {object} ChangePasswordBody
1✔
404
 * @prop {string} password
1✔
405
 */
1✔
406

1✔
407
/**
1✔
408
 * @type {import('../types.js').Controller<ChangePasswordParams, {}, ChangePasswordBody>}
1✔
409
 */
1✔
410
async function changePassword(req) {
×
411
  const { users, redis } = req.uwave;
×
412
  const { reset: resetToken } = req.params;
×
413
  const { password } = req.body;
×
414

×
415
  const userID = /** @type {UserID} */ (await redis.get(`reset:${resetToken}`));
×
416
  if (!userID) {
×
417
    throw new InvalidResetTokenError();
×
418
  }
×
419

×
420
  const user = await users.getUser(userID);
×
421
  if (!user) {
×
422
    throw new UserNotFoundError({ id: userID });
×
423
  }
×
424

×
425
  await users.updatePassword(user.id, password);
×
426

×
427
  await redis.del(`reset:${resetToken}`);
×
428

×
429
  return toItemResponse({}, {
×
430
    meta: {
×
431
      message: `Updated password for ${user.username}`,
×
432
    },
×
433
  });
×
434
}
×
435

1✔
436
/**
1✔
437
 * @param {import('../types.js').AuthenticatedRequest<{}, {}, {}> & WithAuthOptions} req
1✔
438
 * @param {import('express').Response} res
1✔
439
 */
1✔
440
async function logout(req, res) {
×
441
  const { user, cookies } = req;
×
442
  const { cookieSecure, cookiePath } = req.authOptions;
×
443
  const uw = req.uwave;
×
444

×
445
  uw.publish('user:logout', {
×
446
    userID: user.id,
×
447
  });
×
448

×
NEW
449
  // Clear the legacy `uwsession` cookie.
×
450
  if (cookies && cookies.uwsession) {
×
451
    const serialized = cookie.serialize('uwsession', '', {
×
452
      httpOnly: true,
×
453
      secure: !!cookieSecure,
×
454
      path: cookiePath ?? '/',
×
455
      maxAge: 0,
×
456
    });
×
457
    res.setHeader('Set-Cookie', serialized);
×
458
  }
×
459

×
460
  const passportLogout = promisify(req.logout.bind(req));
×
461
  await passportLogout();
×
462

×
463
  return toItemResponse({});
×
464
}
×
465

1✔
466
/**
1✔
467
 * @returns {Promise<import('type-fest').JsonObject>}
1✔
468
 */
1✔
469
async function removeSession() {
×
470
  throw new Error('Unimplemented');
×
471
}
×
472

1✔
473
export {
1✔
474
  changePassword,
1✔
475
  getAuthStrategies,
1✔
476
  getCurrentUser,
1✔
477
  getSocketToken,
1✔
478
  login,
1✔
479
  logout,
1✔
480
  register,
1✔
481
  removeSession,
1✔
482
  reset,
1✔
483
  socialLoginCallback,
1✔
484
  socialLoginFinish,
1✔
485
};
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