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

u-wave / core / 11980840475

22 Nov 2024 10:04PM UTC coverage: 78.492% (-1.7%) from 80.158%
11980840475

Pull #637

github

goto-bus-stop
ci: add node 22
Pull Request #637: Switch to a relational database

757 of 912 branches covered (83.0%)

Branch coverage included in aggregate %.

2001 of 2791 new or added lines in 52 files covered. (71.69%)

9 existing lines in 7 files now uncovered.

8666 of 11093 relevant lines covered (78.12%)

70.72 hits per line

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

52.58
/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 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 beautifyDuplicateKeyError from '../utils/beautifyDuplicateKeyError.js';
1✔
16
import toItemResponse from '../utils/toItemResponse.js';
1✔
17
import toListResponse from '../utils/toListResponse.js';
1✔
18
import { serializeUser } 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✔
43
function seconds(str) {
×
44
  return Math.floor(ms(str) / 1000);
×
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 ? serializeUser(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('express').Response} res
1✔
72
 * @param {import('../HttpApi.js').HttpApi} api
1✔
73
 * @param {import('../schema.js').User} user
1✔
74
 * @param {AuthenticateOptions & { session: 'cookie' | 'token' }} options
1✔
75
 */
1✔
76
async function refreshSession(res, api, user, options) {
×
77
  const token = jwt.sign(
×
78
    { id: user.id },
×
79
    options.secret,
×
80
    { expiresIn: '31d' },
×
81
  );
×
82

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

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

×
96
  return { token, socketToken };
×
97
}
×
98

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

×
114
  const sessionType = session === 'cookie' ? 'cookie' : 'token';
×
115

×
116
  if (await bans.isBanned(user)) {
×
117
    throw new BannedError();
×
118
  }
×
119

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

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

1✔
133
/**
1✔
134
 * @param {import('../Uwave.js').default} uw
1✔
135
 * @param {import('../schema.js').User} user
1✔
136
 * @param {string} service
1✔
137
 */
1✔
138
async function getSocialAvatar(uw, user, service) {
×
NEW
139
  const auth = await uw.db.selectFrom('authServices')
×
NEW
140
    .where('userID', '=', user.id)
×
NEW
141
    .where('service', '=', service)
×
NEW
142
    .select(['serviceAvatar'])
×
NEW
143
    .executeTakeFirst();
×
144

×
NEW
145
  return auth?.serviceAvatar ?? null;
×
146
}
×
147

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

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

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

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

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

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

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

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

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

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

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

×
242
  const { username, avatar } = req.body;
×
243

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

×
NEW
253
  const updates = await db.updateTable('users')
×
NEW
254
    .where('id', '=', user.id)
×
NEW
255
    .set({
×
NEW
256
      username,
×
NEW
257
      avatar: avatarUrl,
×
NEW
258
      pendingActivation: false,
×
NEW
259
    })
×
NEW
260
    .returning(['username', 'avatar', 'pendingActivation'])
×
NEW
261
    .executeTakeFirst();
×
NEW
262

×
NEW
263
  Object.assign(user, updates);
×
264

×
265
  const { token, socketToken } = await refreshSession(res, req.uwaveHttp, user, {
×
266
    ...options,
×
267
    session: sessionType,
×
268
  });
×
269

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

1✔
278
/**
1✔
279
 * @type {import('../types.js').AuthenticatedController}
1✔
280
 */
1✔
281
async function getSocketToken(req) {
×
282
  const { user } = req;
×
283
  const { authRegistry } = req.uwaveHttp;
×
284

×
285
  const socketToken = await authRegistry.createAuthToken(user);
×
286

×
287
  return toItemResponse({ socketToken }, {
×
288
    url: req.fullUrl,
×
289
  });
×
290
}
×
291

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

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

1✔
319
/**
1✔
320
 * @typedef {object} RegisterBody
1✔
321
 * @prop {string} email
1✔
322
 * @prop {string} username
1✔
323
 * @prop {string} password
1✔
324
 * @prop {string} [grecaptcha]
1✔
325
 */
1✔
326

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

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

4✔
350
    const user = await users.createUser({
4✔
351
      email,
4✔
352
      username,
4✔
353
      password,
4✔
354
    });
4✔
355

4✔
356
    return toItemResponse(serializeUser(user));
4✔
357
  } catch (error) {
5✔
358
    throw beautifyDuplicateKeyError(error);
1✔
359
  }
