Skip to content

Commit c183da6

Browse files
committed
feat: Add autoSignupLogin to signup when user doesn't already exist
1 parent e78e58d commit c183da6

5 files changed

Lines changed: 221 additions & 28 deletions

File tree

spec/ParseUser.spec.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,109 @@ describe('Parse.User testing', () => {
210210
done();
211211
});
212212

213+
describe('autoSignupOnLogin option', () => {
214+
it('does not auto sign up when disabled', async () => {
215+
await reconfigureServer({ autoSignupOnLogin: false });
216+
await expectAsync(Parse.User.logIn('ghost-user', 'hunter2')).toBeRejectedWith(
217+
jasmine.objectContaining({ code: Parse.Error.OBJECT_NOT_FOUND })
218+
);
219+
const count = await new Parse.Query(Parse.User)
220+
.equalTo('username', 'ghost-user')
221+
.count({ useMasterKey: true });
222+
expect(count).toBe(0);
223+
});
224+
225+
it('creates user on login when enabled (username + password)', async () => {
226+
await reconfigureServer({ autoSignupOnLogin: true });
227+
const user = await Parse.User.logIn('auto-login-user', 'pass1234');
228+
expect(user.id).toBeDefined();
229+
expect(user.getSessionToken()).toBeDefined();
230+
const stored = await new Parse.Query(Parse.User)
231+
.equalTo('username', 'auto-login-user')
232+
.first({ useMasterKey: true });
233+
expect(stored).toBeTruthy();
234+
expect(stored.id).toBe(user.id);
235+
});
236+
237+
it('creates user on login when enabled with email + password', async () => {
238+
await reconfigureServer({ autoSignupOnLogin: true });
239+
const email = 'auto-email@example.com';
240+
const res = await request({
241+
method: 'POST',
242+
url: 'http://localhost:8378/1/login',
243+
headers: {
244+
'X-Parse-Application-Id': Parse.applicationId,
245+
'X-Parse-REST-API-Key': 'rest',
246+
},
247+
body: {
248+
email,
249+
password: 'pass1234',
250+
},
251+
});
252+
expect(res.data.username).toBe(email);
253+
expect(res.data.email).toBe(email);
254+
expect(res.data.sessionToken).toBeDefined();
255+
const stored = await new Parse.Query(Parse.User)
256+
.equalTo('email', email)
257+
.first({ useMasterKey: true });
258+
expect(stored).toBeTruthy();
259+
expect(stored.get('username')).toBe(email);
260+
});
261+
262+
it('uses existing user when present and does not duplicate', async () => {
263+
await reconfigureServer({ autoSignupOnLogin: true });
264+
const existing = new Parse.User();
265+
existing.setUsername('existing-login');
266+
existing.setPassword('pass123');
267+
await existing.signUp();
268+
269+
const logged = await Parse.User.logIn('existing-login', 'pass123');
270+
expect(logged.id).toBe(existing.id);
271+
const count = await new Parse.Query(Parse.User)
272+
.equalTo('username', 'existing-login')
273+
.count({ useMasterKey: true });
274+
expect(count).toBe(1);
275+
});
276+
277+
it('respects preventLoginWithUnverifiedEmail when auto-signing up', async () => {
278+
await reconfigureServer({
279+
appName: 'preventLoginWithUnverifiedEmail',
280+
autoSignupOnLogin: true,
281+
verifyUserEmails: true,
282+
preventLoginWithUnverifiedEmail: true,
283+
emailAdapter: {
284+
sendVerificationMail: () => Promise.resolve(),
285+
sendMail: () => Promise.resolve(),
286+
},
287+
publicServerURL: 'http://localhost:8378/1',
288+
});
289+
const email = 'unverified@example.com';
290+
await expectAsync(
291+
request({
292+
method: 'POST',
293+
url: 'http://localhost:8378/1/login',
294+
headers: {
295+
'X-Parse-Application-Id': Parse.applicationId,
296+
'X-Parse-REST-API-Key': 'rest',
297+
},
298+
body: {
299+
email,
300+
password: 'pass1234',
301+
},
302+
})
303+
).toBeRejectedWith(
304+
jasmine.objectContaining({
305+
data: jasmine.objectContaining({ code: Parse.Error.EMAIL_NOT_FOUND }),
306+
})
307+
);
308+
const stored = await new Parse.Query(Parse.User)
309+
.equalTo('email', email)
310+
.first({ useMasterKey: true });
311+
expect(stored).toBeTruthy();
312+
expect(stored.get('emailVerified')).toBe(false);
313+
});
314+
});
315+
213316
it('should respect ACL without locking user out', done => {
214317
const user = new Parse.User();
215318
const ACL = new Parse.ACL();

src/Options/Definitions.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ module.exports.ParseServerOptions = {
113113
'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication',
114114
action: parsers.objectParser,
115115
},
116+
autoSignupOnLogin: {
117+
env: 'PARSE_SERVER_AUTO_SIGNUP_ON_LOGIN',
118+
help:
119+
'Set to `true` to allow the login endpoint to automatically create a user with the provided username/email and password when no existing user is found. Default is `false`.',
120+
action: parsers.booleanParser,
121+
default: false,
122+
},
116123
cacheAdapter: {
117124
env: 'PARSE_SERVER_CACHE_ADAPTER',
118125
help: 'Adapter module for the cache',

src/Options/docs.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Options/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ export interface ParseServerOptions {
193193
Requires option `verifyUserEmails: true`.
194194
:DEFAULT: false */
195195
preventSignupWithUnverifiedEmail: ?boolean;
196+
/* Set to `true` to allow the login endpoint to automatically create a user with the provided username/email and password when no existing user is found. Default is `false`.
197+
:DEFAULT: false */
198+
autoSignupOnLogin: ?boolean;
196199
/* Set the validity duration of the email verification token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires.
197200
<br><br>
198201
For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).

src/Routers/UsersRouter.js

Lines changed: 107 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -61,38 +61,47 @@ export class UsersRouter extends ClassesRouter {
6161
}
6262
}
6363

64+
/**
65+
* Extract and validate login payload from request
66+
* @param {Object} req The request
67+
* @returns {{ username: string | void, email: string | void, password: string, ignoreEmailVerification: boolean | void }}
68+
* @private
69+
*/
70+
_getLoginPayload(req) {
71+
let payload = req.body || {};
72+
if (
73+
(!payload.username && req.query && req.query.username) ||
74+
(!payload.email && req.query && req.query.email)
75+
) {
76+
payload = req.query;
77+
}
78+
const { username, email, password, ignoreEmailVerification } = payload;
79+
80+
if (!username && !email) {
81+
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username/email is required.');
82+
}
83+
if (!password) {
84+
throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.');
85+
}
86+
if (
87+
typeof password !== 'string' ||
88+
(email && typeof email !== 'string') ||
89+
(username && typeof username !== 'string')
90+
) {
91+
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
92+
}
93+
94+
return { username, email, password, ignoreEmailVerification };
95+
}
96+
6497
/**
6598
* Validates a password request in login and verifyPassword
6699
* @param {Object} req The request
67100
* @returns {Object} User object
68-
* @private
69101
*/
70102
_authenticateUserFromRequest(req) {
71103
return new Promise((resolve, reject) => {
72-
// Use query parameters instead if provided in url
73-
let payload = req.body || {};
74-
if (
75-
(!payload.username && req.query && req.query.username) ||
76-
(!payload.email && req.query && req.query.email)
77-
) {
78-
payload = req.query;
79-
}
80-
const { username, email, password, ignoreEmailVerification } = payload;
81-
82-
// TODO: use the right error codes / descriptions.
83-
if (!username && !email) {
84-
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username/email is required.');
85-
}
86-
if (!password) {
87-
throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.');
88-
}
89-
if (
90-
typeof password !== 'string' ||
91-
(email && typeof email !== 'string') ||
92-
(username && typeof username !== 'string')
93-
) {
94-
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
95-
}
104+
const { username, email, password, ignoreEmailVerification } = this._getLoginPayload(req);
96105

97106
let user;
98107
let isValidPassword = false;
@@ -170,6 +179,58 @@ export class UsersRouter extends ClassesRouter {
170179
});
171180
}
172181

182+
/**
183+
* Auto sign-up when login misses existing user and option is enabled
184+
* @param {Object} req The request
185+
* @returns {{ user: Object, authDataResponse: any }}
186+
*/
187+
async _autoSignupOnLogin(req) {
188+
const { username, email, password } = this._getLoginPayload(req);
189+
const inferredUsername = username || email;
190+
const data = { username: inferredUsername, password };
191+
if (email) {
192+
data.email = email;
193+
}
194+
195+
const { response } = await new RestWrite(
196+
req.config,
197+
req.auth,
198+
'_User',
199+
null,
200+
data,
201+
null,
202+
req.info.clientSDK,
203+
req.info.context
204+
).execute();
205+
206+
// Fetch fresh user object to return a login-like response with username/email
207+
const createdUserResults = await req.config.database.find(
208+
'_User',
209+
{ objectId: response.objectId },
210+
{},
211+
Auth.master(req.config)
212+
);
213+
const createdUser = createdUserResults[0];
214+
if (!createdUser) {
215+
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
216+
}
217+
createdUser.sessionToken = response.sessionToken;
218+
219+
if (
220+
req.config.verifyUserEmails &&
221+
req.config.preventLoginWithUnverifiedEmail &&
222+
createdUser.email &&
223+
createdUser.emailVerified !== true
224+
) {
225+
throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.');
226+
}
227+
228+
UsersRouter.removeHiddenProperties(createdUser);
229+
await req.config.filesController.expandFilesInObject(req.config, createdUser);
230+
231+
return { user: createdUser, authDataResponse: response.authDataResponse };
232+
}
233+
173234
handleMe(req) {
174235
if (!req.info || !req.info.sessionToken) {
175236
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
@@ -201,8 +262,28 @@ export class UsersRouter extends ClassesRouter {
201262
}
202263

203264
async handleLogIn(req) {
204-
const user = await this._authenticateUserFromRequest(req);
265+
let user;
266+
let authDataResponse;
267+
let validatedAuthData;
268+
269+
try {
270+
user = await this._authenticateUserFromRequest(req);
271+
} catch (error) {
272+
if (
273+
req.config.autoSignupOnLogin &&
274+
error &&
275+
error.code === Parse.Error.OBJECT_NOT_FOUND
276+
) {
277+
const autoSignup = await this._autoSignupOnLogin(req);
278+
user = autoSignup.user;
279+
authDataResponse = autoSignup.authDataResponse;
280+
} else {
281+
throw error;
282+
}
283+
}
284+
205285
const authData = req.body && req.body.authData;
286+
206287
// Check if user has provided their required auth providers
207288
Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(
208289
req,
@@ -211,8 +292,6 @@ export class UsersRouter extends ClassesRouter {
211292
req.config
212293
);
213294

214-
let authDataResponse;
215-
let validatedAuthData;
216295
if (authData) {
217296
const res = await Auth.handleAuthDataValidation(
218297
authData,

0 commit comments

Comments
 (0)