Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
86 changes: 84 additions & 2 deletions spec/EmailVerificationToken.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,15 @@ describe('Email Verification Token Expiration:', () => {
};
const verifyUserEmails = {
method(req) {
expect(Object.keys(req)).toEqual(['original', 'object', 'master', 'ip', 'installationId']);
expect(Object.keys(req)).toEqual([
'original',
'object',
'master',
'ip',
'installationId',
'createdWith',
]);
expect(req.createdWith).toEqual({ action: 'signup', authProvider: 'password' });
return false;
},
};
Expand Down Expand Up @@ -359,7 +367,15 @@ describe('Email Verification Token Expiration:', () => {
};
const verifyUserEmails = {
method(req) {
expect(Object.keys(req)).toEqual(['original', 'object', 'master', 'ip', 'installationId']);
expect(Object.keys(req)).toEqual([
'original',
'object',
'master',
'ip',
'installationId',
'createdWith',
]);
expect(req.createdWith).toEqual({ action: 'signup', authProvider: 'password' });
if (req.object.get('username') === 'no_email') {
return false;
}
Expand Down Expand Up @@ -394,6 +410,71 @@ describe('Email Verification Token Expiration:', () => {
expect(verifySpy).toHaveBeenCalledTimes(5);
});

it('provides createdWith on signup when verification blocks session creation', async () => {
const verifyUserEmails = {
method: params => {
expect(params.object).toBeInstanceOf(Parse.User);
expect(params.createdWith).toEqual({ action: 'signup', authProvider: 'password' });
return true;
},
};
const verifySpy = spyOn(verifyUserEmails, 'method').and.callThrough();
await reconfigureServer({
appName: 'emailVerifyToken',
verifyUserEmails: verifyUserEmails.method,
preventLoginWithUnverifiedEmail: true,
preventSignupWithUnverifiedEmail: true,
emailAdapter: MockEmailAdapterWithOptions({
fromAddress: 'parse@example.com',
apiKey: 'k',
domain: 'd',
}),
publicServerURL: 'http://localhost:8378/1',
});

const user = new Parse.User();
user.setUsername('signup_created_with');
user.setPassword('pass');
user.setEmail('signup@example.com');
const res = await user.signUp().catch(e => e);
expect(res.message).toBe('User email is not verified.');
expect(user.getSessionToken()).toBeUndefined();
expect(verifySpy).toHaveBeenCalledTimes(2); // before signup completion and on preventLoginWithUnverifiedEmail
});

it('provides createdWith with auth provider on login verification', async () => {
const user = new Parse.User();
user.setUsername('user_created_with_login');
user.setPassword('pass');
user.set('email', 'login@example.com');
await user.signUp();

const verifyUserEmails = {
method: async params => {
expect(params.object).toBeInstanceOf(Parse.User);
expect(params.createdWith).toEqual({ action: 'login', authProvider: 'password' });
return true;
},
};
const verifyUserEmailsSpy = spyOn(verifyUserEmails, 'method').and.callThrough();
await reconfigureServer({
appName: 'emailVerifyToken',
publicServerURL: 'http://localhost:8378/1',
verifyUserEmails: verifyUserEmails.method,
preventLoginWithUnverifiedEmail: verifyUserEmails.method,
preventSignupWithUnverifiedEmail: true,
emailAdapter: MockEmailAdapterWithOptions({
fromAddress: 'parse@example.com',
apiKey: 'k',
domain: 'd',
}),
});

const res = await Parse.User.logIn('user_created_with_login', 'pass').catch(e => e);
expect(res.code).toBe(205);
expect(verifyUserEmailsSpy).toHaveBeenCalledTimes(2); // before login completion and on preventLoginWithUnverifiedEmail
});

it_id('d812de87-33d1-495e-a6e8-3485f6dc3589')(it)('can conditionally send user email verification', async () => {
const emailAdapter = {
sendVerificationEmail: () => {},
Expand Down Expand Up @@ -797,6 +878,7 @@ describe('Email Verification Token Expiration:', () => {
expect(params.master).toBeDefined();
expect(params.installationId).toBeDefined();
expect(params.resendRequest).toBeTrue();
expect(params.createdWith).toBeUndefined();
return true;
},
};
Expand Down
1 change: 1 addition & 0 deletions spec/ValidationAndPasswordsReset.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
expect(params.ip).toBeDefined();
expect(params.master).toBeDefined();
expect(params.installationId).toBeDefined();
expect(params.createdWith).toEqual({ action: 'login', authProvider: 'password' });
return true;
},
};
Expand Down
3 changes: 1 addition & 2 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,6 @@ module.exports.ParseServerOptions = {
env: 'PARSE_SERVER_PREVENT_LOGIN_WITH_UNVERIFIED_EMAIL',
help:
'Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required.<br><br>Default is `false`.<br>Requires option `verifyUserEmails: true`.',
action: parsers.booleanParser,
default: false,
},
preventSignupWithUnverifiedEmail: {
Expand Down Expand Up @@ -637,7 +636,7 @@ module.exports.ParseServerOptions = {
verifyUserEmails: {
env: 'PARSE_SERVER_VERIFY_USER_EMAILS',
help:
'Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification.<br><br>Default is `false`.',
'Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.<br><br>Default is `false`.',
default: false,
},
webhookKey: {
Expand Down
2 changes: 1 addition & 1 deletion src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 18 additions & 3 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ type RequestKeywordDenylist = {
key: string | any,
value: any,
};
type EmailVerificationRequest = {
original?: any,
object: any,
master?: boolean,
ip?: string,
installationId?: string,
createdWith?: {
action: 'login' | 'signup',
authProvider: string,
},
resendRequest?: boolean,
};

export interface ParseServerOptions {
/* Your Parse Application ID
Expand Down Expand Up @@ -174,18 +186,21 @@ export interface ParseServerOptions {
/* Max file size for uploads, defaults to 20mb
:DEFAULT: 20mb */
maxUploadSize: ?string;
/* Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification.
/* Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.
<br><br>
Default is `false`.
:DEFAULT: false */
verifyUserEmails: ?(boolean | void);
verifyUserEmails: ?(boolean | (EmailVerificationRequest => boolean | Promise<boolean>));
/* Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required.
<br><br>
Default is `false`.
<br>
Requires option `verifyUserEmails: true`.
:DEFAULT: false */
preventLoginWithUnverifiedEmail: ?boolean;
preventLoginWithUnverifiedEmail: ?(
| boolean
| (EmailVerificationRequest => boolean | Promise<boolean>)
);
/* If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.
<br><br>
Default is `false`.
Expand Down
34 changes: 30 additions & 4 deletions src/RestWrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,28 @@ RestWrite.prototype._validateUserName = function () {
});
};

RestWrite.prototype.getCreatedWith = function () {
if (this.storage.createdWith) {
return this.storage.createdWith;
}
const isCreateOperation = !this.query;
// Determine authProvider: from stored authProvider or authData keys (e.g., anonymous, facebook).
// Default to 'password' on signup with no authData so createdWith aligns with legacy expectations/tests.
const authProvider =
this.storage.authProvider ||
(this.data &&
this.data.authData &&
Object.keys(this.data.authData).length &&
Object.keys(this.data.authData).join(','));
const action = authProvider ? 'login' : isCreateOperation ? 'signup' : undefined;
if (!action) {
return;
}
const resolvedAuthProvider = authProvider || (action === 'signup' ? 'password' : undefined);
this.storage.createdWith = { action, authProvider: resolvedAuthProvider };
return this.storage.createdWith;
};

/*
As with usernames, Parse should not allow case insensitive collisions of email.
unlike with usernames (which can have case insensitive collisions in the case of
Expand Down Expand Up @@ -826,6 +848,7 @@ RestWrite.prototype._validateEmail = function () {
master: this.auth.isMaster,
ip: this.config.ip,
installationId: this.auth.installationId,
createdWith: this.getCreatedWith(),
};
return this.config.userController.setEmailVerifyToken(this.data, request, this.storage);
}
Expand Down Expand Up @@ -961,6 +984,7 @@ RestWrite.prototype.createSessionTokenIfNeeded = async function () {
master: this.auth.isMaster,
ip: this.config.ip,
installationId: this.auth.installationId,
createdWith: this.getCreatedWith(),
};
// Get verification conditions which can be booleans or functions; the purpose of this async/await
// structure is to avoid unnecessarily executing subsequent functions if previous ones fail in the
Expand All @@ -987,12 +1011,14 @@ RestWrite.prototype.createSessionToken = async function () {
this.storage.authProvider = Object.keys(this.data.authData).join(',');
}

const { sessionData, createSession } = RestWrite.createSession(this.config, {
userId: this.objectId(),
createdWith: {
const createdWith =
this.getCreatedWith() || {
action: this.storage.authProvider ? 'login' : 'signup',
authProvider: this.storage.authProvider || 'password',
},
};
const { sessionData, createSession } = RestWrite.createSession(this.config, {
userId: this.objectId(),
createdWith,
installationId: this.auth.installationId,
});

Expand Down
9 changes: 9 additions & 0 deletions src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,20 @@ export class UsersRouter extends ClassesRouter {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
// Create request object for verification functions
const authProvider =
req.body &&
req.body.authData &&
Object.keys(req.body.authData).length &&
Object.keys(req.body.authData).join(',');
const request = {
master: req.auth.isMaster,
ip: req.config.ip,
installationId: req.auth.installationId,
object: Parse.User.fromJSON(Object.assign({ className: '_User' }, user)),
createdWith: {
action: 'login',
authProvider: authProvider || 'password',
},
};

// If request doesn't use master or maintenance key with ignoring email verification
Expand Down
16 changes: 14 additions & 2 deletions types/Options/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ type RequestKeywordDenylist = {
key: string;
value: any;
};
export interface VerifyUserEmailsRequest {
original?: any;
object: any;
master?: boolean;
ip?: string;
installationId?: string;
createdWith?: {
action: 'login' | 'signup';
authProvider: string;
};
resendRequest?: boolean;
}
export interface ParseServerOptions {
appId: string;
masterKey: (() => void) | string;
Expand Down Expand Up @@ -74,8 +86,8 @@ export interface ParseServerOptions {
auth?: Record<string, AuthAdapter>;
enableInsecureAuthAdapters?: boolean;
maxUploadSize?: string;
verifyUserEmails?: (boolean | void);
preventLoginWithUnverifiedEmail?: boolean;
verifyUserEmails?: boolean | ((params: VerifyUserEmailsRequest) => boolean | Promise<boolean>);
preventLoginWithUnverifiedEmail?: boolean | ((params: VerifyUserEmailsRequest) => boolean | Promise<boolean>);
preventSignupWithUnverifiedEmail?: boolean;
emailVerifyTokenValidityDuration?: number;
emailVerifyTokenReuseIfValid?: boolean;
Expand Down
Loading