BREAKING CHANGE: The `Parse.User` passed as argument if `verifyUserEmails` is set to a function is renamed from `user` to `object` for consistency with invocations of `verifyUserEmails` on signup or login; the user object is not a plain JavaScript object anymore but an instance of `Parse.User`
389 lines
11 KiB
JavaScript
389 lines
11 KiB
JavaScript
import { randomString } from '../cryptoUtils';
|
|
import { inflate } from '../triggers';
|
|
import AdaptableController from './AdaptableController';
|
|
import MailAdapter from '../Adapters/Email/MailAdapter';
|
|
import rest from '../rest';
|
|
import Parse from 'parse/node';
|
|
import AccountLockout from '../AccountLockout';
|
|
import Config from '../Config';
|
|
|
|
var RestQuery = require('../RestQuery');
|
|
var Auth = require('../Auth');
|
|
|
|
export class UserController extends AdaptableController {
|
|
constructor(adapter, appId, options = {}) {
|
|
super(adapter, appId, options);
|
|
}
|
|
|
|
get config() {
|
|
return Config.get(this.appId);
|
|
}
|
|
|
|
validateAdapter(adapter) {
|
|
// Allow no adapter
|
|
if (!adapter && !this.shouldVerifyEmails) {
|
|
return;
|
|
}
|
|
super.validateAdapter(adapter);
|
|
}
|
|
|
|
expectedAdapterType() {
|
|
return MailAdapter;
|
|
}
|
|
|
|
get shouldVerifyEmails() {
|
|
return (this.config || this.options).verifyUserEmails;
|
|
}
|
|
|
|
async setEmailVerifyToken(user, req, storage = {}) {
|
|
const shouldSendEmail =
|
|
this.shouldVerifyEmails === true ||
|
|
(typeof this.shouldVerifyEmails === 'function' &&
|
|
(await Promise.resolve(this.shouldVerifyEmails(req))) === true);
|
|
if (!shouldSendEmail) {
|
|
return false;
|
|
}
|
|
storage.sendVerificationEmail = true;
|
|
user._email_verify_token = randomString(25);
|
|
if (
|
|
!storage.fieldsChangedByTrigger ||
|
|
!storage.fieldsChangedByTrigger.includes('emailVerified')
|
|
) {
|
|
user.emailVerified = false;
|
|
}
|
|
|
|
if (this.config.emailVerifyTokenValidityDuration) {
|
|
user._email_verify_token_expires_at = Parse._encode(
|
|
this.config.generateEmailVerifyTokenExpiresAt()
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async verifyEmail(username, 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 updateFields = {
|
|
emailVerified: true,
|
|
_email_verify_token: { __op: 'Delete' },
|
|
};
|
|
|
|
// if the email verify token needs to be validated then
|
|
// add additional query params and additional fields that need to be updated
|
|
if (this.config.emailVerifyTokenValidityDuration) {
|
|
query.emailVerified = false;
|
|
query._email_verify_token_expires_at = { $gt: Parse._encode(new Date()) };
|
|
|
|
updateFields._email_verify_token_expires_at = { __op: 'Delete' };
|
|
}
|
|
const maintenanceAuth = Auth.maintenance(this.config);
|
|
var findUserForEmailVerification = 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);
|
|
});
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
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) {
|
|
if (user.username && user.email) {
|
|
return Promise.resolve(user);
|
|
}
|
|
var where = {};
|
|
if (user.username) {
|
|
where.username = user.username;
|
|
}
|
|
if (user.email) {
|
|
where.email = user.email;
|
|
}
|
|
|
|
var query = await RestQuery({
|
|
method: RestQuery.Method.get,
|
|
config: this.config,
|
|
runBeforeFind: false,
|
|
auth: Auth.master(this.config),
|
|
className: '_User',
|
|
restWhere: where,
|
|
});
|
|
return query.execute().then(function (result) {
|
|
if (result.results.length != 1) {
|
|
throw undefined;
|
|
}
|
|
return result.results[0];
|
|
});
|
|
}
|
|
|
|
async sendVerificationEmail(user, req) {
|
|
if (!this.shouldVerifyEmails) {
|
|
return;
|
|
}
|
|
const token = encodeURIComponent(user._email_verify_token);
|
|
// We may need to fetch the user in case of update email
|
|
const fetchedUser = await this.getUserIfNeeded(user);
|
|
let shouldSendEmail = this.config.sendUserEmailVerification;
|
|
if (typeof shouldSendEmail === 'function') {
|
|
const response = await Promise.resolve(
|
|
this.config.sendUserEmailVerification({
|
|
user: Parse.Object.fromJSON({ className: '_User', ...fetchedUser }),
|
|
master: req.auth?.isMaster,
|
|
})
|
|
);
|
|
shouldSendEmail = !!response;
|
|
}
|
|
if (!shouldSendEmail) {
|
|
return;
|
|
}
|
|
const username = encodeURIComponent(user.username);
|
|
|
|
const link = buildEmailLink(this.config.verifyEmailURL, username, token, this.config);
|
|
const options = {
|
|
appName: this.config.appName,
|
|
link: link,
|
|
user: inflate('_User', fetchedUser),
|
|
};
|
|
if (this.adapter.sendVerificationEmail) {
|
|
this.adapter.sendVerificationEmail(options);
|
|
} else {
|
|
this.adapter.sendMail(this.defaultVerificationEmail(options));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Regenerates the given user's email verification token
|
|
*
|
|
* @param user
|
|
* @returns {*}
|
|
*/
|
|
async regenerateEmailVerifyToken(user, master, installationId, ip) {
|
|
const { _email_verify_token } = user;
|
|
let { _email_verify_token_expires_at } = user;
|
|
if (_email_verify_token_expires_at && _email_verify_token_expires_at.__type === 'Date') {
|
|
_email_verify_token_expires_at = _email_verify_token_expires_at.iso;
|
|
}
|
|
if (
|
|
this.config.emailVerifyTokenReuseIfValid &&
|
|
this.config.emailVerifyTokenValidityDuration &&
|
|
_email_verify_token &&
|
|
new Date() < new Date(_email_verify_token_expires_at)
|
|
) {
|
|
return Promise.resolve();
|
|
}
|
|
const shouldSend = await this.setEmailVerifyToken(user, {
|
|
object: Parse.User.fromJSON(Object.assign({ className: '_User' }, user)),
|
|
master,
|
|
installationId,
|
|
ip,
|
|
resendRequest: true
|
|
});
|
|
if (!shouldSend) {
|
|
return;
|
|
}
|
|
return this.config.database.update('_User', { username: user.username }, user);
|
|
}
|
|
|
|
async resendVerificationEmail(username, req) {
|
|
const aUser = await this.getUserIfNeeded({ username: username });
|
|
if (!aUser || aUser.emailVerified) {
|
|
throw undefined;
|
|
}
|
|
const generate = await this.regenerateEmailVerifyToken(aUser, req.auth?.isMaster, req.auth?.installationId, req.ip);
|
|
if (generate) {
|
|
this.sendVerificationEmail(aUser, req);
|
|
}
|
|
}
|
|
|
|
setPasswordResetToken(email) {
|
|
const token = { _perishable_token: randomString(25) };
|
|
|
|
if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) {
|
|
token._perishable_token_expires_at = Parse._encode(
|
|
this.config.generatePasswordResetTokenExpiresAt()
|
|
);
|
|
}
|
|
|
|
return this.config.database.update(
|
|
'_User',
|
|
{ $or: [{ email }, { username: email, email: { $exists: false } }] },
|
|
token,
|
|
{},
|
|
true
|
|
);
|
|
}
|
|
|
|
async sendPasswordResetEmail(email) {
|
|
if (!this.adapter) {
|
|
throw 'Trying to send a reset password but no adapter is set';
|
|
// TODO: No adapter?
|
|
}
|
|
let user;
|
|
if (
|
|
this.config.passwordPolicy &&
|
|
this.config.passwordPolicy.resetTokenReuseIfValid &&
|
|
this.config.passwordPolicy.resetTokenValidityDuration
|
|
) {
|
|
const results = await this.config.database.find(
|
|
'_User',
|
|
{
|
|
$or: [
|
|
{ email, _perishable_token: { $exists: true } },
|
|
{ username: email, email: { $exists: false }, _perishable_token: { $exists: true } },
|
|
],
|
|
},
|
|
{ limit: 1 },
|
|
Auth.maintenance(this.config)
|
|
);
|
|
if (results.length == 1) {
|
|
let expiresDate = results[0]._perishable_token_expires_at;
|
|
if (expiresDate && expiresDate.__type == 'Date') {
|
|
expiresDate = new Date(expiresDate.iso);
|
|
}
|
|
if (expiresDate > new Date()) {
|
|
user = results[0];
|
|
}
|
|
}
|
|
}
|
|
if (!user || !user._perishable_token) {
|
|
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 options = {
|
|
appName: this.config.appName,
|
|
link: link,
|
|
user: inflate('_User', user),
|
|
};
|
|
|
|
if (this.adapter.sendPasswordResetEmail) {
|
|
this.adapter.sendPasswordResetEmail(options);
|
|
} else {
|
|
this.adapter.sendMail(this.defaultResetPasswordEmail(options));
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
defaultVerificationEmail({ link, user, appName }) {
|
|
const text =
|
|
'Hi,\n\n' +
|
|
'You are being asked to confirm the e-mail address ' +
|
|
user.get('email') +
|
|
' with ' +
|
|
appName +
|
|
'\n\n' +
|
|
'' +
|
|
'Click here to confirm it:\n' +
|
|
link;
|
|
const to = user.get('email');
|
|
const subject = 'Please verify your e-mail for ' + appName;
|
|
return { text, to, subject };
|
|
}
|
|
|
|
defaultResetPasswordEmail({ link, user, appName }) {
|
|
const text =
|
|
'Hi,\n\n' +
|
|
'You requested to reset your password for ' +
|
|
appName +
|
|
(user.get('username') ? " (your username is '" + user.get('username') + "')" : '') +
|
|
'.\n\n' +
|
|
'' +
|
|
'Click here to reset it:\n' +
|
|
link;
|
|
const to = user.get('email') || user.get('username');
|
|
const subject = 'Password Reset for ' + appName;
|
|
return { text, to, subject };
|
|
}
|
|
}
|
|
|
|
// Mark this private
|
|
function updateUserPassword(user, password, config) {
|
|
return rest
|
|
.update(
|
|
config,
|
|
Auth.master(config),
|
|
'_User',
|
|
{ objectId: user.objectId },
|
|
{
|
|
password: password,
|
|
}
|
|
)
|
|
.then(() => user);
|
|
}
|
|
|
|
function buildEmailLink(destination, username, token, config) {
|
|
const usernameAndToken = `token=${token}&username=${username}`;
|
|
|
|
if (config.parseFrameURL) {
|
|
const destinationWithoutHost = destination.replace(config.publicServerURL, '');
|
|
|
|
return `${config.parseFrameURL}?link=${encodeURIComponent(
|
|
destinationWithoutHost
|
|
)}&${usernameAndToken}`;
|
|
} else {
|
|
return `${destination}?${usernameAndToken}`;
|
|
}
|
|
}
|
|
|
|
export default UserController;
|