Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Remove username from email verification and password reset process #8488

Merged
merged 35 commits into from
Mar 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
78c67d4
fix: remove username from verification emails
dblythy Mar 30, 2023
407825d
tests
dblythy Mar 30, 2023
0c49a4a
feat: allow Pointers in cloud code params
dblythy Mar 30, 2023
3d50b00
Revert "feat: allow Pointers in cloud code params"
dblythy Mar 30, 2023
713e357
Merge branch 'alpha' into password-reset
dblythy Jul 24, 2023
f814457
wip
dblythy Jul 24, 2023
b960a1a
Update ValidationAndPasswordsReset.spec.js
dblythy Jul 25, 2023
9f3808a
Merge remote-tracking branch 'upstream/alpha' into password-reset
dblythy Jan 28, 2025
9d4a028
Update UserController.spec.js
dblythy Jan 28, 2025
7252893
fix failing tests
dblythy Jan 28, 2025
f9b54dd
Update UserController.js
dblythy Jan 28, 2025
4a72e2e
fix tests
dblythy Jan 28, 2025
e110732
Update ValidationAndPasswordsReset.spec.js
dblythy Jan 28, 2025
5beb30f
Update ValidationAndPasswordsReset.spec.js
dblythy Jan 29, 2025
205d59e
Update UserController.spec.js
dblythy Jan 29, 2025
db5409a
fix failing tests
dblythy Jan 29, 2025
170f83a
add logging
dblythy Jan 29, 2025
6ff9e6b
Update CurrentSpecReporter.js
dblythy Jan 29, 2025
e1ed76b
revert resolve
dblythy Jan 29, 2025
0d8a4a2
add catch
dblythy Jan 29, 2025
493fcf2
Merge branch 'alpha' into password-reset
dblythy Jan 29, 2025
0cb359a
Merge branch 'alpha' into password-reset
mtrezza Jan 30, 2025
3295166
Create 8.0.0.md
dblythy Jan 30, 2025
4808c8a
fix pages router
dblythy Feb 3, 2025
c09bc87
Merge branch 'alpha' into password-reset
mtrezza Feb 3, 2025
d60bbd2
review feedback
dblythy Feb 12, 2025
e6b67ed
Update 8.0.0.md
mtrezza Feb 12, 2025
780bc48
Update 8.0.0.md
mtrezza Feb 12, 2025
42a2ff5
Update 8.0.0.md
mtrezza Feb 12, 2025
a32d0e6
Update 8.0.0.md
mtrezza Feb 12, 2025
ae6ac40
Merge branch 'alpha' into password-reset
mtrezza Feb 12, 2025
e75113c
Update 8.0.0.md
mtrezza Feb 12, 2025
02069d9
Update 8.0.0.md
dblythy Feb 23, 2025
280984f
Update 8.0.0.md
mtrezza Mar 2, 2025
e0e094c
Merge branch 'alpha' into password-reset
mtrezza Mar 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions 8.0.0.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion public/de-AT/email_verification_link_expired.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion public/de/email_verification_link_expired.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion public/email_verification_link_expired.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
6 changes: 3 additions & 3 deletions public_html/invalid_verification_link.html
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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>
Expand Down
4 changes: 2 additions & 2 deletions spec/AccountLockoutPolicy.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down Expand Up @@ -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',
},
Expand Down
72 changes: 68 additions & 4 deletions spec/EmailVerificationToken.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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', '[email protected]');
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 = {
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
Expand Down
48 changes: 10 additions & 38 deletions spec/PagesRouter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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',
});
Expand All @@ -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',
});
Expand Down Expand Up @@ -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}`)
Expand All @@ -696,7 +676,6 @@ describe('Pages Router', () => {
body: {
token,
locale,
username,
new_password: 'newPassword',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
Expand Down Expand Up @@ -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`;
Expand All @@ -810,7 +787,7 @@ describe('Pages Router', () => {
method: 'POST',
body: {
locale,
username,
username: 'exampleUsername',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
followRedirects: false,
Expand Down Expand Up @@ -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(() =>
Expand All @@ -870,7 +845,7 @@ describe('Pages Router', () => {
method: 'POST',
body: {
locale,
username,
username: 'exampleUsername',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
followRedirects: false,
Expand Down Expand Up @@ -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();
Expand All @@ -1171,7 +1144,6 @@ describe('Pages Router', () => {
method: 'POST',
body: {
token,
username,
new_password: 'newPassword',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
Expand Down
2 changes: 1 addition & 1 deletion spec/ParseLiveQuery.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Expand Down
Loading
Loading