From d21dd973363f9c5eca86a1007cb67e445b0d2e02 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 2 Mar 2025 12:32:43 +1100 Subject: [PATCH] fix: Remove username from email verification and password reset process (#8488) BREAKING CHANGE: This removes the username from the email verification and password reset process to prevent storing personally identifiable information (PII) in server and infrastructure logs. Customized HTML pages or emails related to email verification and password reset may need to be adapted accordingly. See the new templates that come bundled with Parse Server and the [migration guide](https://github.com/parse-community/parse-server/blob/alpha/8.0.0.md) for more details. --- 8.0.0.md | 27 ++++ .../email_verification_link_expired.html | 2 +- .../de/email_verification_link_expired.html | 2 +- public/email_verification_link_expired.html | 2 +- public_html/invalid_verification_link.html | 6 +- spec/AccountLockoutPolicy.spec.js | 4 +- spec/EmailVerificationToken.spec.js | 72 +++++++++- spec/PagesRouter.spec.js | 48 ++----- spec/ParseLiveQuery.spec.js | 2 +- spec/PasswordPolicy.spec.js | 42 +++--- spec/PublicAPI.spec.js | 26 +--- spec/RegexVulnerabilities.spec.js | 8 +- spec/UserController.spec.js | 8 +- spec/ValidationAndPasswordsReset.spec.js | 104 ++++++++++++--- spec/helper.js | 102 +++++++------- spec/support/CurrentSpecReporter.js | 1 + src/Controllers/UserController.js | 125 ++++++++---------- src/GraphQL/loaders/usersMutations.js | 7 +- src/Routers/PagesRouter.js | 53 +++----- src/Routers/PublicAPIRouter.js | 54 ++++---- src/Routers/UsersRouter.js | 14 +- 21 files changed, 401 insertions(+), 308 deletions(-) create mode 100644 8.0.0.md diff --git a/8.0.0.md b/8.0.0.md new file mode 100644 index 00000000..3d7dd9d6 --- /dev/null +++ b/8.0.0.md @@ -0,0 +1,27 @@ +# Parse Server 8 Migration Guide + +This document only highlights specific changes that require a longer explanation. For a full list of changes in Parse Server 8 please refer to the [changelog](https://github.com/parse-community/parse-server/blob/alpha/CHANGELOG.md). + +--- + +- [Email Verification](#email-verification) + +--- + +## Email Verification + +In order to remove sensitive information (PII) from technical logs, the `Parse.User.username` field has been removed from the email verification process. This means the username will no longer be used and the already existing verification token, that is internal to Parse Server and associated with the user, will be used instead. This makes use of the fact that an expired verification token is not deleted from the database by Parse Server, despite being expired, and can therefore be used to identify a user. + +This change affects how verification emails with expired tokens are handled. When opening a verification link that contains an expired token, the page that the user is redirected to will no longer provide the `username` as a URL query parameter. Instead, the URL query parameter `token` will be provided. + +The request to re-send a verification email changed to sending a `POST` request to the endpoint `/resend_verification_email` with `token` in the body, instead of `username`. If you have customized the HTML pages for email verification either for the `PagesRouter` in `/public/` or the deprecated `PublicAPIRouter` in `/public_html/`, you need to adapt the form request in your custom pages. See the example pages in these aforementioned directories for how the forms must be set up. + +> [!WARNING] +> An expired verification token is not automatically deleted from the database by Parse Server even though it has expired. If you have implemented a custom clean-up logic that removes expired tokens, this will break the form request to re-send a verification email as the expired token won't be found and cannot be associated with any user. In that case you'll have to implement your custom process to re-send a verification email. + +> [!IMPORTANT] +> Parse Server does not keep a history of verification tokens but only stores the most recently generated verification token in the database. Every time Parse Server generates a new verification token, the currently stored token is replaced. If a user opens a link with an expired token, and that token has already been replaced in the database, Parse Server cannot associate the expired token with any user. In this case, another way has to be offered to the user to re-send a verification email. To mitigate this issue, set the Parse Server option `emailVerifyTokenReuseIfValid: true` and set `emailVerifyTokenValidityDuration` to a longer duration, which ensures that the currently stored verification token is not replaced too soon. + +Related pull requests: + +- https://github.com/parse-community/parse-server/pull/8488 diff --git a/public/de-AT/email_verification_link_expired.html b/public/de-AT/email_verification_link_expired.html index bea8d949..cae39c7a 100644 --- a/public/de-AT/email_verification_link_expired.html +++ b/public/de-AT/email_verification_link_expired.html @@ -15,7 +15,7 @@

{{appName}}

Expired verification link!

- +
diff --git a/public/de/email_verification_link_expired.html b/public/de/email_verification_link_expired.html index bea8d949..cae39c7a 100644 --- a/public/de/email_verification_link_expired.html +++ b/public/de/email_verification_link_expired.html @@ -15,7 +15,7 @@

{{appName}}

Expired verification link!

- +
diff --git a/public/email_verification_link_expired.html b/public/email_verification_link_expired.html index bea8d949..cae39c7a 100644 --- a/public/email_verification_link_expired.html +++ b/public/email_verification_link_expired.html @@ -15,7 +15,7 @@

{{appName}}

Expired verification link!

- +
diff --git a/public_html/invalid_verification_link.html b/public_html/invalid_verification_link.html index fe6914fc..063ac354 100644 --- a/public_html/invalid_verification_link.html +++ b/public_html/invalid_verification_link.html @@ -47,8 +47,8 @@ window.onload = addDataToForm; function addDataToForm() { - var username = getUrlParameter("username"); - document.getElementById("usernameField").value = username; + const token = getUrlParameter("token"); + document.getElementById("token").value = token; var appId = getUrlParameter("appId"); document.getElementById("resendForm").action = '/apps/' + appId + '/resend_verification_email' @@ -60,7 +60,7 @@

Invalid Verification Link

- +
diff --git a/spec/AccountLockoutPolicy.spec.js b/spec/AccountLockoutPolicy.spec.js index 43212d0e..da8048ad 100644 --- a/spec/AccountLockoutPolicy.spec.js +++ b/spec/AccountLockoutPolicy.spec.js @@ -419,7 +419,7 @@ describe('lockout with password reset option', () => { await request({ method: 'POST', url: `${config.publicServerURL}/apps/test/request_password_reset`, - body: `new_password=${newPassword}&token=${token}&username=${username}`, + body: `new_password=${newPassword}&token=${token}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -454,7 +454,7 @@ describe('lockout with password reset option', () => { await request({ method: 'POST', url: `${config.publicServerURL}/apps/test/request_password_reset`, - body: `new_password=${newPassword}&token=${token}&username=${username}`, + body: `new_password=${newPassword}&token=${token}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index 1e9f6a78..ec3d7b8e 100644 --- a/spec/EmailVerificationToken.spec.js +++ b/spec/EmailVerificationToken.spec.js @@ -39,8 +39,10 @@ describe('Email Verification Token Expiration: ', () => { followRedirects: false, }).then(response => { expect(response.status).toEqual(302); + const url = new URL(sendEmailOptions.link); + const token = url.searchParams.get('token'); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=testEmailVerifyTokenValidity&appId=test' + `Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}` ); done(); }); @@ -135,7 +137,7 @@ describe('Email Verification Token Expiration: ', () => { }).then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=testEmailVerifyTokenValidity' + 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html' ); done(); }); @@ -292,6 +294,64 @@ describe('Email Verification Token Expiration: ', () => { }); }); + it('can resend email using an expired token', async () => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('test'); + user.setPassword('password'); + user.set('email', 'user@example.com'); + await user.signUp(); + + await Parse.Server.database.update( + '_User', + { objectId: user.id }, + { + _email_verify_token_expires_at: Parse._encode(new Date('2000')), + } + ); + + const obj = await Parse.Server.database.find( + '_User', + { objectId: user.id }, + {}, + Auth.maintenance(Parse.Server) + ); + const token = obj[0]._email_verify_token; + + const res = await request({ + url: `http://localhost:8378/1/apps/test/verify_email?token=${token}`, + method: 'GET', + }); + expect(res.text).toEqual( + `Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}` + ); + + const formUrl = `http://localhost:8378/1/apps/test/resend_verification_email`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + token: token, + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.text).toEqual( + `Found. Redirecting to http://localhost:8378/1/apps/link_send_success.html` + ); + }); + it_id('9365c53c-b8b4-41f7-a3c1-77882f76a89c')(it)('can conditionally send emails', async () => { let sendEmailOptions; const emailAdapter = { @@ -614,8 +674,10 @@ describe('Email Verification Token Expiration: ', () => { followRedirects: false, }).then(response => { expect(response.status).toEqual(302); + const url = new URL(sendEmailOptions.link); + const token = url.searchParams.get('token'); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=testEmailVerifyTokenValidity' + `Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}` ); done(); }); @@ -667,8 +729,10 @@ describe('Email Verification Token Expiration: ', () => { followRedirects: false, }).then(response => { expect(response.status).toEqual(302); + const url = new URL(sendEmailOptions.link); + const token = url.searchParams.get('token'); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=testEmailVerifyTokenValidity&appId=test' + `Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}` ); done(); }); diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index ca61fa4f..0aa5bb35 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -108,7 +108,7 @@ describe('Pages Router', () => { const res = await request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=user1&token=43634643&username=username`, + body: `new_password=user1&token=43634643`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', @@ -124,7 +124,7 @@ describe('Pages Router', () => { await request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=&token=132414&username=Johnny`, + body: `new_password=&token=132414`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', @@ -137,30 +137,12 @@ describe('Pages Router', () => { } }); - it('request_password_reset: responds with AJAX error on missing username', async () => { - try { - await request({ - method: 'POST', - url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=user1&token=43634643&username=`, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-Requested-With': 'XMLHttpRequest', - }, - followRedirects: false, - }); - } catch (error) { - expect(error.status).not.toBe(302); - expect(error.text).toEqual('{"code":200,"error":"Missing username"}'); - } - }); - it('request_password_reset: responds with AJAX error on missing token', async () => { try { await request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=user1&token=&username=Johnny`, + body: `new_password=user1&token=`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', @@ -577,7 +559,7 @@ describe('Pages Router', () => { spyOnProperty(Page.prototype, 'defaultFile').and.returnValue(jsonPageFile); const response = await request({ - url: `http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&username=exampleUsername&locale=${exampleLocale}`, + url: `http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=${exampleLocale}`, followRedirects: false, }).catch(e => e); expect(response.status).toEqual(200); @@ -626,7 +608,7 @@ describe('Pages Router', () => { await reconfigureServer(config); const response = await request({ url: - 'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&username=exampleUsername&locale=de-AT', + 'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=de-AT', followRedirects: false, method: 'POST', }); @@ -640,7 +622,7 @@ describe('Pages Router', () => { await reconfigureServer(config); const response = await request({ url: - 'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&username=exampleUsername&locale=de-AT', + 'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=de-AT', followRedirects: false, method: 'GET', }); @@ -676,13 +658,11 @@ describe('Pages Router', () => { const appId = linkResponse.headers['x-parse-page-param-appid']; const token = linkResponse.headers['x-parse-page-param-token']; const locale = linkResponse.headers['x-parse-page-param-locale']; - const username = linkResponse.headers['x-parse-page-param-username']; const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; const passwordResetPagePath = pageResponse.calls.all()[0].args[0]; expect(appId).toBeDefined(); expect(token).toBeDefined(); expect(locale).toBeDefined(); - expect(username).toBeDefined(); expect(publicServerUrl).toBeDefined(); expect(passwordResetPagePath).toMatch( new RegExp(`\/${exampleLocale}\/${pages.passwordReset.defaultFile}`) @@ -696,7 +676,6 @@ describe('Pages Router', () => { body: { token, locale, - username, new_password: 'newPassword', }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -793,15 +772,13 @@ describe('Pages Router', () => { const appId = linkResponse.headers['x-parse-page-param-appid']; const locale = linkResponse.headers['x-parse-page-param-locale']; - const username = linkResponse.headers['x-parse-page-param-username']; const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0]; expect(appId).toBeDefined(); expect(locale).toBe(exampleLocale); - expect(username).toBeDefined(); expect(publicServerUrl).toBeDefined(); expect(invalidVerificationPagePath).toMatch( - new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkExpired.defaultFile}`) + new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`) ); const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`; @@ -810,7 +787,7 @@ describe('Pages Router', () => { method: 'POST', body: { locale, - username, + username: 'exampleUsername', }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, followRedirects: false, @@ -847,17 +824,15 @@ describe('Pages Router', () => { const appId = linkResponse.headers['x-parse-page-param-appid']; const locale = linkResponse.headers['x-parse-page-param-locale']; - const username = linkResponse.headers['x-parse-page-param-username']; const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; await jasmine.timeout(); const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0]; expect(appId).toBeDefined(); expect(locale).toBe(exampleLocale); - expect(username).toBeDefined(); expect(publicServerUrl).toBeDefined(); expect(invalidVerificationPagePath).toMatch( - new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkExpired.defaultFile}`) + new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`) ); spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() => @@ -870,7 +845,7 @@ describe('Pages Router', () => { method: 'POST', body: { locale, - username, + username: 'exampleUsername', }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, followRedirects: false, @@ -1155,12 +1130,10 @@ describe('Pages Router', () => { const appId = linkResponse.headers['x-parse-page-param-appid']; const token = linkResponse.headers['x-parse-page-param-token']; - const username = linkResponse.headers['x-parse-page-param-username']; const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; const passwordResetPagePath = pageResponse.calls.all()[0].args[0]; expect(appId).toBeDefined(); expect(token).toBeDefined(); - expect(username).toBeDefined(); expect(publicServerUrl).toBeDefined(); expect(passwordResetPagePath).toMatch(new RegExp(`\/${pages.passwordReset.defaultFile}`)); pageResponse.calls.reset(); @@ -1171,7 +1144,6 @@ describe('Pages Router', () => { method: 'POST', body: { token, - username, new_password: 'newPassword', }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index a65eef60..6294c609 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -969,7 +969,7 @@ describe('ParseLiveQuery', function () { const userController = new UserController(emailAdapter, 'test', { verifyUserEmails: true, }); - userController.verifyEmail(foundUser.username, foundUser._email_verify_token); + userController.verifyEmail(foundUser._email_verify_token); }); }); }); diff --git a/spec/PasswordPolicy.spec.js b/spec/PasswordPolicy.spec.js index b39790cf..1fd2e6aa 100644 --- a/spec/PasswordPolicy.spec.js +++ b/spec/PasswordPolicy.spec.js @@ -107,7 +107,7 @@ describe('Password Policy: ', () => { }) .then(response => { expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=testResetTokenValidity/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&/; expect(response.text.match(re)).not.toBe(null); done(); }) @@ -622,7 +622,7 @@ describe('Password Policy: ', () => { }) .then(response => { expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/; const match = response.text.match(re); if (!match) { fail('should have a token'); @@ -634,7 +634,7 @@ describe('Password Policy: ', () => { request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=has2init&token=${token}&username=user1`, + body: `new_password=has2init&token=${token}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -645,7 +645,7 @@ describe('Password Policy: ', () => { .then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1' + 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html' ); Parse.User.logIn('user1', 'has2init') @@ -714,7 +714,7 @@ describe('Password Policy: ', () => { }) .then(response => { expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/; const match = response.text.match(re); if (!match) { fail('should have a token'); @@ -726,7 +726,7 @@ describe('Password Policy: ', () => { request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=hasnodigit&token=${token}&username=user1`, + body: `new_password=hasnodigit&token=${token}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -737,7 +737,7 @@ describe('Password Policy: ', () => { .then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - `Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=Password%20should%20contain%20at%20least%20one%20digit.&app=passwordPolicy` + `Found. Redirecting to http://localhost:8378/1/apps/choose_password?token=${token}&id=test&error=Password%20should%20contain%20at%20least%20one%20digit.&app=passwordPolicy` ); Parse.User.logIn('user1', 'has 1 digit') @@ -900,7 +900,7 @@ describe('Password Policy: ', () => { }) .then(response => { expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/; const match = response.text.match(re); if (!match) { fail('should have a token'); @@ -912,7 +912,7 @@ describe('Password Policy: ', () => { request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=xuser12&token=${token}&username=user1`, + body: `new_password=xuser12&token=${token}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -923,7 +923,7 @@ describe('Password Policy: ', () => { .then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - `Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=Password%20cannot%20contain%20your%20username.&app=passwordPolicy` + `Found. Redirecting to http://localhost:8378/1/apps/choose_password?token=${token}&id=test&error=Password%20cannot%20contain%20your%20username.&app=passwordPolicy` ); Parse.User.logIn('user1', 'r@nd0m') @@ -991,7 +991,7 @@ describe('Password Policy: ', () => { resolveWithFullResponse: true, }); expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/; const match = response.text.match(re); if (!match) { fail('should have a token'); @@ -1003,7 +1003,7 @@ describe('Password Policy: ', () => { await request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=xuser12&token=${token}&username=user1`, + body: `new_password=xuser12&token=${token}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', @@ -1051,7 +1051,7 @@ describe('Password Policy: ', () => { }) .then(response => { expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/; const match = response.text.match(re); if (!match) { fail('should have a token'); @@ -1063,7 +1063,7 @@ describe('Password Policy: ', () => { request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=uuser11&token=${token}&username=user1`, + body: `new_password=uuser11&token=${token}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -1074,7 +1074,7 @@ describe('Password Policy: ', () => { .then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1' + 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html' ); Parse.User.logIn('user1', 'uuser11') @@ -1317,7 +1317,7 @@ describe('Password Policy: ', () => { }) .then(response => { expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/; const match = response.text.match(re); if (!match) { fail('should have a token'); @@ -1329,7 +1329,7 @@ describe('Password Policy: ', () => { request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=uuser11&token=${token}&username=user1`, + body: `new_password=uuser11&token=${token}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -1340,7 +1340,7 @@ describe('Password Policy: ', () => { .then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1' + 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html' ); Parse.User.logIn('user1', 'uuser11') @@ -1472,7 +1472,7 @@ describe('Password Policy: ', () => { }) .then(response => { expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/; const match = response.text.match(re); if (!match) { fail('should have a token'); @@ -1484,7 +1484,7 @@ describe('Password Policy: ', () => { return request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=user1&token=${token}&username=user1`, + body: `new_password=user1&token=${token}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -1500,7 +1500,7 @@ describe('Password Policy: ', () => { const token = data[1]; expect(response.status).toEqual(302); expect(response.text).toEqual( - `Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=New%20password%20should%20not%20be%20the%20same%20as%20last%201%20passwords.&app=passwordPolicy` + `Found. Redirecting to http://localhost:8378/1/apps/choose_password?token=${token}&id=test&error=New%20password%20should%20not%20be%20the%20same%20as%20last%201%20passwords.&app=passwordPolicy` ); done(); return Promise.resolve(); diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js index 54566291..63df9cb4 100644 --- a/spec/PublicAPI.spec.js +++ b/spec/PublicAPI.spec.js @@ -10,28 +10,6 @@ const request = function (url, callback) { }; describe('public API', () => { - it('should return missing username error on ajax request without username provided', async () => { - await reconfigureServer({ - publicServerURL: 'http://localhost:8378/1', - }); - - try { - await req({ - method: 'POST', - url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=user1&token=43634643&username=`, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-Requested-With': 'XMLHttpRequest', - }, - followRedirects: false, - }); - } catch (error) { - expect(error.status).not.toBe(302); - expect(error.text).toEqual('{"code":200,"error":"Missing username"}'); - } - }); - it('should return missing token error on ajax request without token provided', async () => { await reconfigureServer({ publicServerURL: 'http://localhost:8378/1', @@ -41,7 +19,7 @@ describe('public API', () => { await req({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=user1&token=&username=Johnny`, + body: `new_password=user1&token=`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', @@ -63,7 +41,7 @@ describe('public API', () => { await req({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=&token=132414&username=Johnny`, + body: `new_password=&token=132414`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', diff --git a/spec/RegexVulnerabilities.spec.js b/spec/RegexVulnerabilities.spec.js index 9dc8eb8d..8418494b 100644 --- a/spec/RegexVulnerabilities.spec.js +++ b/spec/RegexVulnerabilities.spec.js @@ -95,7 +95,7 @@ describe('Regex Vulnerabilities', () => { it('should not work with regex', async () => { expect(user.get('emailVerified')).toEqual(false); await request({ - url: `${serverURL}/apps/test/verify_email?username=someemail@somedomain.com&token[$regex]=`, + url: `${serverURL}/apps/test/verify_email?token[$regex]=`, method: 'GET', }); await user.fetch({ useMasterKey: true }); @@ -117,7 +117,7 @@ describe('Regex Vulnerabilities', () => { }).then(res => res.data); // It should work await request({ - url: `${serverURL}/apps/test/verify_email?username=someemail@somedomain.com&token=${current._email_verify_token}`, + url: `${serverURL}/apps/test/verify_email?token=${current._email_verify_token}`, method: 'GET', }); await user.fetch({ useMasterKey: true }); @@ -144,7 +144,7 @@ describe('Regex Vulnerabilities', () => { }); await user.fetch({ useMasterKey: true }); const passwordResetResponse = await request({ - url: `${serverURL}/apps/test/request_password_reset?username=someemail@somedomain.com&token[$regex]=`, + url: `${serverURL}/apps/test/request_password_reset?token[$regex]=`, method: 'GET', }); expect(passwordResetResponse.status).toEqual(302); @@ -192,7 +192,7 @@ describe('Regex Vulnerabilities', () => { }).then(res => res.data); const token = current._perishable_token; const passwordResetResponse = await request({ - url: `${serverURL}/apps/test/request_password_reset?username=someemail@somedomain.com&token=${token}`, + url: `${serverURL}/apps/test/request_password_reset?token=${token}`, method: 'GET', }); expect(passwordResetResponse.status).toEqual(302); diff --git a/spec/UserController.spec.js b/spec/UserController.spec.js index e240d466..31d50519 100644 --- a/spec/UserController.spec.js +++ b/spec/UserController.spec.js @@ -19,7 +19,6 @@ describe('UserController', () => { let emailOptions; emailAdapter.sendVerificationEmail = options => { emailOptions = options; - return Promise.resolve(); }; const username = 'verificationUser'; @@ -35,7 +34,8 @@ describe('UserController', () => { const rawToken = rawUser[0]._email_verify_token; expect(rawToken).toBeDefined(); expect(rawUsername).toBe(username); - expect(emailOptions.link).toEqual(`http://www.example.com/apps/test/verify_email?token=${rawToken}&username=${username}`); + + expect(emailOptions.link).toEqual(`http://www.example.com/apps/test/verify_email?token=${rawToken}`); }); }); @@ -54,7 +54,6 @@ describe('UserController', () => { let emailOptions; emailAdapter.sendVerificationEmail = options => { emailOptions = options; - return Promise.resolve(); }; const username = 'verificationUser'; @@ -70,7 +69,8 @@ describe('UserController', () => { const rawToken = rawUser[0]._email_verify_token; expect(rawToken).toBeDefined(); expect(rawUsername).toBe(username); - expect(emailOptions.link).toEqual(`http://someother.example.com/handle-parse-iframe?link=%2Fapps%2Ftest%2Fverify_email&token=${rawToken}&username=${username}`); + + expect(emailOptions.link).toEqual(`http://someother.example.com/handle-parse-iframe?link=%2Fapps%2Ftest%2Fverify_email&token=${rawToken}`); }); }); }); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 7c533426..3f6d4048 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -3,6 +3,7 @@ const MockEmailAdapterWithOptions = require('./support/MockEmailAdapterWithOptions'); const request = require('../lib/request'); const Config = require('../lib/Config'); +const Auth = require('../lib/Auth'); describe('Custom Pages, Email Verification, Password Reset', () => { it('should set the custom pages', done => { @@ -334,7 +335,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { }); expect(response.status).toEqual(302); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=user' + 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html' ); user = await new Parse.Query(Parse.User).first({ useMasterKey: true }); expect(user.get('emailVerified')).toEqual(true); @@ -675,7 +676,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { }).then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=user' + 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html' ); user .fetch() @@ -734,12 +735,12 @@ describe('Custom Pages, Email Verification, Password Reset', () => { publicServerURL: 'http://localhost:8378/1', }).then(() => { request({ - url: 'http://localhost:8378/1/apps/test/verify_email?token=asdfasdf&username=sadfasga', + url: 'http://localhost:8378/1/apps/test/verify_email?token=asdfasdf', followRedirects: false, }).then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=sadfasga&appId=test' + 'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=asdfasdf' ); done(); }); @@ -779,12 +780,12 @@ describe('Custom Pages, Email Verification, Password Reset', () => { const emailAdapter = { sendVerificationEmail: () => { request({ - url: 'http://localhost:8378/1/apps/test/verify_email?token=invalid&username=zxcv', + url: 'http://localhost:8378/1/apps/test/verify_email?token=invalid', followRedirects: false, }).then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=zxcv&appId=test' + 'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=invalid' ); user.fetch().then(() => { expect(user.get('emailVerified')).toEqual(false); @@ -824,7 +825,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { followRedirects: false, }).then(response => { expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=zxcv%2Bzxcv/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&/; expect(response.text.match(re)).not.toBe(null); done(); }); @@ -864,8 +865,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { publicServerURL: 'http://localhost:8378/1', }).then(() => { request({ - url: - 'http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf&username=sadfasga', + url: 'http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf', followRedirects: false, }).then(response => { expect(response.status).toEqual(302); @@ -887,7 +887,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { followRedirects: false, }).then(response => { expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/; const match = response.text.match(re); if (!match) { fail('should have a token'); @@ -907,7 +907,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { }).then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=zxcv' + 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html' ); Parse.User.logIn('zxcv', 'hello').then( @@ -964,7 +964,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { followRedirects: false, }).then(response => { expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv%2B1/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/; const match = response.text.match(re); if (!match) { fail('should have a token'); @@ -984,7 +984,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { }).then(response => { expect(response.status).toEqual(302); expect(response.text).toEqual( - 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=zxcv%2B1' + 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html' ); done(); }); @@ -1023,7 +1023,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { followRedirects: false, }); expect(response.status).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv/; + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/; const match = response.text.match(re); if (!match) { fail('should have a token'); @@ -1081,7 +1081,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { await request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=user1&token=12345&username=Johnny`, + body: `new_password=user1&token=12345`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', @@ -1150,6 +1150,80 @@ describe('Custom Pages, Email Verification, Password Reset', () => { }); }); + it('can resend email using an expired reset password token', async () => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + passwordPolicy: { + resetTokenValidityDuration: 5 * 60, // 5 minutes + }, + silent: false, + }); + user.setUsername('test'); + user.setPassword('password'); + user.set('email', 'user@example.com'); + await user.signUp(); + await Parse.User.requestPasswordReset('user@example.com'); + + await Parse.Server.database.update( + '_User', + { objectId: user.id }, + { + _perishable_token_expires_at: Parse._encode(new Date('2000')), + } + ); + + let obj = await Parse.Server.database.find( + '_User', + { objectId: user.id }, + {}, + Auth.maintenance(Parse.Server) + ); + const token = obj[0]._perishable_token; + const res = await request({ + url: `http://localhost:8378/1/apps/test/request_password_reset`, + method: 'POST', + body: { + token, + new_password: 'newpassword', + }, + }); + expect(res.text).toEqual( + `Found. Redirecting to http://localhost:8378/1/apps/choose_password?id=test&error=The%20password%20reset%20link%20has%20expired&app=emailVerifyToken&token=${token}` + ); + + await request({ + url: `http://localhost:8378/1/requestPasswordReset`, + method: 'POST', + body: { + token: token, + }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + + obj = await Parse.Server.database.find( + '_User', + { objectId: user.id }, + {}, + Auth.maintenance(Parse.Server) + ); + + expect(obj._perishable_token).not.toBe(token); + }); + it('should throw on an invalid reset password', async () => { await reconfigureServer({ appName: 'coolapp', diff --git a/spec/helper.js b/spec/helper.js index 7093cfcc..bc20ebcf 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -227,65 +227,55 @@ beforeAll(async () => { Parse.serverURL = 'http://localhost:' + port + '/1'; }); -afterEach(function (done) { - const afterLogOut = async () => { - // Jasmine process uses one connection - if (Object.keys(openConnections).length > 1) { - console.warn(`There were ${Object.keys(openConnections).length} open connections to the server left after the test finished`); - } - await TestUtils.destroyAllDataPermanently(true); - SchemaCache.clear(); - if (didChangeConfiguration) { - await reconfigureServer(); - } else { - await databaseAdapter.performInitialization({ VolatileClassesSchemas }); - } - done(); - }; +global.afterEachFn = async () => { Parse.Cloud._removeAllHooks(); Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(); defaults.protectedFields = { _User: { '*': ['email'] } }; - databaseAdapter - .getAllClasses() - .then(allSchemas => { - allSchemas.forEach(schema => { - const className = schema.className; - expect(className).toEqual({ - asymmetricMatch: className => { - if (!className.startsWith('_')) { - return true; - } else { - // Other system classes will break Parse.com, so make sure that we don't save anything to _SCHEMA that will - // break it. - return ( - [ - '_User', - '_Installation', - '_Role', - '_Session', - '_Product', - '_Audience', - '_Idempotency', - ].indexOf(className) >= 0 - ); - } - }, - }); - }); - }) - .then(() => Parse.User.logOut()) - .then( - () => {}, - () => {} - ) // swallow errors - .then(() => { - // Connection close events are not immediate on node 10+... wait a bit - return new Promise(resolve => { - setTimeout(resolve, 0); - }); - }) - .then(afterLogOut); -}); + + const allSchemas = await databaseAdapter.getAllClasses().catch(() => []); + + allSchemas.forEach(schema => { + const className = schema.className; + expect(className).toEqual({ + asymmetricMatch: className => { + if (!className.startsWith('_')) { + return true; + } + return [ + '_User', + '_Installation', + '_Role', + '_Session', + '_Product', + '_Audience', + '_Idempotency', + ].includes(className); + }, + }); + }); + + await Parse.User.logOut().catch(() => {}); + + // Connection close events are not immediate on node 10+, so wait a bit + await new Promise(resolve => setTimeout(resolve, 0)); + + // After logout operations + if (Object.keys(openConnections).length > 1) { + console.warn( + `There were ${Object.keys(openConnections).length} open connections to the server left after the test finished` + ); + } + + await TestUtils.destroyAllDataPermanently(true); + SchemaCache.clear(); + + if (didChangeConfiguration) { + await reconfigureServer(); + } else { + await databaseAdapter.performInitialization({ VolatileClassesSchemas }); + } +} +afterEach(global.afterEachFn); afterAll(() => { global.displayTestStats(); diff --git a/spec/support/CurrentSpecReporter.js b/spec/support/CurrentSpecReporter.js index 35c0340d..8e0e0daf 100755 --- a/spec/support/CurrentSpecReporter.js +++ b/spec/support/CurrentSpecReporter.js @@ -108,6 +108,7 @@ global.retryFlakyTests = function() { } if (isFlaky) { retryMap[spec.result.fullName] = (retryMap[spec.result.fullName] || 0) + 1; + await global.afterEachFn(); } } if (exceptionCaught) { diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index ac896c51..455ec038 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -60,14 +60,14 @@ export class UserController extends AdaptableController { return true; } - async verifyEmail(username, token) { + async verifyEmail(token) { if (!this.shouldVerifyEmails) { // Trying to verify email when not enabled // TODO: Better error here. throw undefined; } - const query = { username: username, _email_verify_token: token }; + const query = { _email_verify_token: token }; const updateFields = { emailVerified: true, _email_verify_token: { __op: 'Delete' }, @@ -82,50 +82,45 @@ export class UserController extends AdaptableController { updateFields._email_verify_token_expires_at = { __op: 'Delete' }; } const maintenanceAuth = Auth.maintenance(this.config); - var findUserForEmailVerification = await RestQuery({ + const restQuery = await RestQuery({ method: RestQuery.Method.get, config: this.config, auth: maintenanceAuth, className: '_User', - restWhere: { - username, - }, - }); - return findUserForEmailVerification.execute().then(result => { - if (result.results.length && result.results[0].emailVerified) { - return Promise.resolve(result.results.length[0]); - } else if (result.results.length) { - query.objectId = result.results[0].objectId; - } - return rest.update(this.config, maintenanceAuth, '_User', query, updateFields); + restWhere: query, }); + + const result = await restQuery.execute(); + if (result.results.length) { + query.objectId = result.results[0].objectId; + } + return await rest.update(this.config, maintenanceAuth, '_User', query, updateFields); } - checkResetTokenValidity(username, token) { - return this.config.database - .find( - '_User', - { - username: username, - _perishable_token: token, - }, - { limit: 1 }, - Auth.maintenance(this.config) - ) - .then(results => { - if (results.length != 1) { - throw 'Failed to reset password: username / email / token is invalid'; - } + async checkResetTokenValidity(token) { + const results = await this.config.database.find( + '_User', + { + _perishable_token: token, + }, + { limit: 1 }, + Auth.maintenance(this.config) + ); + if (results.length !== 1) { + throw 'Failed to reset password: username / email / token is invalid'; + } - if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) { - let expiresDate = results[0]._perishable_token_expires_at; - if (expiresDate && expiresDate.__type == 'Date') { - expiresDate = new Date(expiresDate.iso); - } - if (expiresDate < new Date()) { throw 'The password reset link has expired'; } - } - return results[0]; - }); + if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) { + let expiresDate = results[0]._perishable_token_expires_at; + if (expiresDate && expiresDate.__type == 'Date') { + expiresDate = new Date(expiresDate.iso); + } + if (expiresDate < new Date()) { + throw 'The password reset link has expired'; + } + } + + return results[0]; } async getUserIfNeeded(user) { @@ -136,6 +131,9 @@ export class UserController extends AdaptableController { if (user.email) { where.email = user.email; } + if (user._email_verify_token) { + where._email_verify_token = user._email_verify_token; + } var query = await RestQuery({ method: RestQuery.Method.get, @@ -173,9 +171,7 @@ export class UserController extends AdaptableController { if (!shouldSendEmail) { return; } - const username = encodeURIComponent(fetchedUser.username); - - const link = buildEmailLink(this.config.verifyEmailURL, username, token, this.config); + const link = buildEmailLink(this.config.verifyEmailURL, token, this.config); const options = { appName: this.config.appName, link: link, @@ -221,8 +217,8 @@ export class UserController extends AdaptableController { return this.config.database.update('_User', { username: user.username }, user); } - async resendVerificationEmail(username, req) { - const aUser = await this.getUserIfNeeded({ username: username }); + async resendVerificationEmail(username, req, token) { + const aUser = await this.getUserIfNeeded({ username, _email_verify_token: token }); if (!aUser || aUser.emailVerified) { throw undefined; } @@ -286,9 +282,8 @@ export class UserController extends AdaptableController { user = await this.setPasswordResetToken(email); } const token = encodeURIComponent(user._perishable_token); - const username = encodeURIComponent(user.username); - const link = buildEmailLink(this.config.requestResetPasswordURL, username, token, this.config); + const link = buildEmailLink(this.config.requestResetPasswordURL, token, this.config); const options = { appName: this.config.appName, link: link, @@ -304,21 +299,20 @@ export class UserController extends AdaptableController { return Promise.resolve(user); } - updatePassword(username, token, password) { - return this.checkResetTokenValidity(username, token) - .then(user => updateUserPassword(user, password, this.config)) - .then(user => { - const accountLockoutPolicy = new AccountLockout(user, this.config); - return accountLockoutPolicy.unlockAccount(); - }) - .catch(error => { - if (error && error.message) { - // in case of Parse.Error, fail with the error message only - return Promise.reject(error.message); - } else { - return Promise.reject(error); - } - }); + async updatePassword(token, password) { + try { + const rawUser = await this.checkResetTokenValidity(token); + const user = await updateUserPassword(rawUser, password, this.config); + + const accountLockoutPolicy = new AccountLockout(user, this.config); + return await accountLockoutPolicy.unlockAccount(); + } catch (error) { + if (error && error.message) { + // in case of Parse.Error, fail with the error message only + return Promise.reject(error.message); + } + return Promise.reject(error); + } } defaultVerificationEmail({ link, user, appName }) { @@ -368,17 +362,14 @@ function updateUserPassword(user, password, config) { .then(() => user); } -function buildEmailLink(destination, username, token, config) { - const usernameAndToken = `token=${token}&username=${username}`; - +function buildEmailLink(destination, token, config) { + token = `token=${token}`; if (config.parseFrameURL) { const destinationWithoutHost = destination.replace(config.publicServerURL, ''); - return `${config.parseFrameURL}?link=${encodeURIComponent( - destinationWithoutHost - )}&${usernameAndToken}`; + return `${config.parseFrameURL}?link=${encodeURIComponent(destinationWithoutHost)}&${token}`; } else { - return `${destination}?${usernameAndToken}`; + return `${destination}?${token}`; } } diff --git a/src/GraphQL/loaders/usersMutations.js b/src/GraphQL/loaders/usersMutations.js index 018e46e6..2f59081a 100644 --- a/src/GraphQL/loaders/usersMutations.js +++ b/src/GraphQL/loaders/usersMutations.js @@ -302,11 +302,8 @@ const load = parseGraphQLSchema => { type: new GraphQLNonNull(GraphQLBoolean), }, }, - mutateAndGetPayload: async ({ username, password, token }, context) => { + mutateAndGetPayload: async ({ password, token }, context) => { const { config } = context; - if (!username) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'you must provide a username'); - } if (!password) { throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'you must provide a password'); } @@ -315,7 +312,7 @@ const load = parseGraphQLSchema => { } const userController = config.userController; - await userController.updatePassword(username, token, password); + await userController.updatePassword(token, password); return { ok: true }; }, }); diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js index 79a487b6..32dfd1ce 100644 --- a/src/Routers/PagesRouter.js +++ b/src/Routers/PagesRouter.js @@ -83,30 +83,24 @@ export class PagesRouter extends PromiseRouter { verifyEmail(req) { const config = req.config; - const { username, token: rawToken } = req.query; + const { token: rawToken } = req.query; const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; if (!config) { this.invalidRequest(); } - if (!token || !username) { + if (!token) { return this.goToPage(req, pages.emailVerificationLinkInvalid); } const userController = config.userController; - return userController.verifyEmail(username, token).then( + return userController.verifyEmail(token).then( () => { - const params = { - [pageParams.username]: username, - }; - return this.goToPage(req, pages.emailVerificationSuccess, params); + return this.goToPage(req, pages.emailVerificationSuccess); }, () => { - const params = { - [pageParams.username]: username, - }; - return this.goToPage(req, pages.emailVerificationLinkExpired, params); + return this.goToPage(req, pages.emailVerificationLinkInvalid); } ); } @@ -114,18 +108,19 @@ export class PagesRouter extends PromiseRouter { resendVerificationEmail(req) { const config = req.config; const username = req.body.username; + const token = req.body.token; if (!config) { this.invalidRequest(); } - if (!username) { + if (!username && !token) { return this.goToPage(req, pages.emailVerificationLinkInvalid); } const userController = config.userController; - return userController.resendVerificationEmail(username, req).then( + return userController.resendVerificationEmail(username, req, token).then( () => { return this.goToPage(req, pages.emailVerificationSendSuccess); }, @@ -154,28 +149,24 @@ export class PagesRouter extends PromiseRouter { this.invalidRequest(); } - const { username, token: rawToken } = req.query; + const { token: rawToken } = req.query; const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; - if (!username || !token) { + if (!token) { return this.goToPage(req, pages.passwordResetLinkInvalid); } - return config.userController.checkResetTokenValidity(username, token).then( + return config.userController.checkResetTokenValidity(token).then( () => { const params = { [pageParams.token]: token, - [pageParams.username]: username, [pageParams.appId]: config.applicationId, [pageParams.appName]: config.appName, }; return this.goToPage(req, pages.passwordReset, params); }, () => { - const params = { - [pageParams.username]: username, - }; - return this.goToPage(req, pages.passwordResetLinkInvalid, params); + return this.goToPage(req, pages.passwordResetLinkInvalid); } ); } @@ -187,17 +178,13 @@ export class PagesRouter extends PromiseRouter { this.invalidRequest(); } - const { username, new_password, token: rawToken } = req.body; + const { new_password, token: rawToken } = req.body; const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; - if ((!username || !token || !new_password) && req.xhr === false) { + if ((!token || !new_password) && req.xhr === false) { return this.goToPage(req, pages.passwordResetLinkInvalid); } - if (!username) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'Missing username'); - } - if (!token) { throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing token'); } @@ -207,7 +194,7 @@ export class PagesRouter extends PromiseRouter { } return config.userController - .updatePassword(username, token, new_password) + .updatePassword(token, new_password) .then( () => { return Promise.resolve({ @@ -235,16 +222,18 @@ export class PagesRouter extends PromiseRouter { } const query = result.success - ? { - [pageParams.username]: username, - } + ? {} : { - [pageParams.username]: username, [pageParams.token]: token, [pageParams.appId]: config.applicationId, [pageParams.error]: result.err, [pageParams.appName]: config.appName, }; + + if (result?.err === 'The password reset link has expired') { + delete query[pageParams.token]; + query[pageParams.token] = token; + } const page = result.success ? pages.passwordResetSuccess : pages.passwordReset; return this.goToPage(req, page, query, false); diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js index 625b3d45..1a09db85 100644 --- a/src/Routers/PublicAPIRouter.js +++ b/src/Routers/PublicAPIRouter.js @@ -19,7 +19,7 @@ export class PublicAPIRouter extends PromiseRouter { }); } verifyEmail(req) { - const { username, token: rawToken } = req.query; + const { token: rawToken } = req.query; const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; const appId = req.params.appId; @@ -33,21 +33,20 @@ export class PublicAPIRouter extends PromiseRouter { return this.missingPublicServerURL(); } - if (!token || !username) { + if (!token) { return this.invalidLink(req); } const userController = config.userController; - return userController.verifyEmail(username, token).then( + return userController.verifyEmail(token).then( () => { - const params = qs.stringify({ username }); return Promise.resolve({ status: 302, - location: `${config.verifyEmailSuccessURL}?${params}`, + location: `${config.verifyEmailSuccessURL}`, }); }, () => { - return this.invalidVerificationLink(req); + return this.invalidVerificationLink(req, token); } ); } @@ -65,13 +64,15 @@ export class PublicAPIRouter extends PromiseRouter { return this.missingPublicServerURL(); } - if (!username) { + const token = req.body.token; + + if (!username && !token) { return this.invalidLink(req); } const userController = config.userController; - return userController.resendVerificationEmail(username, req).then( + return userController.resendVerificationEmail(username, req, token).then( () => { return Promise.resolve({ status: 302, @@ -125,19 +126,18 @@ export class PublicAPIRouter extends PromiseRouter { return this.missingPublicServerURL(); } - const { username, token: rawToken } = req.query; + const { token: rawToken } = req.query; const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; - if (!username || !token) { + if (!token) { return this.invalidLink(req); } - return config.userController.checkResetTokenValidity(username, token).then( + return config.userController.checkResetTokenValidity(token).then( () => { const params = qs.stringify({ token, id: config.applicationId, - username, app: config.appName, }); return Promise.resolve({ @@ -162,17 +162,13 @@ export class PublicAPIRouter extends PromiseRouter { return this.missingPublicServerURL(); } - const { username, new_password, token: rawToken } = req.body; + const { new_password, token: rawToken } = req.body; const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; - if ((!username || !token || !new_password) && req.xhr === false) { + if ((!token || !new_password) && req.xhr === false) { return this.invalidLink(req); } - if (!username) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'Missing username'); - } - if (!token) { throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing token'); } @@ -182,7 +178,7 @@ export class PublicAPIRouter extends PromiseRouter { } return config.userController - .updatePassword(username, token, new_password) + .updatePassword(token, new_password) .then( () => { return Promise.resolve({ @@ -197,13 +193,18 @@ export class PublicAPIRouter extends PromiseRouter { } ) .then(result => { - const params = qs.stringify({ - username: username, + const queryString = { token: token, id: config.applicationId, error: result.err, app: config.appName, - }); + }; + + if (result?.err === 'The password reset link has expired') { + delete queryString.token; + queryString.token = token; + } + const params = qs.stringify(queryString); if (req.xhr) { if (result.success) { @@ -217,9 +218,8 @@ export class PublicAPIRouter extends PromiseRouter { } } - const encodedUsername = encodeURIComponent(username); const location = result.success - ? `${config.passwordResetSuccessURL}?username=${encodedUsername}` + ? `${config.passwordResetSuccessURL}` : `${config.choosePasswordURL}?${params}`; return Promise.resolve({ @@ -236,12 +236,12 @@ export class PublicAPIRouter extends PromiseRouter { }); } - invalidVerificationLink(req) { + invalidVerificationLink(req, token) { const config = req.config; - if (req.query.username && req.params.appId) { + if (req.params.appId) { const params = qs.stringify({ - username: req.query.username, appId: req.params.appId, + token, }); return Promise.resolve({ status: 302, diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 70085f98..c3e86a8e 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -438,10 +438,20 @@ export class UsersRouter extends ClassesRouter { async handleResetRequest(req) { this._throwOnBadEmailConfig(req); - const { email } = req.body; - if (!email) { + let email = req.body.email; + const token = req.body.token; + if (!email && !token) { throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email'); } + if (token) { + const results = await req.config.database.find('_User', { + _perishable_token: token, + _perishable_token_expires_at: { $lt: Parse._encode(new Date()) }, + }); + if (results && results[0] && results[0].email) { + email = results[0].email; + } + } if (typeof email !== 'string') { throw new Parse.Error( Parse.Error.INVALID_EMAIL_ADDRESS,