diff --git a/migrations/20211001095256-users-add-inviteToken.js b/migrations/20211001095256-users-add-inviteToken.js new file mode 100644 index 000000000..06e3e5d4b --- /dev/null +++ b/migrations/20211001095256-users-add-inviteToken.js @@ -0,0 +1,9 @@ +'use strict'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE users ADD COLUMN inviteToken VARCHAR(128) DEFAULT ""', callback); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE users DROP COLUMN inviteToken', callback); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index 9a393e454..1c240110f 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS users( twoFactorAuthenticationEnabled BOOLEAN DEFAULT false, source VARCHAR(128) DEFAULT "", role VARCHAR(32), + inviteToken VARCHAR(128) DEFAULT "", resetToken VARCHAR(128) DEFAULT "", resetTokenCreationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, active BOOLEAN DEFAULT 1, diff --git a/src/routes/cloudron.js b/src/routes/cloudron.js index 8f893ea6c..2391e481e 100644 --- a/src/routes/cloudron.js +++ b/src/routes/cloudron.js @@ -123,16 +123,16 @@ async function passwordReset(req, res, next) { async function setupAccount(req, res, next) { assert.strictEqual(typeof req.body, 'object'); - if (!req.body.resetToken || typeof req.body.resetToken !== 'string') return next(new HttpError(400, 'resetToken must be a non-empty string')); + if (!req.body.inviteToken || typeof req.body.inviteToken !== 'string') return next(new HttpError(400, 'inviteToken must be a non-empty string')); if (!req.body.password || typeof req.body.password !== 'string') return next(new HttpError(400, 'password must be a non-empty string')); // only sent if profile is not locked if ('username' in req.body && typeof req.body.username !== 'string') return next(new HttpError(400, 'username must be a non-empty string')); if ('displayName' in req.body && typeof req.body.displayName !== 'string') return next(new HttpError(400, 'displayName must be a non-empty string')); - const [error, userObject] = await safe(users.getByResetToken(req.body.resetToken)); - if (error) return next(new HttpError(401, 'Invalid resetToken')); - if (!userObject) return next(new HttpError(401, 'Invalid resetToken')); + const [error, userObject] = await safe(users.getByInviteToken(req.body.inviteToken)); + if (error) return next(new HttpError(401, 'Invalid inviteToken')); + if (!userObject) return next(new HttpError(401, 'Invalid inviteToken')); // if you fix the duration here, the emails and UI have to be fixed as well if (Date.now() - userObject.resetTokenCreationTime > 7 * 24 * 60 * 60 * 1000) return next(new HttpError(401, 'Token expired')); diff --git a/src/users.js b/src/users.js index 3f1a33e46..6c65438af 100644 --- a/src/users.js +++ b/src/users.js @@ -11,6 +11,7 @@ exports = module.exports = { list, listPaged, get, + getByInviteToken, getByResetToken, getByUsername, getByEmail, @@ -57,7 +58,7 @@ exports = module.exports = { const ORDERED_ROLES = [ exports.ROLE_USER, exports.ROLE_USER_MANAGER, exports.ROLE_ADMIN, exports.ROLE_OWNER ]; // the avatar field is special and not added here to reduce response sizes -const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'resetToken', 'displayName', +const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'inviteToken', 'resetToken', 'displayName', 'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'loginLocationsJson' ].join(','); const DEFAULT_GHOST_LIFETIME = 6 * 60 * 60 * 1000; // 6 hours @@ -133,7 +134,7 @@ function validateEmail(email) { return null; } -function validateResetToken(token) { +function validateToken(token) { assert.strictEqual(typeof token, 'string'); if (token.length !== 64) return new BoxError(BoxError.BAD_FIELD, 'Invalid token'); // 256-bit hex coded token @@ -220,6 +221,7 @@ async function add(email, data, auditSource) { password: Buffer.from(derivedKey, 'binary').toString('hex'), salt: salt.toString('hex'), resetToken: '', + inviteToken: '', displayName: displayName, source: source, role: role, @@ -477,7 +479,7 @@ async function getByRole(role) { async function getByResetToken(resetToken) { assert.strictEqual(typeof resetToken, 'string'); - let error = validateResetToken(resetToken); + let error = validateToken(resetToken); if (error) throw error; const result = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE resetToken=?`, [ resetToken ]); @@ -486,6 +488,18 @@ async function getByResetToken(resetToken) { return postProcess(result[0]); } +async function getByInviteToken(inviteToken) { + assert.strictEqual(typeof inviteToken, 'string'); + + let error = validateToken(inviteToken); + if (error) throw error; + + const result = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE inviteToken=?`, [ inviteToken ]); + if (result.length === 0) return null; + + return postProcess(result[0]); +} + async function getByUsername(username) { assert.strictEqual(typeof username, 'string'); @@ -691,15 +705,13 @@ async function sendInvite(user, options, auditSource) { if (user.source) throw new BoxError(BoxError.CONFLICT, 'User is from an external directory'); - const resetToken = hat(256); - const resetTokenCreationTime = new Date(); + const inviteToken = hat(256); - user.resetToken = resetToken; - user.resetTokenCreationTime = resetTokenCreationTime; - await update(user, { resetToken, resetTokenCreationTime }, auditSource); + user.inviteToken = inviteToken; + await update(user, { inviteToken }, auditSource); const directoryConfig = await settings.getDirectoryConfig(); - let inviteLink = `${settings.dashboardOrigin()}/setupaccount.html?resetToken=${user.resetToken}&email=${encodeURIComponent(user.email)}`; + let inviteLink = `${settings.dashboardOrigin()}/setupaccount.html?inviteToken=${user.inviteToken}&email=${encodeURIComponent(user.email)}`; if (user.username) inviteLink += `&username=${encodeURIComponent(user.username)}`; if (user.displayName) inviteLink += `&displayName=${encodeURIComponent(user.displayName)}`; @@ -718,9 +730,9 @@ async function setupAccount(user, data, auditSource) { const directoryConfig = await settings.getDirectoryConfig(); if (directoryConfig.lockUserProfiles) return; - await update(user, _.pick(data, 'username', 'displayName'), auditSource); + await update(user, { username: data.username, displayName: data.displayName, inviteToken: '' }, auditSource); - await setPassword(user, data.password, auditSource); // setPassword clears the resetToken + await setPassword(user, data.password, auditSource); const token = { clientId: tokens.ID_WEBADMIN, identifier: user.id, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS }; const result = await tokens.add(token);