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

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,