diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 0380589057..c937dc4c15 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -210,6 +210,113 @@ describe('Parse.User testing', () => { done(); }); + describe('autoSignupOnLogin option', () => { + it('does not auto sign up when disabled', async () => { + await reconfigureServer({ autoSignupOnLogin: false }); + await expectAsync(Parse.User.logIn('ghost-user', 'hunter2')).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OBJECT_NOT_FOUND }) + ); + const count = await new Parse.Query(Parse.User) + .equalTo('username', 'ghost-user') + .count({ useMasterKey: true }); + expect(count).toBe(0); + }); + + it('creates user on login when enabled (username + password)', async () => { + await reconfigureServer({ autoSignupOnLogin: true }); + const user = await Parse.User.logIn('auto-login-user', 'pass1234'); + expect(user.id).toBeDefined(); + expect(user.getSessionToken()).toBeDefined(); + const stored = await new Parse.Query(Parse.User) + .equalTo('username', 'auto-login-user') + .first({ useMasterKey: true }); + expect(stored).toBeTruthy(); + expect(stored.id).toBe(user.id); + }); + + it('creates user on login when enabled with email + password', async () => { + await reconfigureServer({ autoSignupOnLogin: true }); + const email = 'auto-email@example.com'; + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + email, + password: 'pass1234', + }, + }); + expect(res.data.username).toBe(email); + expect(res.data.email).toBe(email); + expect(res.data.sessionToken).toBeDefined(); + const stored = await new Parse.Query(Parse.User) + .equalTo('email', email) + .first({ useMasterKey: true }); + expect(stored).toBeTruthy(); + expect(stored.get('username')).toBe(email); + }); + + it('uses existing user when present and does not duplicate', async () => { + await reconfigureServer({ autoSignupOnLogin: true }); + const existing = new Parse.User(); + existing.setUsername('existing-login'); + existing.setPassword('pass123'); + await existing.signUp(); + + const logged = await Parse.User.logIn('existing-login', 'pass123'); + expect(logged.id).toBe(existing.id); + const count = await new Parse.Query(Parse.User) + .equalTo('username', 'existing-login') + .count({ useMasterKey: true }); + expect(count).toBe(1); + }); + + it('respects preventLoginWithUnverifiedEmail when auto-signing up', async () => { + await reconfigureServer({ + appName: 'preventLoginWithUnverifiedEmail', + autoSignupOnLogin: true, + verifyUserEmails: true, + preventLoginWithUnverifiedEmail: true, + emailAdapter: { + sendVerificationMail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }, + publicServerURL: 'http://localhost:8378/1', + }); + const email = 'unverified@example.com'; + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + email, + password: 'pass1234', + }, + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + data: jasmine.objectContaining({ code: Parse.Error.EMAIL_NOT_FOUND }), + }) + ); + const storedCount = await new Parse.Query(Parse.User) + .equalTo('email', email) + .count({ useMasterKey: true }); + expect(storedCount).toBe(0); + + // Ensure no session persists for the rejected login + const sessions = await new Parse.Query('_Session') + .find({ useMasterKey: true }); + expect(sessions.length).toBe(0); + }); + }); + it('should respect ACL without locking user out', done => { const user = new Parse.User(); const ACL = new Parse.ACL(); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 66c1d8bcea..b1262f0f75 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -113,6 +113,13 @@ module.exports.ParseServerOptions = { 'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication', action: parsers.objectParser, }, + autoSignupOnLogin: { + env: 'PARSE_SERVER_AUTO_SIGNUP_ON_LOGIN', + help: + '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`.', + action: parsers.booleanParser, + default: false, + }, cacheAdapter: { env: 'PARSE_SERVER_CACHE_ADAPTER', help: 'Adapter module for the cache', diff --git a/src/Options/docs.js b/src/Options/docs.js index 9569239ef7..c8bd8716f5 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -22,6 +22,7 @@ * @property {String} appId Your Parse Application ID * @property {String} appName Sets the app name * @property {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication + * @property {Boolean} autoSignupOnLogin 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`. * @property {Adapter} cacheAdapter Adapter module for the cache * @property {Number} cacheMaxSize Sets the maximum size for the in memory cache, defaults to 10000 * @property {Number} cacheTTL Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds) diff --git a/src/Options/index.js b/src/Options/index.js index cdeb7cd846..1a2190ff88 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -193,6 +193,9 @@ export interface ParseServerOptions { Requires option `verifyUserEmails: true`. :DEFAULT: false */ preventSignupWithUnverifiedEmail: ?boolean; + /* 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`. + :DEFAULT: false */ + autoSignupOnLogin: ?boolean; /* 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.

