Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions api/controllers/user-email/get.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module.exports = async function (req, res) {
try {
const userEmail = await UserEmail.findOne({ user: req.session.usr });

return res.ok({
email: userEmail?.email ?? null,
promotional: userEmail?.promotional ?? false,
});
} catch (err) {
return res.badRequest(err);
}
};
53 changes: 53 additions & 0 deletions api/controllers/user-email/request-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const crypto = require('crypto');
const dayjs = require('dayjs');
const utc = require('dayjs/plugin/utc');
dayjs.extend(utc);

const CODE_TTL_MINUTES = 10;
const RESEND_COOLDOWN_SECONDS = 60;

module.exports = async function (req, res) {
try {
const { email } = req.body;
const promotional = req.body.promotional === true;
const userId = req.session.usr;

// Email must not already belong to another user
const emailOwner = await UserEmail.findOne({ email });
if (emailOwner && emailOwner.user !== userId) {
return res.status(409).json({ message: 'emailPreference.error.emailTaken' });
}

const userEmail = await UserEmail.findOne({ user: userId });

// Limit how often a user can request a code
const now = dayjs.utc();
if (userEmail?.codeSentAt && now.diff(dayjs.utc(userEmail.codeSentAt), 'second') < RESEND_COOLDOWN_SECONDS) {
return res.status(429).json({ message: 'emailPreference.error.tooManyRequests' });
}

const code = crypto.randomInt(0, 1_000_000).toString()
.padStart(6, '0');
const updates = {
pendingEmail: email,
verificationCode: sails.helpers.email.hashVerificationCode(code),
codeExpiresAt: now.add(CODE_TTL_MINUTES, 'minute').toDate(),
codeSentAt: now.toDate(),
attempts: 0,
promotional,
};

if (userEmail) {
await UserEmail.updateOne({ id: userEmail.id }).set(updates);
} else {
await UserEmail.create({ user: userId, ...updates });
}

await sails.helpers.email.sendVerificationEmail(email, code);

return res.ok();
} catch (err) {
sails.log.error(err);
return res.badRequest({ message: 'emailPreference.error.sendFailed' });
}
};
57 changes: 57 additions & 0 deletions api/controllers/user-email/verify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const crypto = require('crypto');
const dayjs = require('dayjs');
const utc = require('dayjs/plugin/utc');
dayjs.extend(utc);

const MAX_ATTEMPTS = 5;

module.exports = async function (req, res) {
try {
const code = typeof req.body.code === 'string' ? req.body.code.trim() : '';
if (!/^\d{6}$/.test(code)) {
return res.badRequest({ message: 'emailPreference.error.invalidCode' });
}

const userEmail = await UserEmail.findOne({ user: req.session.usr });
if (!userEmail?.pendingEmail || !userEmail.verificationCode) {
return res.badRequest({ message: 'emailPreference.error.noPendingEmail' });
}

if (dayjs.utc().isAfter(dayjs.utc(userEmail.codeExpiresAt)) || userEmail.attempts >= MAX_ATTEMPTS) {
return res.badRequest({ message: 'emailPreference.error.codeExpired' });
}

const expected = Buffer.from(userEmail.verificationCode);
const actual = Buffer.from(sails.helpers.email.hashVerificationCode(code));
if (!crypto.timingSafeEqual(expected, actual)) {
await UserEmail.updateOne({ id: userEmail.id }).set({ attempts: userEmail.attempts + 1 });
return res.badRequest({ message: 'emailPreference.error.invalidCode' });
}

// Another user verified this email after the code was sent
const emailOwner = await UserEmail.findOne({ email: userEmail.pendingEmail });
if (emailOwner && emailOwner.user !== userEmail.user) {
return res.status(409).json({ message: 'emailPreference.error.emailTaken' });
}

try {
await UserEmail.updateOne({ id: userEmail.id }).set({
email: userEmail.pendingEmail,
pendingEmail: null,
verificationCode: null,
codeExpiresAt: null,
attempts: 0,
});
} catch (err) {
// Postgres unique index caught a concurrent verification of the same email
if (err.code === 'E_UNIQUE') {
return res.status(409).json({ message: 'emailPreference.error.emailTaken' });
}
throw err;
}

return res.ok({ email: userEmail.pendingEmail, promotional: userEmail.promotional });
} catch (err) {
return res.badRequest(err);
}
};
21 changes: 21 additions & 0 deletions api/helpers/email/hash-verification-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const crypto = require('crypto');

module.exports = {
friendlyName: 'Hash Verification Code',

description: 'Hashes an email verification code so it is never stored in plain text',

inputs: {
code: {
type: 'string',
required: true,
},
},

sync: true,

fn: ({ code }, exits) => {
return exits.success(crypto.createHash('sha256').update(code)
.digest('hex'));
},
};
52 changes: 52 additions & 0 deletions api/helpers/email/send-verification-email.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
const nodemailer = require('nodemailer');

module.exports = {
friendlyName: 'Send Verification Email',

description: 'Emails a 6 digit verification code using the configured gmail account',

inputs: {
email: {
type: 'string',
required: true,
},
code: {
type: 'string',
required: true,
},
},

fn: async function ({ email, code }, exits) {
const { GMAIL_USER, GMAIL_APP_PASSWORD } = process.env;

// Without credentials, log the code locally instead of sending it
if (!GMAIL_USER || !GMAIL_APP_PASSWORD) {
if (sails.config.environment === 'production') {
return exits.error(new Error('Email credentials are not configured'));
}
sails.log.info(`Email verification code for ${email}: ${code}`);
return exits.success();
}

try {
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: GMAIL_USER,
pass: GMAIL_APP_PASSWORD,
},
});

