diff --git a/bin/cli-users b/bin/cli-users index cfcbcd13a..a01980a20 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -287,8 +287,15 @@ async function createUser() { const { email, username, password, role } = answers; + const ctx = Context.forSystem(); + // Create the user. - const user = await UsersService.createLocalUser(email, password, username); + const user = await UsersService.createLocalUser( + ctx, + email, + password, + username + ); // Set the role. await UsersService.setRole(user.id, role); diff --git a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js index 38f211781..2bbe1a230 100644 --- a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js +++ b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js @@ -71,7 +71,6 @@ class OrganizationSettings extends React.Component { await this.props.savePending(); this.disableEditing(); }; - displayErrors = (errors = []) => (
{emailAddress}
: null} +{emailAddress}
: null} -{emailAddress}
+ ) : null} +<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <%= t('email.download.download_archive') %>
+<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <%= t('email.download.download_archive') %>
diff --git a/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs index 138ea1088..940d62bad 100644 --- a/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs +++ b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs @@ -1,3 +1,3 @@ <%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> - <%= BASE_URL %>account/download#<%= token %> + <%= downloadLandingURL %> diff --git a/plugins/talk-plugin-profile-data/server/mutators.js b/plugins/talk-plugin-profile-data/server/mutators.js index 5a897a2e1..09c9445ef 100644 --- a/plugins/talk-plugin-profile-data/server/mutators.js +++ b/plugins/talk-plugin-profile-data/server/mutators.js @@ -1,17 +1,41 @@ const moment = require('moment'); const uuid = require('uuid/v4'); const { DOWNLOAD_LINK_SUBJECT } = require('./constants'); +const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors'); +const { URL } = require('url'); + +// generateDownloadLinks will generate a signed set of links for a given user to +// download an archive of their data. +async function generateDownloadLinks(ctx, userID) { + const { connectors: { url: { BASE_URL }, secrets } } = ctx; + + // Generate a token for the download link. + const token = await secrets.jwt.sign( + { user: userID }, + { jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT } + ); + + // Generate the url that a user can land on. + const downloadLandingURL = new URL('account/download', BASE_URL); + downloadLandingURL.hash = token; + + // Generate the url that the API calls to download the actual zip. + const downloadFileURL = new URL('api/v1/account/download', BASE_URL); + downloadFileURL.searchParams.set('token', token); + + return { + downloadLandingURL: downloadLandingURL.href, + downloadFileURL: downloadFileURL.href, + }; +} + +async function sendDownloadLink(ctx) { + const { + user, + loaders: { Settings }, + connectors: { services: { Users, I18n, Limit }, models: { User } }, + } = ctx; -async function sendDownloadLink({ - user, - loaders: { Settings }, - connectors: { - errors, - secrets, - services: { Users, I18n, Limit }, - models: { User }, - }, -}) { // downloadLinkLimiter can be used to limit downloads for the user's data to // once every 7 days. const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d'); @@ -20,7 +44,7 @@ async function sendDownloadLink({ // 7 days. const attempts = await downloadLinkLimiter.get(user.id); if (attempts && attempts >= 1) { - throw errors.ErrMaxRateLimit; + throw new ErrMaxRateLimit(); } // Check if the lastAccountDownload time is within 7 days. @@ -30,7 +54,7 @@ async function sendDownloadLink({ .add(7, 'days') .isAfter(moment()) ) { - throw errors.ErrMaxRateLimit; + throw new ErrMaxRateLimit(); } // The account currently does not have a download link, let's record the @@ -38,21 +62,18 @@ async function sendDownloadLink({ // now. await downloadLinkLimiter.test(user.id); - // Generate a token for the download link. - const token = await secrets.jwt.sign( - { user: user.id }, - { jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT } - ); - const now = new Date(); + // Generate the download links. + const { downloadLandingURL } = await generateDownloadLinks(ctx, user.id); + const { organizationName } = await Settings.load('organizationName'); // Send the download link via the user's attached email account. await Users.sendEmail(user, { template: 'download', locals: { - token, + downloadLandingURL, organizationName, now, }, @@ -66,8 +87,20 @@ async function sendDownloadLink({ ); } +// downloadUser will return the download file url that can be used to directly +// download the archive. +async function downloadUser(ctx, userID) { + const { downloadFileURL } = await generateDownloadLinks(ctx, userID); + return downloadFileURL; +} + module.exports = ctx => ({ User: { requestDownloadLink: () => sendDownloadLink(ctx), + download: + // Only ADMIN users can execute an account download. + ctx.user && ctx.user.role === 'ADMIN' + ? userID => downloadUser(ctx, userID) + : () => Promise.reject(new ErrNotAuthorized()), }, }); diff --git a/plugins/talk-plugin-profile-data/server/resolvers.js b/plugins/talk-plugin-profile-data/server/resolvers.js index 691907f8c..7a261772f 100644 --- a/plugins/talk-plugin-profile-data/server/resolvers.js +++ b/plugins/talk-plugin-profile-data/server/resolvers.js @@ -5,6 +5,9 @@ module.exports = { requestDownloadLink: async (_, args, { mutators: { User } }) => { await User.requestDownloadLink(); }, + downloadUser: async (_, { id }, { mutators: { User } }) => ({ + archiveURL: await User.download(id), + }), }, User: { lastAccountDownload: (user, args, { user: currentUser }) => { diff --git a/plugins/talk-plugin-profile-data/server/router.js b/plugins/talk-plugin-profile-data/server/router.js index 2b0628753..2a2e0161a 100644 --- a/plugins/talk-plugin-profile-data/server/router.js +++ b/plugins/talk-plugin-profile-data/server/router.js @@ -101,11 +101,21 @@ module.exports = router => { // /api/v1/account/download will send back a zipped archive of the users // account. - router.post( + router.all( '/api/v1/account/download', express.urlencoded({ extended: false }), async (req, res, next) => { - const { token = null, check = false } = req.body; + let { token = null, check = false } = req.body; + + if (!token) { + // If the token wasn't found in the body, then we should check the query + // to see if it was passed that way. + token = req.query.token; + } + + if (!token) { + return res.status(400).end(); + } if (check) { // This request is checking to see if the token is valid. diff --git a/plugins/talk-plugin-profile-data/server/typeDefs.graphql b/plugins/talk-plugin-profile-data/server/typeDefs.graphql index 0029111c3..aa3d78adc 100644 --- a/plugins/talk-plugin-profile-data/server/typeDefs.graphql +++ b/plugins/talk-plugin-profile-data/server/typeDefs.graphql @@ -11,9 +11,23 @@ type RequestDownloadLinkResponse implements Response { errors: [UserError!] } +type DownloadUserResponse implements Response { + + # archiveURL is the link that can be used within the next 1 hour to download a + # users archive. + archiveURL: String + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + type RootMutation { # requestDownloadLink will request a download link be sent to the primary # users email address. requestDownloadLink: RequestDownloadLinkResponse + + # downloadUser will provide an account download for the indicated User. This + # mutation requires the ADMIN role. + downloadUser(id: ID!): DownloadUserResponse } diff --git a/plugins/talk-plugin-profile-data/translations.yml b/plugins/talk-plugin-profile-data/translations.yml index 6637c6958..d1059d470 100644 --- a/plugins/talk-plugin-profile-data/translations.yml +++ b/plugins/talk-plugin-profile-data/translations.yml @@ -1,7 +1,7 @@ en: download_landing: download_your_account: "Download Your Comment History" - download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma seperated value (or .csv) file that you can easily import into your favorite spreadsheet application." + download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma separated value (or .csv) file that you can easily import into your favorite spreadsheet application." all_information_included: "For each of your comments the following information is included:" information_included: date: "When you wrote the comment" diff --git a/routes/api/v1/account.js b/routes/api/v1/account.js index 3909d5dce..bc642f93f 100644 --- a/routes/api/v1/account.js +++ b/routes/api/v1/account.js @@ -109,20 +109,11 @@ router.put( async (req, res, next) => { const { token, password } = req.body; - if (!password || password.length < 8) { - return next(errors.ErrPasswordTooShort); - } - try { - let [user, redirect] = await UsersService.verifyPasswordResetToken(token); - - // Change the users' password. - await UsersService.changePassword(user.id, password); - + const { redirect } = await UsersService.resetPassword(token, password); res.json({ redirect }); - } catch (e) { - console.error(e); - return next(errors.ErrNotAuthorized); + } catch (err) { + return next(err); } } ); diff --git a/services/users.js b/services/users.js index 93d6fd753..a3301be10 100644 --- a/services/users.js +++ b/services/users.js @@ -1,4 +1,5 @@ const uuid = require('uuid'); +const moment = require('moment'); const bcrypt = require('bcryptjs'); const { ErrMaxRateLimit, @@ -132,7 +133,7 @@ class Users { locals: { body: message, }, - subject: 'Your account has been suspended', + subject: 'Your account has been suspended', // TODO: replace with translation }); } @@ -234,57 +235,64 @@ class Users { return user; } - static async _setUsername( - id, - username, - fromStatus, - toStatus, - assignedBy, - resetAllowed = false - ) { + static async setUsername(id, username, assignedBy) { try { + const oldestEditTime = moment() + .subtract(14, 'days') + .toDate(); + + // A username can be set if: + // + // - The previous status was 'UNSET' + // - The username has not been changed within the last 14 days. const query = { id, - 'status.username.status': fromStatus, - }; - if (!resetAllowed) { - query.username = { $ne: username }; - } - - let user = await User.findOneAndUpdate( - query, - { - $set: { - username, - lowercaseUsername: username.toLowerCase(), - 'status.username.status': toStatus, + $or: [ + { + 'status.username.status': 'UNSET', }, - $push: { - 'status.username.history': { - status: toStatus, - assigned_by: assignedBy, - created_at: Date.now(), + { + 'status.username.status': { $in: ['APPROVED', 'SET'] }, + 'status.username.history.created_at': { + $lte: oldestEditTime, }, }, + ], + }; + + const update = { + $set: { + username, + lowercaseUsername: username.toLowerCase(), + 'status.username.status': 'SET', }, - { - new: true, - } - ); + $push: { + 'status.username.history': { + status: 'SET', + assigned_by: assignedBy, + created_at: Date.now(), + }, + }, + }; + + let user = await User.findOneAndUpdate(query, update, { + new: true, + }); if (!user) { user = await Users.findById(id); if (user === null) { throw new ErrNotFound(); } - if (user.status.username.status !== fromStatus) { + if ( + !['UNSET', 'APPROVED', 'SET'].includes(user.status.username.status) || + !user.status.username.history.every(({ created_at }) => + oldestEditTime.isAfter(created_at) + ) + ) { throw new ErrPermissionUpdateUsername(); } - if (!resetAllowed && user.username === username) { - throw new ErrSameUsernameProvided(); - } - throw new Error('edit username failed for an unexpected reason'); } @@ -298,12 +306,57 @@ class Users { } } - static async setUsername(id, username, assignedBy) { - return Users._setUsername(id, username, 'UNSET', 'SET', assignedBy, true); - } - static async changeUsername(id, username, assignedBy) { - return Users._setUsername(id, username, 'REJECTED', 'CHANGED', assignedBy); + try { + const query = { + id, + username: { $ne: username }, + 'status.username.status': 'REJECTED', + }; + + const update = { + $set: { + username, + lowercaseUsername: username.toLowerCase(), + 'status.username.status': 'CHANGED', + }, + $push: { + 'status.username.history': { + status: 'CHANGED', + assigned_by: assignedBy, + created_at: Date.now(), + }, + }, + }; + + let user = await User.findOneAndUpdate(query, update, { + new: true, + }); + if (!user) { + user = await Users.findById(id); + if (user === null) { + throw new ErrNotFound(); + } + + if (user.status.username.status !== 'REJECTED') { + throw new ErrPermissionUpdateUsername(); + } + + if (user.username === username) { + throw new ErrSameUsernameProvided(); + } + + throw new Error('edit username failed for an unexpected reason'); + } + + return user; + } catch (err) { + if (err.code === 11000) { + throw new ErrUsernameTaken(); + } + + throw err; + } } /** @@ -490,6 +543,10 @@ class Users { } static async changePassword(id, password) { + if (!password || password.length < 8) { + throw new ErrPasswordTooShort(); + } + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); return User.update( @@ -725,18 +782,13 @@ class Users { }); } - /** - * Verifies a jwt and returns the associated user. Throws an error when the - * token isn't valid. - * - * @param {String} token the JSON Web Token to verify - */ + // TODO: update doc static async verifyPasswordResetToken(token) { if (!token) { throw new Error('cannot verify an empty token'); } - const { userId, loc, version } = await Users.verifyToken(token, { + const { userId, loc: redirect, version } = await Users.verifyToken(token, { subject: PASSWORD_RESET_JWT_SUBJECT, }); @@ -746,7 +798,33 @@ class Users { throw new Error('password reset token has expired'); } - return [user, loc]; + return { user, redirect, version }; + } + + // TODO: update doc + static async resetPassword(token, password) { + const { user, redirect, version } = await this.verifyPasswordResetToken( + token + ); + + if (!password || password.length < 8) { + throw new ErrPasswordTooShort(); + } + + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); + + // Update the user's password. + await User.update( + { id: user.id, __v: version }, + { + $inc: { __v: 1 }, + $set: { + password: hashedPassword, + }, + } + ); + + return { user, redirect }; } /** diff --git a/test/server/graph/mutations/changeUsername.js b/test/server/graph/mutations/changeUsername.js index eaff85434..a5c3daed8 100644 --- a/test/server/graph/mutations/changeUsername.js +++ b/test/server/graph/mutations/changeUsername.js @@ -89,7 +89,7 @@ describe('graph.mutations.changeUsername', () => { expect(res.data.changeUsername.errors).to.have.length(1); expect(res.data.changeUsername.errors[0]).to.have.property( 'translation_key', - 'NOT_AUTHORIZED' + 'EDIT_USERNAME_NOT_AUTHORIZED' ); // Set the user to the desired status. diff --git a/test/server/services/users.js b/test/server/services/users.js index e8a0172f7..a7d7475e9 100644 --- a/test/server/services/users.js +++ b/test/server/services/users.js @@ -2,6 +2,8 @@ const UsersService = require('../../../services/users'); const SettingsService = require('../../../services/settings'); const mailer = require('../../../services/mailer'); const Context = require('../../../graph/context'); +const timekeeper = require('timekeeper'); +const moment = require('moment'); const chai = require('chai'); chai.use(require('chai-as-promised')); @@ -303,6 +305,62 @@ describe('services.UsersService', () => { } }); }); + + if (func === 'setUsername') { + it('should let a user set their username from UNSET', async () => { + const user = mockUsers[0]; + + // Set the user to the desired status. + await UsersService.setUsernameStatus(user.id, 'UNSET'); + await UsersService.setUsername(user.id, 'new_username', null); + }); + + describe('time based', () => { + afterEach(() => { + timekeeper.reset(); + }); + + ['SET', 'APPROVED'].forEach(status => { + it(`should not allow users to change their username if it was changed within 14 of today from ${status}`, async () => { + const user = mockUsers[0]; + + // Set the user to the desired status. + await UsersService.setUsernameStatus(user.id, status); + + timekeeper.travel( + moment() + .add(5, 'days') + .toDate() + ); + + try { + await UsersService.setUsername(user.id, 'new_username', null); + throw new Error('edit was processed successfully'); + } catch (err) { + expect(err).have.property( + 'translation_key', + 'EDIT_USERNAME_NOT_AUTHORIZED' + ); + } + }); + + it(`allows users to change their username if it was changed 14 days before today from ${status}`, async () => { + const user = mockUsers[0]; + + // Set the user to the desired status. + await UsersService.setUsernameStatus(user.id, status); + + timekeeper.travel( + moment() + .add(15, 'days') + .toDate() + ); + + await UsersService.setUsername(user.id, 'new_username', null); + }); + }); + }); + } }); describe('#isValidUsername', () => { diff --git a/views/admin.ejs b/views/admin.ejs index 979e2ec41..b8f775d79 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -11,8 +11,8 @@ - <%- include partials/head %> + diff --git a/views/embed/stream.ejs b/views/embed/stream.ejs index 50faaf5c2..2027f6069 100644 --- a/views/embed/stream.ejs +++ b/views/embed/stream.ejs @@ -2,9 +2,9 @@ + <%- include ../partials/head %> - <%- include ../partials/head %> diff --git a/views/login.ejs b/views/login.ejs index 7af820a9c..c5a86c2fb 100644 --- a/views/login.ejs +++ b/views/login.ejs @@ -4,8 +4,8 @@ - <%- include partials/head %> +