For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours). diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 3828e465e7..2e037de89c 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -61,38 +61,81 @@ export class UsersRouter extends ClassesRouter { } } + /** + * Resolve email verification flags; supports boolean or async function options. + * @param {Object} req The request + * @param {Object} userObj The user object to pass into config callbacks + * @returns {Promise<{verifyUserEmails: boolean, preventLoginWithUnverifiedEmail: boolean, preventSignupWithUnverifiedEmail: boolean}>} + * @private + */ + async _resolveEmailVerificationFlags(req, userObj) { + const request = { + master: req.auth.isMaster, + ip: req.config.ip, + installationId: req.auth.installationId, + object: Parse.User.fromJSON(Object.assign({ className: '_User' }, userObj)), + }; + const verifyUserEmails = + req.config.verifyUserEmails === true || + (typeof req.config.verifyUserEmails === 'function' && + (await Promise.resolve(req.config.verifyUserEmails(request))) === true); + const preventLoginWithUnverifiedEmail = + req.config.preventLoginWithUnverifiedEmail === true || + (typeof req.config.preventLoginWithUnverifiedEmail === 'function' && + (await Promise.resolve(req.config.preventLoginWithUnverifiedEmail(request))) === true); + const preventSignupWithUnverifiedEmail = + req.config.preventSignupWithUnverifiedEmail === true || + (typeof req.config.preventSignupWithUnverifiedEmail === 'function' && + (await Promise.resolve(req.config.preventSignupWithUnverifiedEmail(request))) === true); + + return { + verifyUserEmails, + preventLoginWithUnverifiedEmail, + preventSignupWithUnverifiedEmail, + }; + } + + /** + * Extract and validate login payload from request + * @param {Object} req The request + * @returns {{ username: string | void, email: string | void, password: string, ignoreEmailVerification: boolean | void }} + * @private + */ + _getLoginPayload(req) { + let payload = req.body || {}; + if ( + (!payload.username && req.query && req.query.username) || + (!payload.email && req.query && req.query.email) + ) { + payload = req.query; + } + const { username, email, password, ignoreEmailVerification } = payload; + + if (!username && !email) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username/email is required.'); + } + if (!password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.'); + } + if ( + typeof password !== 'string' || + (email && typeof email !== 'string') || + (username && typeof username !== 'string') + ) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + + return { username, email, password, ignoreEmailVerification }; + } + /** * Validates a password request in login and verifyPassword * @param {Object} req The request * @returns {Object} User object - * @private */ _authenticateUserFromRequest(req) { return new Promise((resolve, reject) => { - // Use query parameters instead if provided in url - let payload = req.body || {}; - if ( - (!payload.username && req.query && req.query.username) || - (!payload.email && req.query && req.query.email) - ) { - payload = req.query; - } - const { username, email, password, ignoreEmailVerification } = payload; - - // TODO: use the right error codes / descriptions. - if (!username && !email) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username/email is required.'); - } - if (!password) { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.'); - } - if ( - typeof password !== 'string' || - (email && typeof email !== 'string') || - (username && typeof username !== 'string') - ) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); - } + const { username, email, password, ignoreEmailVerification } = this._getLoginPayload(req); let user; let isValidPassword = false; @@ -139,23 +182,14 @@ export class UsersRouter extends ClassesRouter { if (!req.auth.isMaster && user.ACL && Object.keys(user.ACL).length == 0) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - // Create request object for verification functions - const request = { - master: req.auth.isMaster, - ip: req.config.ip, - installationId: req.auth.installationId, - object: Parse.User.fromJSON(Object.assign({ className: '_User' }, user)), - }; // If request doesn't use master or maintenance key with ignoring email verification if (!((req.auth.isMaster || req.auth.isMaintenance) && ignoreEmailVerification)) { - - // 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 - // conditional statement below, as a developer may decide to execute expensive operations in them - const verifyUserEmails = async () => req.config.verifyUserEmails === true || (typeof req.config.verifyUserEmails === 'function' && await Promise.resolve(req.config.verifyUserEmails(request)) === true); - const preventLoginWithUnverifiedEmail = async () => req.config.preventLoginWithUnverifiedEmail === true || (typeof req.config.preventLoginWithUnverifiedEmail === 'function' && await Promise.resolve(req.config.preventLoginWithUnverifiedEmail(request)) === true); - if (await verifyUserEmails() && await preventLoginWithUnverifiedEmail() && !user.emailVerified) { + const { + verifyUserEmails, + preventLoginWithUnverifiedEmail, + } = await this._resolveEmailVerificationFlags(req, user); + if (verifyUserEmails && preventLoginWithUnverifiedEmail && !user.emailVerified) { throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); } } @@ -170,6 +204,86 @@ export class UsersRouter extends ClassesRouter { }); } + /** + * Auto sign-up when login misses existing user and option is enabled + * @param {Object} req The request + * @returns {{ user: Object, authDataResponse: any }} + */ + async _autoSignupOnLogin(req) { + const { username, email, password } = this._getLoginPayload(req); + const inferredUsername = username || email; + const data = { username: inferredUsername, password }; + if (email) { + data.email = email; + } + + const { response } = await new RestWrite( + req.config, + req.auth, + '_User', + null, + data, + null, + req.info.clientSDK, + req.info.context + ).execute(); + + // Fetch fresh user object to return a login-like response with username/email + const createdUserResults = await req.config.database.find( + '_User', + { objectId: response.objectId }, + {}, + Auth.master(req.config) + ); + const createdUser = createdUserResults[0]; + if (!createdUser) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + + const cleanupAutoSignup = async () => { + if (response.sessionToken) { + await req.config.database.destroy( + '_Session', + { sessionToken: response.sessionToken }, + { acl: undefined } + ); + } + await req.config.database.destroy( + '_User', + { objectId: response.objectId }, + { acl: undefined } + ); + }; + + const { + verifyUserEmails, + preventLoginWithUnverifiedEmail, + preventSignupWithUnverifiedEmail, + } = await this._resolveEmailVerificationFlags(req, createdUser); + + if (verifyUserEmails && preventLoginWithUnverifiedEmail && createdUser.email && createdUser.emailVerified !== true) { + await cleanupAutoSignup(); + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); + } + + // Enforce preventSignupWithUnverifiedEmail by cleaning up the session and failing the login + if (verifyUserEmails && preventSignupWithUnverifiedEmail && createdUser.email && createdUser.emailVerified !== true) { + await cleanupAutoSignup(); + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); + } + + UsersRouter.removeHiddenProperties(createdUser); + await req.config.filesController.expandFilesInObject(req.config, createdUser); + + // Attach the session token created during signup; tell caller to skip creating another session + return { + user: createdUser, + authDataResponse: response.authDataResponse, + sessionToken: response.sessionToken, + skipSessionCreation: true, + }; + } + handleMe(req) { if (!req.info || !req.info.sessionToken) { throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); @@ -201,8 +315,32 @@ export class UsersRouter extends ClassesRouter { } async handleLogIn(req) { - const user = await this._authenticateUserFromRequest(req); + let user; + let authDataResponse; + let validatedAuthData; + let autoSignupResult; + + try { + user = await this._authenticateUserFromRequest(req); + } catch (error) { + if ( + req.config.autoSignupOnLogin && + error && + error.code === Parse.Error.OBJECT_NOT_FOUND + ) { + autoSignupResult = await this._autoSignupOnLogin(req); + user = autoSignupResult.user; + authDataResponse = autoSignupResult.authDataResponse; + if (autoSignupResult.sessionToken) { + user.sessionToken = autoSignupResult.sessionToken; + } + } else { + throw error; + } + } + const authData = req.body && req.body.authData; + // Check if user has provided their required auth providers Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( req, @@ -211,8 +349,6 @@ export class UsersRouter extends ClassesRouter { req.config ); - let authDataResponse; - let validatedAuthData; if (authData) { const res = await Auth.handleAuthDataValidation( authData, @@ -288,18 +424,20 @@ export class UsersRouter extends ClassesRouter { ); } - const { sessionData, createSession } = RestWrite.createSession(req.config, { - userId: user.objectId, - createdWith: { - action: 'login', - authProvider: 'password', - }, - installationId: req.info.installationId, - }); - - user.sessionToken = sessionData.sessionToken; + // Create a session only if not already created by auto-signup + if (!autoSignupResult || !autoSignupResult.skipSessionCreation) { + const { sessionData, createSession } = RestWrite.createSession(req.config, { + userId: user.objectId, + createdWith: { + action: 'login', + authProvider: 'password', + }, + installationId: req.info.installationId, + }); - await createSession(); + user.sessionToken = sessionData.sessionToken; + await createSession(); + } const afterLoginUser = Parse.User.fromJSON(Object.assign({ className: '_User' }, user)); await maybeRunTrigger(