diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 0d2e1cfc50..1b0525004e 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -4,7 +4,9 @@ "missingEmail": "Missing email.", "missingUsername": "Missing username.", "missingPassword": "Missing password.", + "missingNewPassword": "Missing newPassword.", "wrongPassword": "Wrong password.", + "passwordSaved": "New password has been saved.", "notAnEmail": "Invalid email address.", "emailTaken": "Email already taken.", "newEmailRequired": "The newEmail body parameter is required.", diff --git a/test/api/v3/integration/user/POST-user-update-email.test.js b/test/api/v3/integration/user/POST-user_update_email.test.js similarity index 94% rename from test/api/v3/integration/user/POST-user-update-email.test.js rename to test/api/v3/integration/user/POST-user_update_email.test.js index 08f8e4282f..5a8392ec1e 100644 --- a/test/api/v3/integration/user/POST-user-update-email.test.js +++ b/test/api/v3/integration/user/POST-user_update_email.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; import { model as User } from '../../../../../website/src/models/user'; -describe('POST /email/update', () => { +describe('POST /user/update-email', () => { let user; let fbUser; let endpoint = '/user/update-email'; @@ -20,7 +20,7 @@ describe('POST /email/update', () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: 'Invalid request parameters.', + message: t('invalidReqParams'), }); }); @@ -30,7 +30,7 @@ describe('POST /email/update', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: 'Invalid request parameters.', + message: t('invalidReqParams'), }); }); diff --git a/test/api/v3/integration/user/POST-user_update_password.test.js b/test/api/v3/integration/user/POST-user_update_password.test.js new file mode 100644 index 0000000000..daecbed4ed --- /dev/null +++ b/test/api/v3/integration/user/POST-user_update_password.test.js @@ -0,0 +1,53 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { model as User } from '../../../../../website/src/models/user'; + +describe('POST /user/update-password', async () => { + let endpoint = '/user/update-password'; + let user; + let password = 'password'; + let wrongPassword = 'wrong-password'; + let newPassword = 'new-password'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('successfully changes the password', async () => { + let previousHashedPassword = user.auth.local.hashed_password; + let response = await user.post(endpoint, { + password, + newPassword, + confirmPassword: newPassword, + }); + expect(response).to.eql({ message: t('passwordSaved') }); + user = await User.findOne({ _id: user._id }); + expect(user.auth.local.hashed_password).to.not.eql(previousHashedPassword); + }); + + it('new passwords mismatch', async () => { + await expect(user.post(endpoint, { + password, + newPassword, + confirmPassword: `${newPassword}-wrong-confirmation`, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('passwordConfirmationMatch'), + }); + }); + + it('existing password is wrong', async () => { + await expect(user.post(endpoint, { + password: wrongPassword, + newPassword, + confirmPassword: newPassword, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_update_username.test.js b/test/api/v3/integration/user/POST-user_update_username.test.js new file mode 100644 index 0000000000..1c56087007 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_update_username.test.js @@ -0,0 +1,82 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { model as User } from '../../../../../website/src/models/user'; + +describe('POST /user/update-username', async () => { + let endpoint = '/user/update-username'; + let user; + let newUsername = 'new-username'; + let existingUsername = 'existing-username'; + let password = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js + let wrongPassword = 'wrong-password'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('successfully changes username', async () => { + let response = await user.post(endpoint, { + username: newUsername, + password, + }); + expect(response).to.eql({ username: newUsername }); + user = await User.findOne({ _id: user._id }); + expect(user.auth.local.username).to.eql(newUsername); + }); + + context('errors', async () => { + describe('new username is unavailable', async () => { + beforeEach(async () => { + user = await generateUser(); + await user.update({'auth.local.username': existingUsername }); + }); + it('prevents username update', async () => { + await expect(user.post(endpoint, { + username: existingUsername, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameTaken'), + }); + }); + }); + it('password is wrong', async () => { + await expect(user.post(endpoint, { + username: newUsername, + password: wrongPassword, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); + describe('social-only user', async () => { + beforeEach(async () => { + user = await generateUser(); + await user.update({ 'auth.local': { ok: true } }); + }); + it('prevents username update', async () => { + await expect(user.post(endpoint, { + username: newUsername, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('userHasNoLocalRegistration'), + }); + }); + }); + it('new username is not provided', async () => { + await expect(user.post(endpoint, { + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 4b61d441d1..6cd73aeba0 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -42,6 +42,95 @@ api.getUser = { }, }; +/** + * @api {post} /user/update-password + * @apiVersion 3.0.0 + * @apiName updatePassword + * @apiGroup User + * @apiParam {string} password The old password + * @apiParam {string} newPassword The new password + * @apiParam {string} confirmPassword Password confirmation + * @apiSuccess {Object} The success message + **/ +api.updatePassword = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/update-password', + async handler (req, res) { + let user = res.locals.user; + + if (!user.auth.local.hashed_password) throw new BadRequest(res.t('userHasNoLocalRegistration')); + + let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + + req.checkBody({ + password: { + notEmpty: {errorMessage: res.t('missingNewPassword')}, + }, + newPassword: { + notEmpty: {errorMessage: res.t('missingPassword')}, + }, + }); + + if (req.body.newPassword !== req.body.confirmPassword) throw new NotAuthorized(res.t('passwordConfirmationMatch')); + + user.auth.local.hashed_password = passwordUtils.encrypt(req.body.newPassword, user.auth.local.salt); // eslint-disable-line camelcase + user.save(); + + res.send(200, { message: res.t('passwordSaved') }); + }, +}; + +/** + * @api {post} /user/update-username + * @apiVersion 3.0.0 + * @apiName updateUsername + * @apiGroup User + * @apiParam {string} password The password + * @apiParam {string} username New username + * @apiSuccess {Object} The new username + **/ +api.updateUsername = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/update-username', + async handler (req, res) { + let user = res.locals.user; + + req.checkBody({ + password: { + notEmpty: {errorMessage: res.t('missingPassword')}, + }, + username: { + notEmpty: { errorMessage: res.t('missingUsername') }, + }, + }); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.auth.local.username) throw new BadRequest(res.t('userHasNoLocalRegistration')); + + let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + + // check username already exists + let candidateUser = await User.findOne({ + 'auth.local.username': req.body.username, + }, {'auth.local': 1}).exec(); + if (candidateUser) throw new BadRequest(res.t('usernameTaken')); + + // save username + user.auth.local.lowerCaseUsername = req.body.username.toLowerCase(); + user.auth.local.username = req.body.username; + user.save(); + + res.send(200, { username: req.body.username }); + }, +}; + + /** * @api {post} /user/update-email * @apiVersion 3.0.0