Skip to content

Commit d21dd97

Browse files
authored
fix: Remove username from email verification and password reset process (parse-community#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.
1 parent 6a6bc2a commit d21dd97

21 files changed

+401
-308
lines changed

8.0.0.md

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Parse Server 8 Migration Guide <!-- omit in toc -->
2+
3+
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).
4+
5+
---
6+
7+
- [Email Verification](#email-verification)
8+
9+
---
10+
11+
## Email Verification
12+
13+
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.
14+
15+
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.
16+
17+
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.
18+
19+
> [!WARNING]
20+
> 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.
21+
22+
> [!IMPORTANT]
23+
> 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.
24+
25+
Related pull requests:
26+
27+
- https://github.com/parse-community/parse-server/pull/8488

public/de-AT/email_verification_link_expired.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<h1>{{appName}}</h1>
1616
<h1>Expired verification link!</h1>
1717
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
18-
<input name="username" type="hidden" value="{{{username}}}">
18+
<input name="token" type="hidden" value="{{{token}}}">
1919
<input name="locale" type="hidden" value="{{{locale}}}">
2020
<button type="submit">Resend Link</button>
2121
</form>

public/de/email_verification_link_expired.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<h1>{{appName}}</h1>
1616
<h1>Expired verification link!</h1>
1717
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
18-
<input name="username" type="hidden" value="{{{username}}}">
18+
<input name="token" type="hidden" value="{{{token}}}">
1919
<input name="locale" type="hidden" value="{{{locale}}}">
2020
<button type="submit">Resend Link</button>
2121
</form>

public/email_verification_link_expired.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<h1>{{appName}}</h1>
1616
<h1>Expired verification link!</h1>
1717
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
18-
<input name="username" type="hidden" value="{{{username}}}">
18+
<input name="token" type="hidden" value="{{{token}}}">
1919
<input name="locale" type="hidden" value="{{{locale}}}">
2020
<button type="submit">Resend Link</button>
2121
</form>

public_html/invalid_verification_link.html

+3-3
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@
4747
window.onload = addDataToForm;
4848

4949
function addDataToForm() {
50-
var username = getUrlParameter("username");
51-
document.getElementById("usernameField").value = username;
50+
const token = getUrlParameter("token");
51+
document.getElementById("token").value = token;
5252

5353
var appId = getUrlParameter("appId");
5454
document.getElementById("resendForm").action = '/apps/' + appId + '/resend_verification_email'
@@ -60,7 +60,7 @@
6060
<div class="container">
6161
<h1>Invalid Verification Link</h1>
6262
<form id="resendForm" method="POST" action="/resend_verification_email">
63-
<input id="usernameField" class="form-control" name="username" type="hidden" value="">
63+
<input id="token" class="form-control" name="token" type="hidden" value="">
6464
<button type="submit" class="btn btn-default">Resend Link</button>
6565
</form>
6666
</div>

spec/AccountLockoutPolicy.spec.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ describe('lockout with password reset option', () => {
419419
await request({
420420
method: 'POST',
421421
url: `${config.publicServerURL}/apps/test/request_password_reset`,
422-
body: `new_password=${newPassword}&token=${token}&username=${username}`,
422+
body: `new_password=${newPassword}&token=${token}`,
423423
headers: {
424424
'Content-Type': 'application/x-www-form-urlencoded',
425425
},
@@ -454,7 +454,7 @@ describe('lockout with password reset option', () => {
454454
await request({
455455
method: 'POST',
456456
url: `${config.publicServerURL}/apps/test/request_password_reset`,
457-
body: `new_password=${newPassword}&token=${token}&username=${username}`,
457+
body: `new_password=${newPassword}&token=${token}`,
458458
headers: {
459459
'Content-Type': 'application/x-www-form-urlencoded',
460460
},

spec/EmailVerificationToken.spec.js

+68-4
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ describe('Email Verification Token Expiration: ', () => {
3939
followRedirects: false,
4040
}).then(response => {
4141
expect(response.status).toEqual(302);
42+
const url = new URL(sendEmailOptions.link);
43+
const token = url.searchParams.get('token');
4244
expect(response.text).toEqual(
43-
'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=testEmailVerifyTokenValidity&appId=test'
45+
`Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}`
4446
);
4547
done();
4648
});
@@ -135,7 +137,7 @@ describe('Email Verification Token Expiration: ', () => {
135137
}).then(response => {
136138
expect(response.status).toEqual(302);
137139
expect(response.text).toEqual(
138-
'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=testEmailVerifyTokenValidity'
140+
'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html'
139141
);
140142
done();
141143
});
@@ -292,6 +294,64 @@ describe('Email Verification Token Expiration: ', () => {
292294
});
293295
});
294296

297+
it('can resend email using an expired token', async () => {
298+
const user = new Parse.User();
299+
const emailAdapter = {
300+
sendVerificationEmail: () => {},
301+
sendPasswordResetEmail: () => Promise.resolve(),
302+
sendMail: () => {},
303+
};
304+
await reconfigureServer({
305+
appName: 'emailVerifyToken',
306+
verifyUserEmails: true,
307+
emailAdapter: emailAdapter,
308+
emailVerifyTokenValidityDuration: 5, // 5 seconds
309+
publicServerURL: 'http://localhost:8378/1',
310+
});
311+
user.setUsername('test');
312+
user.setPassword('password');
313+
user.set('email', '[email protected]');
314+
await user.signUp();
315+
316+
await Parse.Server.database.update(
317+
'_User',
318+
{ objectId: user.id },
319+
{
320+
_email_verify_token_expires_at: Parse._encode(new Date('2000')),
321+
}
322+
);
323+
324+
const obj = await Parse.Server.database.find(
325+
'_User',
326+
{ objectId: user.id },
327+
{},
328+
Auth.maintenance(Parse.Server)
329+
);
330+
const token = obj[0]._email_verify_token;
331+
332+
const res = await request({
333+
url: `http://localhost:8378/1/apps/test/verify_email?token=${token}`,
334+
method: 'GET',
335+
});
336+
expect(res.text).toEqual(
337+
`Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}`
338+
);
339+
340+
const formUrl = `http://localhost:8378/1/apps/test/resend_verification_email`;
341+
const formResponse = await request({
342+
url: formUrl,
343+
method: 'POST',
344+
body: {
345+
token: token,
346+
},
347+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
348+
followRedirects: false,
349+
});
350+
expect(formResponse.text).toEqual(
351+
`Found. Redirecting to http://localhost:8378/1/apps/link_send_success.html`
352+
);
353+
});
354+
295355
it_id('9365c53c-b8b4-41f7-a3c1-77882f76a89c')(it)('can conditionally send emails', async () => {
296356
let sendEmailOptions;
297357
const emailAdapter = {
@@ -614,8 +674,10 @@ describe('Email Verification Token Expiration: ', () => {
614674
followRedirects: false,
615675
}).then(response => {
616676
expect(response.status).toEqual(302);
677+
const url = new URL(sendEmailOptions.link);
678+
const token = url.searchParams.get('token');
617679
expect(response.text).toEqual(
618-
'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=testEmailVerifyTokenValidity'
680+
`Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}`
619681
);
620682
done();
621683
});
@@ -667,8 +729,10 @@ describe('Email Verification Token Expiration: ', () => {
667729
followRedirects: false,
668730
}).then(response => {
669731
expect(response.status).toEqual(302);
732+
const url = new URL(sendEmailOptions.link);
733+
const token = url.searchParams.get('token');
670734
expect(response.text).toEqual(
671-
'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=testEmailVerifyTokenValidity&appId=test'
735+
`Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}`
672736
);
673737
done();
674738
});

spec/PagesRouter.spec.js

+10-38
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ describe('Pages Router', () => {
108108
const res = await request({
109109
method: 'POST',
110110
url: 'http://localhost:8378/1/apps/test/request_password_reset',
111-
body: `new_password=user1&token=43634643&username=username`,
111+
body: `new_password=user1&token=43634643`,
112112
headers: {
113113
'Content-Type': 'application/x-www-form-urlencoded',
114114
'X-Requested-With': 'XMLHttpRequest',
@@ -124,7 +124,7 @@ describe('Pages Router', () => {
124124
await request({
125125
method: 'POST',
126126
url: 'http://localhost:8378/1/apps/test/request_password_reset',
127-
body: `new_password=&token=132414&username=Johnny`,
127+
body: `new_password=&token=132414`,
128128
headers: {
129129
'Content-Type': 'application/x-www-form-urlencoded',
130130
'X-Requested-With': 'XMLHttpRequest',
@@ -137,30 +137,12 @@ describe('Pages Router', () => {
137137
}
138138
});
139139

140-
it('request_password_reset: responds with AJAX error on missing username', async () => {
141-
try {
142-
await request({
143-
method: 'POST',
144-
url: 'http://localhost:8378/1/apps/test/request_password_reset',
145-
body: `new_password=user1&token=43634643&username=`,
146-
headers: {
147-
'Content-Type': 'application/x-www-form-urlencoded',
148-
'X-Requested-With': 'XMLHttpRequest',
149-
},
150-
followRedirects: false,
151-
});
152-
} catch (error) {
153-
expect(error.status).not.toBe(302);
154-
expect(error.text).toEqual('{"code":200,"error":"Missing username"}');
155-
}
156-
});
157-
158140
it('request_password_reset: responds with AJAX error on missing token', async () => {
159141
try {
160142
await request({
161143
method: 'POST',
162144
url: 'http://localhost:8378/1/apps/test/request_password_reset',
163-
body: `new_password=user1&token=&username=Johnny`,
145+
body: `new_password=user1&token=`,
164146
headers: {
165147
'Content-Type': 'application/x-www-form-urlencoded',
166148
'X-Requested-With': 'XMLHttpRequest',
@@ -577,7 +559,7 @@ describe('Pages Router', () => {
577559
spyOnProperty(Page.prototype, 'defaultFile').and.returnValue(jsonPageFile);
578560

579561
const response = await request({
580-
url: `http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&username=exampleUsername&locale=${exampleLocale}`,
562+
url: `http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=${exampleLocale}`,
581563
followRedirects: false,
582564
}).catch(e => e);
583565
expect(response.status).toEqual(200);
@@ -626,7 +608,7 @@ describe('Pages Router', () => {
626608
await reconfigureServer(config);
627609
const response = await request({
628610
url:
629-
'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&username=exampleUsername&locale=de-AT',
611+
'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=de-AT',
630612
followRedirects: false,
631613
method: 'POST',
632614
});
@@ -640,7 +622,7 @@ describe('Pages Router', () => {
640622
await reconfigureServer(config);
641623
const response = await request({
642624
url:
643-
'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&username=exampleUsername&locale=de-AT',
625+
'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=de-AT',
644626
followRedirects: false,
645627
method: 'GET',
646628
});
@@ -676,13 +658,11 @@ describe('Pages Router', () => {
676658
const appId = linkResponse.headers['x-parse-page-param-appid'];
677659
const token = linkResponse.headers['x-parse-page-param-token'];
678660
const locale = linkResponse.headers['x-parse-page-param-locale'];
679-
const username = linkResponse.headers['x-parse-page-param-username'];
680661
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
681662
const passwordResetPagePath = pageResponse.calls.all()[0].args[0];
682663
expect(appId).toBeDefined();
683664
expect(token).toBeDefined();
684665
expect(locale).toBeDefined();
685-
expect(username).toBeDefined();
686666
expect(publicServerUrl).toBeDefined();
687667
expect(passwordResetPagePath).toMatch(
688668
new RegExp(`\/${exampleLocale}\/${pages.passwordReset.defaultFile}`)
@@ -696,7 +676,6 @@ describe('Pages Router', () => {
696676
body: {
697677
token,
698678
locale,
699-
username,
700679
new_password: 'newPassword',
701680
},
702681
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -793,15 +772,13 @@ describe('Pages Router', () => {
793772

794773
const appId = linkResponse.headers['x-parse-page-param-appid'];
795774
const locale = linkResponse.headers['x-parse-page-param-locale'];
796-
const username = linkResponse.headers['x-parse-page-param-username'];
797775
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
798776
const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0];
799777
expect(appId).toBeDefined();
800778
expect(locale).toBe(exampleLocale);
801-
expect(username).toBeDefined();
802779
expect(publicServerUrl).toBeDefined();
803780
expect(invalidVerificationPagePath).toMatch(
804-
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkExpired.defaultFile}`)
781+
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`)
805782
);
806783

807784
const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`;
@@ -810,7 +787,7 @@ describe('Pages Router', () => {
810787
method: 'POST',
811788
body: {
812789
locale,
813-
username,
790+
username: 'exampleUsername',
814791
},
815792
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
816793
followRedirects: false,
@@ -847,17 +824,15 @@ describe('Pages Router', () => {
847824

848825
const appId = linkResponse.headers['x-parse-page-param-appid'];
849826
const locale = linkResponse.headers['x-parse-page-param-locale'];
850-
const username = linkResponse.headers['x-parse-page-param-username'];
851827
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
852828
await jasmine.timeout();
853829

854830
const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0];
855831
expect(appId).toBeDefined();
856832
expect(locale).toBe(exampleLocale);
857-
expect(username).toBeDefined();
858833
expect(publicServerUrl).toBeDefined();
859834
expect(invalidVerificationPagePath).toMatch(
860-
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkExpired.defaultFile}`)
835+
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`)
861836
);
862837

863838
spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() =>
@@ -870,7 +845,7 @@ describe('Pages Router', () => {
870845
method: 'POST',
871846
body: {
872847
locale,
873-
username,
848+
username: 'exampleUsername',
874849
},
875850
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
876851
followRedirects: false,
@@ -1155,12 +1130,10 @@ describe('Pages Router', () => {
11551130

11561131
const appId = linkResponse.headers['x-parse-page-param-appid'];
11571132
const token = linkResponse.headers['x-parse-page-param-token'];
1158-
const username = linkResponse.headers['x-parse-page-param-username'];
11591133
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
11601134
const passwordResetPagePath = pageResponse.calls.all()[0].args[0];
11611135
expect(appId).toBeDefined();
11621136
expect(token).toBeDefined();
1163-
expect(username).toBeDefined();
11641137
expect(publicServerUrl).toBeDefined();
11651138
expect(passwordResetPagePath).toMatch(new RegExp(`\/${pages.passwordReset.defaultFile}`));
11661139
pageResponse.calls.reset();
@@ -1171,7 +1144,6 @@ describe('Pages Router', () => {
11711144
method: 'POST',
11721145
body: {
11731146
token,
1174-
username,
11751147
new_password: 'newPassword',
11761148
},
11771149
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },

spec/ParseLiveQuery.spec.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,7 @@ describe('ParseLiveQuery', function () {
969969
const userController = new UserController(emailAdapter, 'test', {
970970
verifyUserEmails: true,
971971
});
972-
userController.verifyEmail(foundUser.username, foundUser._email_verify_token);
972+
userController.verifyEmail(foundUser._email_verify_token);
973973
});
974974
});
975975
});

0 commit comments

Comments
 (0)