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

u-wave / core / 4493289026

pending completion
4493289026

Pull #558

github

GitHub
Merge 63586ee4e into a6c0689c9
Pull Request #558: Translate to ES Modules

640 of 775 branches covered (82.58%)

Branch coverage included in aggregate %.

645 of 645 new or added lines in 84 files covered. (100.0%)

8287 of 10318 relevant lines covered (80.32%)

44.58 hits per line

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

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

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

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

1✔
36
/**
1✔
37
 * @param {string} str
1✔
38
 */
1✔
39
function seconds(str) {
×
40
  return Math.floor(ms(str) / 1000);
×
41
}
×
42

1✔
43
/**
1✔
44
 * @type {import('../types').Controller}
1✔
45
 */
1✔
46
async function getCurrentUser(req) {
4✔
47
  return toItemResponse(req.user ?? null, {
4✔
48
    url: req.fullUrl,
4✔
49
  });
4✔
50
}
4✔
51

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

2✔
58
  const strategies = passport.strategies();
2✔
59

2✔
60
  return toListResponse(
2✔
61
    strategies,
2✔
62
    { url: req.fullUrl },
2✔
63
  );
2✔
64
}
2✔
65

1✔
66
/**
1✔
67
 * @param {import('express').Response} res
1✔
68
 * @param {import('../HttpApi').HttpApi} api
1✔
69
 * @param {import('../models').User} user
1✔
70
 * @param {AuthenticateOptions & { session: 'cookie' | 'token' }} options
1✔
71
 */
1✔
72
async function refreshSession(res, api, user, options) {
×
73
  const token = jwt.sign(
×
74
    { id: user.id },
×
75
    options.secret,
×
76
    { expiresIn: '31d' },
×
77
  );
×
78

×
79
  const socketToken = await api.authRegistry.createAuthToken(user);
×
80

×
81
  if (options.session === 'cookie') {
×
82
    const serialized = cookie.serialize('uwsession', token, {
×
83
      httpOnly: true,
×
84
      secure: !!options.cookieSecure,
×
85
      path: options.cookiePath ?? '/',
×
86
      maxAge: seconds('31 days'),
×
87
    });
×
88
    res.setHeader('Set-Cookie', serialized);
×
89
    return { token: 'cookie', socketToken };
×
90
  }
×
91

×
92
  return { token, socketToken };
×
93
}
×
94

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

×
111
  const sessionType = session === 'cookie' ? 'cookie' : 'token';
×
112

×
113
  if (await bans.isBanned(user)) {
×
114
    throw new BannedError();
×
115
  }
×
116

×
117
  const { token, socketToken } = await refreshSession(res, req.uwaveHttp, user, {
×
118
    ...options,
×
119
    session: sessionType,
×
120
  });
×
121

×
122
  return toItemResponse(user, {
×
123
    meta: {
×
124
      jwt: sessionType === 'token' ? token : 'cookie',
×
125
      socketToken,
×
126
    },
×
127
  });
×
128
}
×
129

1✔
130
/**
1✔
131
 * @param {import('../Uwave').default} uw
1✔
132
 * @param {import('../models').User} user
1✔
133
 * @param {string} service
1✔
134
 */
1✔
135
async function getSocialAvatar(uw, user, service) {
×
136
  const { Authentication } = uw.models;
×
137

×
138
  /** @type {import('../models').Authentication|null} */
×
139
  const auth = await Authentication.findOne({
×
140
    user: user._id,
×
141
    type: service,
×
142
  });
×
143
  if (auth && auth.avatar) {
×
144
    return auth.avatar;
×
145
  }
×
146
  return null;
×
147
}
×
148

1✔
149
/**
1✔
150
 * @param {string} service
1✔
151
 * @param {import('../types').AuthenticatedRequest & WithAuthOptions} req
1✔
152
 * @param {import('express').Response} res
1✔
153
 */
1✔
154
async function socialLoginCallback(service, req, res) {
×
155
  const { user } = req;
×
156
  const { bans, locale } = req.uwave;
×
157
  const { origin } = req.authOptions;
×
158

×
159
  if (await bans.isBanned(user)) {
×
160
    throw new BannedError();
×
161
  }
×
162

×
163
  /**
×
164
   * @type {{ pending: boolean, id?: string, type?: string, avatars?: Record<string, string> }}
×
165
   */
×
166
  let activationData = { pending: false };
×
167
  if (user.pendingActivation) {
×
168
    const socialAvatar = await getSocialAvatar(req.uwave, user, service);
×
169

×
170
    /** @type {Record<string, string>} */
×
171
    const avatars = {
×
172
      sigil: `https://sigil.u-wave.net/${user.id}`,
×
173
    };
×
174
    if (socialAvatar) {
×
175
      avatars[service] = socialAvatar;
×
176
    }
×
177
    activationData = {
×
178
      pending: true,
×
179
      id: user.id,
×
180
      avatars,
×
181
      type: service,
×
182
    };
×
183
  }
×
184

×
185
  const script = `
×
186
    var opener = window.opener;
×
187
    if (opener) {
×
188
      opener.postMessage(${htmlescape(activationData)}, ${htmlescape(origin)});
×
189
    }
×
190
    window.close();
×
191
  `;
×
192

×
193
  await refreshSession(res, req.uwaveHttp, user, {
×
194
    ...req.authOptions,
×
195
    session: 'cookie',
×
196
  });
×
197

×
198
  res.end(`
×
199
    <!DOCTYPE html>
×
200
    <html>
×
201
      <head>
×
202
        <meta charset="utf-8">
×
203
        <title>${locale.t('authentication.successTitle')}</title>
×
204
      </head>
×
205
      <body style="background: #151515; color: #fff; font: 12pt 'Open Sans', sans-serif">
×
206
        ${locale.t('authentication.closeThisWindow')}
×
207
        <script>${script}</script>
×
208
      </body>
×
209
    </html>
×
210
  `);
×
211
}
×
212