await transporter.sendMail({
from: `Cuttle <${GMAIL_USER}>`,
to: email,
subject: 'Your Cuttle verification code',
text: `Your Cuttle verification code is ${code}. It expires in 10 minutes.`,
});

return exits.success();
} catch (err) {
return exits.error(err);
}
},
};
1 change: 1 addition & 0 deletions api/helpers/wipe-database.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module.exports = {
UserSpectatingGame.destroy({}),
GameStateRow.destroy({}),
Identity.destroy({}),
UserEmail.destroy({}),
]);

} catch (err) {
Expand Down
58 changes: 58 additions & 0 deletions api/models/UserEmail.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* UserEmail.js
*
* @description :: A user's preferred email. One row per user. The email is only persisted to
* `email` after the user verifies it with the code sent to `pendingEmail`
* @docs :: http://sailsjs.org/#!documentation/models
*/

module.exports = {
attributes: {
user: {
model: 'user',
required: true,
unique: true,
},
/**
* Verified email, lowercased. Unique across users
* @value null until the user verifies an email
* Uniqueness is enforced in the controllers and by a unique index in postgres, because
* sails-disk does not support `unique` on nullable attributes
*/
email: {
type: 'string',
allowNull: true,
isEmail: true,
},
/**
* Email awaiting verification
*/
pendingEmail: {
type: 'string',
allowNull: true,
},
/**
* sha256 hash of the 6 digit code sent to pendingEmail
*/
verificationCode: {
type: 'string',
allowNull: true,
},
codeExpiresAt: {
type: 'ref',
columnType: 'timestamptz',
},
codeSentAt: {
type: 'ref',
columnType: 'timestamptz',
},
attempts: {
type: 'number',
defaultsTo: 0,
},
promotional: {
type: 'boolean',
defaultsTo: false,
},
},
};
18 changes: 18 additions & 0 deletions api/policies/hasValidEmail.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* hasValidEmail
*
* @module :: Policy
* @description :: Only allows requests that contain an 'email' parameter in a valid email format.
* Normalizes the email to lowercase
* @docs :: http://sailsjs.org/#!/documentation/concepts/Policies
*
*/
module.exports = function (req, res, next) {
const email = typeof req.body.email === 'string' ? req.body.email.trim().toLowerCase() : '';

if (!email || email.length > 254 || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
return res.badRequest({ message: 'emailPreference.error.invalidEmail' });
}
req.body.email = email;
return next();
};
4 changes: 4 additions & 0 deletions config/policies.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ module.exports.policies = {
oAuthCompleteRegistration: [ 'hasValidUsername', 'hasNoProfanity', 'hasValidOAuthProvider' ],
},

'user-email/get': [ 'isLoggedIn' ],
'user-email/request-code': [ 'isLoggedIn', 'hasValidEmail' ],
'user-email/verify': [ 'isLoggedIn' ],

'game/create': [ 'isLoggedIn', 'hasGameName', 'hasNoProfanity' ],
'game/ai/create-vs-ai': [ 'isLoggedIn', 'isSocket' ],
'game/ai/ai-move': [ 'isLoggedIn', 'isSocket' ],
Expand Down
5 changes: 5 additions & 0 deletions config/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ module.exports.routes = {
'/api/user/logout': 'userController.logout',
'/api/user/status': 'userController.status',

// User email
'GET /api/user/email': 'user-email/get',
'POST /api/user/email/request-code': 'user-email/request-code',
'POST /api/user/email/verify': 'user-email/verify',

// OAuth
'GET /api/user/:provider/redirect': 'OAuthController.oAuthRedirect',
'GET /api/user/:provider/callback': 'OAuthController.oAuthCallback',
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const sailsGlobals = {
UserSpectatingGame: true,
GameStateRow: true,
Identity: true,
UserEmail: true,
};

const clientFiles = '**/client/**/*.{j,t}s?(x)';
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"lodash": "4.17.21",
"machinepack-passwords": "2.3.0",
"marked": "^16.4.1",
"nodemailer": "^10.0.15",
"pinia": "2.2.4",
"rollbar": "^2.26.5",
"sails": "^1.5.15",
Expand Down
6 changes: 5 additions & 1 deletion src/routes/home/HomeView.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<AnnouncementDialog />
<AnnouncementDialog v-model:open="announcementOpen" />
<EmailPreferenceDialog :waiting="announcementOpen || oAuthSignup" />
<OauthSignupDialog v-model="oAuthSignup" />
<div class="h-100 bg-base-dark">
<v-container id="home-container" class="container">
Expand Down Expand Up @@ -165,6 +166,7 @@ import CreateGameDialog from '@/routes/home/components/CreateGameDialog.vue';
import GameStatus from '_/utils/GameStatus.json';
import AnnouncementDialog from './components/announcementDialog/AnnouncementDialog.vue';
import OauthSignupDialog from '@/routes/home/components/OauthSignupDialog.vue';
import EmailPreferenceDialog from '@/routes/home/components/EmailPreferenceDialog.vue';

const TABS = {
PLAY: '/',
Expand All @@ -178,6 +180,7 @@ export default {
CreateGameDialog,
AnnouncementDialog,
OauthSignupDialog,
EmailPreferenceDialog,
},
setup() {
// Vuetify has its own translation layer that isn't very good
Expand All @@ -194,6 +197,7 @@ export default {
tab: TABS.PLAY,
loadingData: true,
oAuthSignup: false,
announcementOpen: false,
};
},
computed: {
Expand Down
Loading
Loading