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.
This commit is contained in:
Daniel
2025-03-02 12:32:43 +11:00
committed by GitHub
parent 6a6bc2a8cc
commit d21dd97336
21 changed files with 401 additions and 308 deletions

27
8.0.0.md Normal file
View File

@@ -0,0 +1,27 @@
# Parse Server 8 Migration Guide <!-- omit in toc -->
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

View File

@@ -15,7 +15,7 @@
<h1>{{appName}}</h1>
<h1>Expired verification link!</h1>
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
<input name="username" type="hidden" value="{{{username}}}">
<input name="token" type="hidden" value="{{{token}}}">
<input name="locale" type="hidden" value="{{{locale}}}">
<button type="submit">Resend Link</button>
</form>

View File

@@ -15,7 +15,7 @@
<h1>{{appName}}</h1>
<h1>Expired verification link!</h1>
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
<input name="username" type="hidden" value="{{{username}}}">
<input name="token" type="hidden" value="{{{token}}}">
<input name="locale" type="hidden" value="{{{locale}}}">
<button type="submit">Resend Link</button>
</form>

View File

@@ -15,7 +15,7 @@
<h1>{{appName}}</h1>
<h1>Expired verification link!</h1>
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
<input name="username" type="hidden" value="{{{username}}}">
<input name="token" type="hidden" value="{{{token}}}">
<input name="locale" type="hidden" value="{{{locale}}}">
<button type="submit">Resend Link</button>
</form>

View File

@@ -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 @@
<div class="container">
<h1>Invalid Verification Link</h1>
<form id="resendForm" method="POST" action="/resend_verification_email">
<input id="usernameField" class="form-control" name="username" type="hidden" value="">
<input id="token" class="form-control" name="token" type="hidden" value="">
<button type="submit" class="btn btn-default">Resend Link</button>
</form>
</div>

View File

@@ -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',
},

View File

@@ -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();
});

View File

@@ -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' },

View File

@@ -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);
});
});
});

View File

@@ -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();

View File

@@ -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',

View File

@@ -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);

View File

@@ -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}`);
});
});
});

View File

@@ -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',

View File

@@ -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();

View File

@@ -108,6 +108,7 @@ global.retryFlakyTests = function() {
}
if (isFlaky) {
retryMap[spec.result.fullName] = (retryMap[spec.result.fullName] || 0) + 1;
await global.afterEachFn();
}
}
if (exceptionCaught) {

View File

@@ -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}`;
}
}

View File

@@ -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 };
},
});

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,