1✔
360
}
5✔
361

1✔
362
/**
1✔
363
 * @typedef {object} RequestPasswordResetBody
1✔
364
 * @prop {string} email
1✔
365
 */
1✔
366

1✔
367
/**
1✔
368
 * @param {import('../types.js').Request<{}, {}, RequestPasswordResetBody> & WithAuthOptions} req
1✔
369
 */
1✔
370
async function reset(req) {
2✔
371
  const { db, redis } = req.uwave;
2✔
372
  const { email } = req.body;
2✔
373
  const { mailTransport, createPasswordResetEmail } = req.authOptions;
2✔
374

2✔
375
  const user = await db.selectFrom('users')
2✔
376
    .where('email', '=', email)
2✔
377
    .select(['id'])
2✔
378
    .executeTakeFirst();
2✔
379
  if (!user) {
2!
380
    throw new UserNotFoundError({ email });
×
381
  }
×
382

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

2✔
385
  await redis.set(`reset:${token}`, user.id);
2✔
386
  await redis.expire(`reset:${token}`, 24 * 60 * 60);
2✔
387

2✔
388
  const message = createPasswordResetEmail({
2✔
389
    token,
2✔
390
    requestUrl: req.fullUrl,
2✔
391
  });
2✔
392

2✔
393
  const transporter = nodemailer.createTransport(mailTransport ?? {
2!
NEW
394
    host: 'localhost',
×
NEW
395
    port: 25,
×
NEW
396
    debug: true,
×
NEW
397
    tls: {
×
NEW
398
      rejectUnauthorized: false,
×
NEW
399
    },
×
400
  });
2✔
401

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

2✔
404
  return toItemResponse({});
2✔
405
}
2✔
406

1✔
407
/**
1✔
408
 * @typedef {object} ChangePasswordParams
1✔
409
 * @prop {string} reset
1✔
410
 * @typedef {object} ChangePasswordBody
1✔
411
 * @prop {string} password
1✔
412
 */
1✔
413

1✔
414
/**
1✔
415
 * @type {import('../types.js').Controller<ChangePasswordParams, {}, ChangePasswordBody>}
1✔
416
 */
1✔
417
async function changePassword(req) {
×
418
  const { users, redis } = req.uwave;
×
419
  const { reset: resetToken } = req.params;
×
420
  const { password } = req.body;
×
421

×
NEW
422
  const userID = /** @type {UserID} */ (await redis.get(`reset:${resetToken}`));
×
NEW
423
  if (!userID) {
×
424
    throw new InvalidResetTokenError();
×
425
  }
×
426

×
NEW
427
  const user = await users.getUser(userID);
×
428
  if (!user) {
×
NEW
429
    throw new UserNotFoundError({ id: userID });
×
430
  }
×
431

×
432
  await users.updatePassword(user.id, password);
×
433

×
434
  await redis.del(`reset:${resetToken}`);
×
435

×
436
  return toItemResponse({}, {
×
437
    meta: {
×
438
      message: `Updated password for ${user.username}`,
×
439
    },
×
440
  });
×
441
}
×
442

1✔
443
/**
1✔
444
 * @param {import('../types.js').AuthenticatedRequest<{}, {}, {}> & WithAuthOptions} req
1✔
445
 * @param {import('express').Response} res
1✔
446
 */
1✔
447
async function logout(req, res) {
×
448
  const { user, cookies } = req;
×
449
  const { cookieSecure, cookiePath } = req.authOptions;
×
450
  const uw = req.uwave;
×
451

×
452
  uw.publish('user:logout', {
×
453
    userID: user.id,
×
454
  });
×
455

×
456
  if (cookies && cookies.uwsession) {
×
457
    const serialized = cookie.serialize('uwsession', '', {
×
458
      httpOnly: true,
×
459
      secure: !!cookieSecure,
×
460
      path: cookiePath ?? '/',
×
461
      maxAge: 0,
×
462
    });
×
463
    res.setHeader('Set-Cookie', serialized);
×
464
  }
×
465

×
466
  return toItemResponse({});
×
467
}
×
468

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

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