1✔
213
/**
1✔
214
 * @typedef {object} SocialLoginFinishQuery
1✔
215
 * @prop {'cookie'|'token'} [session]
1✔
216
 *
1✔
217
 * @typedef {object} SocialLoginFinishBody
1✔
218
 * @prop {string} username
1✔
219
 * @prop {string} avatar
1✔
220
 */
1✔
221

1✔
222
/**
1✔
223
 * @param {string} service
1✔
224
 * @param {import('../types').Request<{}, SocialLoginFinishQuery, SocialLoginFinishBody> &
1✔
225
 *         WithAuthOptions} req
1✔
226
 * @param {import('express').Response} res
1✔
227
 */
1✔
228
async function socialLoginFinish(service, req, res) {
×
229
  const options = req.authOptions;
×
230
  const { pendingUser: user } = req;
×
231
  const sessionType = req.query.session === 'cookie' ? 'cookie' : 'token';
×
232
  const { bans } = req.uwave;
×
233

×
234
  if (!user) {
×
235
    // Should never happen so not putting much effort into
×
236
    // localising the error message.
×
237
    throw new BadRequest('This account has already been set up');
×
238
  }
×
239

×
240
  if (await bans.isBanned(user)) {
×
241
    throw new BannedError();
×
242
  }
×
243

×
244
  const { username, avatar } = req.body;
×
245

×
246
  // TODO Use the avatars plugin for this stuff later.
×
247
  let avatarUrl;
×
248
  if (avatar !== 'sigil') {
×
249
    avatarUrl = await getSocialAvatar(req.uwave, user, service);
×
250
  }
×
251
  if (!avatarUrl) {
×
252
    avatarUrl = `https://sigil.u-wave.net/${user.id}`;
×
253
  }
×
254

×
255
  user.username = username;
×
256
  user.avatar = avatarUrl;
×
257
  user.pendingActivation = undefined;
×
258
  await user.save();
×
259

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

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

1✔
273
/**
1✔
274
 * @type {import('../types').AuthenticatedController}
1✔
275
 */
1✔
276
async function getSocketToken(req) {
×
277
  const { user } = req;
×
278
  const { authRegistry } = req.uwaveHttp;
×
279

×
280
  const socketToken = await authRegistry.createAuthToken(user);
×
281

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

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

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

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

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

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

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

4✔
351
    return toItemResponse(user);
4✔
352
  } catch (error) {
5✔
353
    throw beautifyDuplicateKeyError(error);
1✔
354
  }
1✔
355
}
5✔
356

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

1✔
362
/**
1✔
363
 * @param {import('../types').Request<{}, {}, RequestPasswordResetBody> & WithAuthOptions} req
1✔
364
 */
1✔
365
async function reset(req) {
2✔
366
  const uw = req.uwave;
2✔
367
  const { Authentication } = uw.models;
2✔
368
  const { email } = req.body;
2✔
369
  const { mailTransport, createPasswordResetEmail } = req.authOptions;
2✔
370

2✔
371
  const auth = await Authentication.findOne({
2✔
372
    email: email.toLowerCase(),
2✔
373
  });
2✔
374
  if (!auth) {
2!
375
    throw new UserNotFoundError({ email });
×
376
  }
×
377

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

2✔
380
  await uw.redis.set(`reset:${token}`, auth.user.toString());
2✔
381
  await uw.redis.expire(`reset:${token}`, 24 * 60 * 60);
2✔
382

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

2✔
388
  await sendEmail(email, {
2✔
389
    mailTransport,
2✔
390
    email: message,
2✔
391
  });
2✔
392

2✔
393
  return toItemResponse({});
2✔
394
}
2✔
395

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

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

×
412
  const userId = await redis.get(`reset:${resetToken}`);
×
413
  if (!userId) {
×
414
    throw new InvalidResetTokenError();
×
415
  }
×
416

×
417
  const user = await users.getUser(userId);
×
418
  if (!user) {
×
419
    throw new UserNotFoundError({ id: userId });
×
420
  }
×
421

×
422
  await users.updatePassword(user.id, password);
×
423

×
424
  await redis.del(`reset:${resetToken}`);
×
425

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

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

×
442
  uw.publish('user:logout', {
×
443
    userID: user.id,
×
444
  });
×
445

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

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

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

1✔
466
export {
1✔
467
  changePassword,
1✔
468
  getAuthStrategies,
1✔
469
  getCurrentUser,
1✔
470
  getSocketToken,
1✔
471
  login,
1✔
472
  logout,
1✔
473
  refreshSession,
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