├── .gitignore ├── .versions ├── sms_tests_setup.js ├── package.js ├── sms_tests.js ├── sms_server.js ├── phone_tests_setup.js ├── phone_client.js ├── README.md ├── phone_server.js └── phone_tests.js /.gitignore: -------------------------------------------------------------------------------- 1 | .npm -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.0 2 | base64@1.0.3 3 | binary-heap@1.0.3 4 | callback-hook@1.0.3 5 | check@1.0.5 6 | ddp@1.1.0 7 | ejson@1.0.6 8 | email@1.0.6 9 | geojson-utils@1.0.3 10 | id-map@1.0.3 11 | jquery@1.11.3_2 12 | json@1.0.3 13 | local-test:poetic:accounts-phone@0.0.6 14 | localstorage@1.0.3 15 | logging@1.0.7 16 | meteor@1.1.6 17 | minimongo@1.0.8 18 | mongo@1.1.0 19 | npm-bcrypt@0.8.6_3 20 | ordered-dict@1.0.3 21 | poetic:accounts-phone@0.0.6 22 | random@1.0.3 23 | retry@1.0.3 24 | service-configuration@1.0.4 25 | sha@1.0.3 26 | srp@1.0.3 27 | test-helpers@1.0.4 28 | tinytest@1.0.5 29 | tracker@1.0.7 30 | underscore@1.0.3 31 | -------------------------------------------------------------------------------- /sms_tests_setup.js: -------------------------------------------------------------------------------- 1 | // 2 | // a mechanism to intercept emails sent to addressing including 3 | // the string "intercept", storing them in an array that can then 4 | // be retrieved using the getInterceptedSMS method 5 | // 6 | var interceptedSMS = {}; // (phone) -> (array of options) 7 | 8 | var streamBuffers = Npm.require('stream-buffers'); 9 | var stream = new streamBuffers.WritableStreamBuffer; 10 | SMSTest.overrideOutputStream(stream); 11 | 12 | SMSTest.hookSend(function (options) { 13 | // console.log('in1', options, options.to); 14 | var to = options.to; 15 | if (!to) { 16 | return true; // go ahead and send 17 | } else { 18 | if (!interceptedSMS[to]) 19 | interceptedSMS[to] = []; 20 | 21 | interceptedSMS[to].push(options); 22 | return false; // skip sending 23 | } 24 | }); 25 | 26 | Meteor.methods({ 27 | getInterceptedSMS: function (phone) { 28 | check(phone, String); 29 | return interceptedSMS[phone]; 30 | }, 31 | 32 | addPhoneForTestAndVerify: function (phone) { 33 | check(phone, String); 34 | Meteor.users.update( 35 | {_id: this.userId}, 36 | {$push: {phone: {number: phone, verified: false}}}); 37 | Accounts.sendPhoneVerificationCode(this.userId, phone); 38 | }, 39 | 40 | createUserOnServer: function (phone) { 41 | check(phone, String); 42 | var userId = Accounts.createUserWithPhone({phone: phone}); 43 | Accounts.sendPhoneVerificationCode(this.userId, phone); 44 | return Meteor.users.findOne(userId); 45 | } 46 | }); -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name : 'poetic:accounts-phone', 3 | version : '0.0.7', 4 | // Brief, one-line summary of the package. 5 | summary : 'A login service based on mobile phone number, For Meteor.', 6 | // URL to the Git repository containing the source code for this package. 7 | git : 'https://github.com/poetic/accounts-phone', 8 | // By default, Meteor will default to using README.md for documentation. 9 | // To avoid submitting documentation, set this field to null. 10 | documentation: null 11 | }); 12 | 13 | Npm.depends({ 14 | "phone" : "1.0.3", 15 | "twilio" : "1.10.0", 16 | "stream-buffers": "0.2.5" 17 | }); 18 | 19 | Package.onUse(function (api) { 20 | api.use('npm-bcrypt@=0.8.7', 'server'); 21 | api.use('accounts-base@1.0.2', ['client', 'server']); 22 | // Export Accounts (etc) to packages using this one. 23 | api.imply('accounts-base@1.0.2', ['client', 'server']); 24 | api.use('srp@1.0.2', ['client', 'server']); 25 | api.use('sha@1.0.2', ['client', 'server']); 26 | api.use('email@1.0.5', ['server']); 27 | api.use('random@1.0.2', ['server']); 28 | api.use('ejson@1.0.5', 'server'); 29 | api.use('callback-hook@1.0.2', 'server'); 30 | api.use('check@1.0.4'); 31 | api.use('underscore@1.0.2'); 32 | api.use('ddp@1.0.14', ['client', 'server']); 33 | api.addFiles('sms_server.js', 'server'); 34 | api.addFiles('phone_server.js', 'server'); 35 | api.addFiles('phone_client.js', 'client'); 36 | 37 | api.export('SMS', 'server'); 38 | api.export('SMSTest', 'server', {testOnly: true}); 39 | api.export('Twilio', 'server'); 40 | }); 41 | 42 | Package.onTest(function (api) { 43 | api.use(['poetic:accounts-phone', 'tinytest', 'test-helpers', 'tracker', 44 | 'accounts-base', 'random', 'underscore', 'check', 45 | 'ddp']); 46 | api.addFiles('phone_tests_setup.js', 'server'); 47 | api.addFiles('phone_tests.js', ['client', 'server']); 48 | api.addFiles('sms_tests_setup.js', 'server'); 49 | api.addFiles('sms_tests.js', 'client'); 50 | }); 51 | -------------------------------------------------------------------------------- /sms_tests.js: -------------------------------------------------------------------------------- 1 | // intentionally initialize later so that we can debug tests after 2 | // they fail without trying to recreate a user with the same phone 3 | // address 4 | var phone1; 5 | var code; 6 | 7 | Accounts._isolateLoginTokenForTest(); 8 | 9 | testAsyncMulti("accounts sms - verification flow", [ 10 | function (test, expect) { 11 | phone1 = '+97254580'+ (Math.abs(Math.floor(Math.random() * 1000 - 1000)) + 1000); 12 | Accounts.createUserWithPhone({phone: phone1, password: 'foobar'}, 13 | expect(function (error) { 14 | test.equal(error, undefined); 15 | test.isFalse(Accounts.isPhoneVerified(), 'User phone should not be verified'); 16 | })); 17 | }, 18 | function (test, expect) { 19 | Accounts.requestPhoneVerification(phone1, expect(function (error) { 20 | test.equal(error, undefined); 21 | test.isFalse(Accounts.isPhoneVerified(), 'User phone should not be verified'); 22 | })); 23 | }, 24 | function (test, expect) { 25 | Accounts.connection.call( 26 | "getInterceptedSMS", phone1, expect(function (error, result) { 27 | test.equal(error, undefined); 28 | test.notEqual(result, undefined); 29 | test.isFalse(Accounts.isPhoneVerified(), 'User phone should not be verified'); 30 | 31 | test.equal(result.length, 2); // the first is the phone verification 32 | var options = result[1]; 33 | 34 | var re = new RegExp("Welcome your invitation code is: (.*)") 35 | test.notEqual(null, options.body); 36 | var match = options.body.match(re); 37 | test.equal(phone1, options.to); 38 | test.notEqual(null, options.from); 39 | code = match[1]; 40 | })); 41 | }, 42 | function (test, expect) { 43 | Accounts.verifyPhone(phone1, code, "newPassword", expect(function(error) { 44 | test.isFalse(error); 45 | test.isTrue(Accounts.isPhoneVerified(), 'User phone should be verified'); 46 | })); 47 | }, 48 | function (test, expect) { 49 | Meteor.logout(expect(function (error) { 50 | test.equal(error, undefined); 51 | test.equal(Meteor.user(), null); 52 | test.isFalse(Accounts.isPhoneVerified(), 'User phone should not be verified'); 53 | })); 54 | }, 55 | function (test, expect) { 56 | Meteor.loginWithPhoneAndPassword( 57 | {phone: phone1}, "newPassword", 58 | expect(function (error) { 59 | test.isFalse(error); 60 | test.isTrue(Accounts.isPhoneVerified(), 'User phone should be verified'); 61 | })); 62 | }, 63 | function (test, expect) { 64 | Meteor.logout(expect(function (error) { 65 | test.equal(error, undefined); 66 | test.equal(Meteor.user(), null); 67 | test.isFalse(Accounts.isPhoneVerified(), 'User phone should not be verified'); 68 | })); 69 | } 70 | ]); 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /sms_server.js: -------------------------------------------------------------------------------- 1 | var Future = Npm.require('fibers/future'); 2 | Twilio = Npm.require('twilio'); 3 | 4 | SMS = {}; 5 | SMSTest = {}; 6 | 7 | var next_devmode_sms_id = 0; 8 | var output_stream = process.stdout; 9 | 10 | // Testing hooks 11 | SMSTest.overrideOutputStream = function (stream) { 12 | next_devmode_sms_id = 0; 13 | output_stream = stream; 14 | }; 15 | 16 | SMSTest.restoreOutputStream = function () { 17 | output_stream = process.stdout; 18 | }; 19 | 20 | var devModeSend = function (options) { 21 | var devmode_sms_id = next_devmode_sms_id++; 22 | 23 | var stream = output_stream; 24 | 25 | // This approach does not prevent other writers to stdout from interleaving. 26 | stream.write("====== BEGIN SMS #" + devmode_sms_id + " ======\n"); 27 | stream.write("(SMS not sent; to enable sending, set the TWILIO_CREDENTIALS " + 28 | "environment variable.)\n"); 29 | var future = new Future; 30 | stream.write("From:" + options.from + "\n"); 31 | stream.write("To:" + options.to + "\n"); 32 | stream.write("Text:" + options.body + "\n"); 33 | stream.write("====== END SMS #" + devmode_sms_id + " ======\n"); 34 | future['return'](); 35 | }; 36 | 37 | /** 38 | * Mock out sms sending (eg, during a test.) This is private for now. 39 | * 40 | * f receives the arguments to SMS.send and should return true to go 41 | * ahead and send the email (or at least, try subsequent hooks), or 42 | * false to skip sending. 43 | */ 44 | var sendHooks = []; 45 | SMSTest.hookSend = function (f) { 46 | sendHooks.push(f); 47 | }; 48 | 49 | /** 50 | * Send an sms. 51 | * 52 | * Connects to twilio via the CONFIG_VARS environment 53 | * variable. If unset, prints formatted message to stdout. The "from" option 54 | * is required, and at least one of "to", "from", and "body" must be provided; 55 | * all other options are optional. 56 | * 57 | * @param options 58 | * @param options.from {String} - The sending SMS number 59 | * @param options.to {String} - The receiver SMS number 60 | * @param options.body {String} - The content of the SMS 61 | */ 62 | SMS.send = function (options) { 63 | for (var i = 0; i < sendHooks.length; i++) 64 | if (!sendHooks[i](options)) 65 | return; 66 | if (SMS.twilio) { 67 | var client = Twilio(SMS.twilio.ACCOUNT_SID, SMS.twilio.AUTH_TOKEN); 68 | // Send SMS API async func 69 | var sendSMSSync = Meteor.wrapAsync(client.sendMessage, client); 70 | // call the sync version of our API func with the parameters from the method call 71 | var result = sendSMSSync(options, function (err, responseData) { //this function is executed when a response is received from Twilio 72 | if (err) { // "err" is an error received during the request, if any 73 | throw new Meteor.Error("Error sending SMS ", err); 74 | } 75 | return responseData; 76 | }); 77 | 78 | return result; 79 | } else { 80 | devModeSend(options); 81 | } 82 | }; 83 | 84 | SMS.phoneTemplates = { 85 | from: '+972545999999', 86 | text: function (user, code, context) { 87 | return 'Welcome your invitation code is: ' + code + ' ' + context; 88 | } 89 | }; 90 | 91 | -------------------------------------------------------------------------------- /phone_tests_setup.js: -------------------------------------------------------------------------------- 1 | 2 | // Phone test vars setup 3 | Accounts._options.verificationWaitTime = 1; 4 | Accounts._options.verificationRetriesWaitTime = 1; 5 | Accounts._options.verificationMaxRetries = 100; 6 | 7 | Accounts.validateNewUser(function (user) { 8 | if (user.profile && user.profile.invalidAndThrowException) { 9 | throw new Meteor.Error(403, "An exception thrown within Accounts.validateNewUser"); 10 | } 11 | return !(user.profile && user.profile.invalid); 12 | }); 13 | 14 | Accounts.onCreateUser(function (options, user) { 15 | if (options.testOnCreateUserHook) { 16 | user.profile = user.profile || {}; 17 | user.profile.touchedByOnCreateUser = true; 18 | return user; 19 | } else { 20 | return 'TEST DEFAULT HOOK'; 21 | } 22 | }); 23 | 24 | 25 | // connection id -> action 26 | var invalidateLogins = {}; 27 | 28 | 29 | Meteor.methods({ 30 | testInvalidateLogins: function (action) { 31 | if (action) 32 | invalidateLogins[this.connection.id] = action; 33 | else 34 | delete invalidateLogins[this.connection.id]; 35 | } 36 | }); 37 | 38 | 39 | Accounts.validateLoginAttempt(function (attempt) { 40 | var action = 41 | attempt && 42 | attempt.connection && 43 | invalidateLogins[attempt.connection.id]; 44 | 45 | if (! action) 46 | return true; 47 | else if (action === 'fail') 48 | return false; 49 | else if (action === 'hide') 50 | throw new Meteor.Error(403, 'hide actual error'); 51 | else 52 | throw new Error('unknown action: ' + action); 53 | }); 54 | 55 | 56 | // connection id -> [{successful: boolean, attempt: object}] 57 | var capturedLogins = {}; 58 | 59 | Meteor.methods({ 60 | testCaptureLogins: function () { 61 | capturedLogins[this.connection.id] = []; 62 | }, 63 | 64 | testFetchCapturedLogins: function () { 65 | if (capturedLogins[this.connection.id]) { 66 | var logins = capturedLogins[this.connection.id]; 67 | delete capturedLogins[this.connection.id]; 68 | return logins; 69 | } 70 | else 71 | return []; 72 | } 73 | }); 74 | 75 | Accounts.onLogin(function (attempt) { 76 | if (capturedLogins[attempt.connection.id]) 77 | capturedLogins[attempt.connection.id].push({ 78 | successful: true, 79 | attempt: _.omit(attempt, 'connection') 80 | }); 81 | }); 82 | 83 | Accounts.onLoginFailure(function (attempt) { 84 | if (capturedLogins[attempt.connection.id]) { 85 | capturedLogins[attempt.connection.id].push({ 86 | successful: false, 87 | attempt: _.omit(attempt, 'connection') 88 | }); 89 | } 90 | }); 91 | 92 | // Because this is global state that affects every client, we can't turn 93 | // it on and off during the tests. Doing so would mean two simultaneous 94 | // test runs could collide with each other. 95 | // 96 | // We should probably have some sort of server-isolation between 97 | // multiple test runs. Perhaps a separate server instance per run. This 98 | // problem isn't unique to this test, there are other places in the code 99 | // where we do various hacky things to work around the lack of 100 | // server-side isolation. 101 | // 102 | // For now, we just test the one configuration state. You can comment 103 | // out each configuration option and see that the tests fail. 104 | Accounts.config({ 105 | }); 106 | 107 | // Set send phone verification to true 108 | Accounts._options.sendPhoneVerificationCode = true; 109 | 110 | Meteor.methods({ 111 | testMeteorUser: function () { return Meteor.user(); }, 112 | clearPhonsAndProfile: function () { 113 | if (!this.userId) 114 | throw new Error("Not logged in!"); 115 | Meteor.users.update(this.userId, 116 | {$unset: {profile: 1, phone: 1}}); 117 | }, 118 | 119 | expireTokens: function () { 120 | Accounts._expireTokens(new Date(), this.userId); 121 | }, 122 | removeUser: function (phone) { 123 | Meteor.users.remove({ "phone.number": phone }); 124 | } 125 | }); 126 | 127 | 128 | // Create a user that had previously logged in with SRP. 129 | 130 | Meteor.methods({ 131 | testCreateSRPUser: function () { 132 | var phone = '+97254580'+ (Math.abs(Math.floor(Math.random() * 1000 - 1000)) + 1000); 133 | Meteor.users.remove({'phone.number': phone}); 134 | var userId = Accounts.createUserWithPhone({'phone.number': phone}); 135 | Meteor.users.update( 136 | userId, 137 | { '$set': { 'services.phone.srp': { 138 | "identity" : "iPNrshUEcpOSO5fRDu7o4RRDc9OJBCGGljYpcXCuyg9", 139 | "salt" : "Dk3lFggdEtcHU3aKm6Odx7sdcaIrMskQxBbqtBtFzt6", 140 | "verifier" : "2e8bce266b1357edf6952cc56d979db19f699ced97edfb2854b95972f820b0c7006c1a18e98aad40edf3fe111b87c52ef7dd06b320ce452d01376df2d560fdc4d8e74f7a97bca1f67b3cfaef34dee34dd6c76571c247d762624dc166dab5499da06bc9358528efa75bf74e2e7f5a80d09e60acf8856069ae5cfb080f2239ee76" 141 | } } } 142 | ); 143 | return username; 144 | }, 145 | 146 | testSRPUpgrade: function (phone) { 147 | var user = Meteor.users.findOne({'phone.number': phone}); 148 | if (user.services && user.services.phone && user.services.phone.srp) 149 | throw new Error("srp wasn't removed"); 150 | if (!(user.services && user.services.phone && user.services.phone.bcrypt)) 151 | throw new Error("bcrypt wasn't added"); 152 | }, 153 | 154 | testNoSRPUpgrade: function (phone) { 155 | var user = Meteor.users.findOne({'phone.number': phone}); 156 | if (user.services && user.services.phone && user.services.phone.bcrypt) 157 | throw new Error("bcrypt was added"); 158 | if (user.services && user.services.phone && ! user.services.phone.srp) 159 | throw new Error("srp was removed"); 160 | } 161 | }); -------------------------------------------------------------------------------- /phone_client.js: -------------------------------------------------------------------------------- 1 | // Attempt to log in with phone and password. 2 | // 3 | // @param selector {String|Object} One of the following: 4 | // - {phone: (phone)} 5 | // @param password {String} 6 | // @param callback {Function(error|undefined)} 7 | 8 | 9 | /** 10 | * @summary Log the user in with a password. 11 | * @locus Client 12 | * @param {Object | String} user Either a string interpreted as a phone; 13 | * or an object with a single key: `phone` or `id`. 14 | * @param {String} password The user's password. 15 | * @param {Function} [callback] Optional callback. Called with no arguments on success, 16 | * or with a single `Error` argument on failure. 17 | */ 18 | Meteor.loginWithPhoneAndPassword = function (selector, password, callback) { 19 | if (typeof selector === 'string') 20 | selector = {phone: selector}; 21 | 22 | Accounts.callLoginMethod({ 23 | methodArguments: [ 24 | { 25 | user : selector, 26 | password: Accounts._hashPassword(password) 27 | } 28 | ], 29 | userCallback : function (error, result) { 30 | if (error && error.error === 400 && 31 | error.reason === 'old password format') { 32 | // The "reason" string should match the error thrown in the 33 | // password login handler in password_server.js. 34 | 35 | // XXX COMPAT WITH 0.8.1.3 36 | // If this user's last login was with a previous version of 37 | // Meteor that used SRP, then the server throws this error to 38 | // indicate that we should try again. The error includes the 39 | // user's SRP identity. We provide a value derived from the 40 | // identity and the password to prove to the server that we know 41 | // the password without requiring a full SRP flow, as well as 42 | // SHA256(password), which the server bcrypts and stores in 43 | // place of the old SRP information for this user. 44 | srpUpgradePath({ 45 | upgradeError : error, 46 | userSelector : selector, 47 | plaintextPassword: password 48 | }, callback); 49 | } 50 | else if (error) { 51 | callback && callback(error); 52 | } else { 53 | callback && callback(); 54 | } 55 | } 56 | }); 57 | }; 58 | 59 | Accounts._hashPassword = function (password) { 60 | return { 61 | digest : SHA256(password), 62 | algorithm: "sha-256" 63 | }; 64 | }; 65 | 66 | // XXX COMPAT WITH 0.8.1.3 67 | // The server requested an upgrade from the old SRP password format, 68 | // so supply the needed SRP identity to login. Options: 69 | // - upgradeError: the error object that the server returned to tell 70 | // us to upgrade from SRP to bcrypt. 71 | // - userSelector: selector to retrieve the user object 72 | // - plaintextPassword: the password as a string 73 | var srpUpgradePath = function (options, callback) { 74 | var details; 75 | try { 76 | details = EJSON.parse(options.upgradeError.details); 77 | } catch (e) {} 78 | if (!(details && details.format === 'srp')) { 79 | callback && callback( 80 | new Meteor.Error(400, "Password is old. Please reVerify phone again")); 81 | } else { 82 | Accounts.callLoginMethod({ 83 | methodArguments: [ 84 | { 85 | user : options.userSelector, 86 | srp : SHA256(details.identity + ":" + options.plaintextPassword), 87 | password: Accounts._hashPassword(options.plaintextPassword) 88 | } 89 | ], 90 | userCallback : callback 91 | }); 92 | } 93 | }; 94 | 95 | // Attempt to log in as a new user. 96 | 97 | /** 98 | * @summary Create a new user with phone. 99 | * @locus Anywhere 100 | * @param {Object} options 101 | * @param {String} options.phone The user's full phone number. 102 | * @param {String} options.password, Optional -- (optional) The user's password. This is __not__ sent in plain text over the wire. 103 | * @param {Object} options.profile The user's profile, typically including the `name` field. 104 | * @param {Function} [callback] Client only, optional callback. Called with no arguments on success, or with a single `Error` argument on failure. 105 | */ 106 | Accounts.createUserWithPhone = function (options, callback) { 107 | options = _.clone(options); // we'll be modifying options 108 | 109 | // If no password was given create random one 110 | if (typeof options.password !== 'string' || !options.password) { 111 | options.password = Math.random().toString(36).slice(-8); 112 | } 113 | 114 | // Replace password with the hashed password. 115 | options.password = Accounts._hashPassword(options.password); 116 | 117 | Accounts.callLoginMethod({ 118 | methodName : 'createUserWithPhone', 119 | methodArguments: [options], 120 | userCallback : callback 121 | }); 122 | }; 123 | 124 | 125 | // Sends an sms to a user with a code to verify his number. 126 | // 127 | // @param phone: (phone) 128 | // @param callback (optional) {Function(error|undefined)} 129 | 130 | /** 131 | * @summary Request a new verification code. 132 | * @locus Client 133 | * @param {String} phone - The phone we send the verification code to. 134 | * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure. 135 | */ 136 | Accounts.requestPhoneVerification = function (phone, callback) { 137 | if (!phone) 138 | throw new Error("Must pass phone"); 139 | Accounts.connection.call("requestPhoneVerification", phone, callback); 140 | }; 141 | 142 | // Verify phone number - 143 | // Based on a code ( received by SMS ) originally created by 144 | // Accounts.verifyPhone, optionally change password and then logs in the matching user. 145 | // 146 | // @param code {String} 147 | // @param newPassword (optional) {String} 148 | // @param callback (optional) {Function(error|undefined)} 149 | 150 | /** 151 | * @summary Marks the user's phone as verified. Optional change passwords, Logs the user in afterwards.. 152 | * @locus Client 153 | * @param {String} phone - The phone number we want to verify. 154 | * @param {String} code - The code retrieved in the SMS. 155 | * @param {String} newPassword, Optional, A new password for the user. This is __not__ sent in plain text over the wire. 156 | * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure. 157 | */ 158 | Accounts.verifyPhone = function (phone, code, newPassword, callback) { 159 | check(code, String); 160 | check(phone, String); 161 | 162 | var hashedPassword; 163 | 164 | if (newPassword) { 165 | // If didn't gave newPassword and only callback was given 166 | if (typeof(newPassword) === 'function') { 167 | callback = newPassword; 168 | } else { 169 | check(newPassword, String); 170 | hashedPassword = Accounts._hashPassword(newPassword); 171 | } 172 | } 173 | Accounts.callLoginMethod({ 174 | methodName : 'verifyPhone', 175 | methodArguments: [phone, code, hashedPassword], 176 | userCallback : callback}); 177 | }; 178 | 179 | /** 180 | * Returns whether the current user phone is verified 181 | * @returns {boolean} Whether the user phone is verified 182 | */ 183 | Accounts.isPhoneVerified = function () { 184 | var me = Meteor.user(); 185 | return !!(me && me.phone && me.phone.verified); 186 | }; 187 | 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Accounts-Phone 2 | ========================= 3 | 4 | Accounts-Phone is a Meteor package that let you authenticate by phone number. 5 | The package use SMS code verification to verify the user account. 6 | The package is based and inspired by Meteor Accounts-Password package. 7 | 8 | ## Installation 9 | 10 | In a Meteor app directory, enter: 11 | 12 | ``` 13 | $ meteor add okland:accounts-phone 14 | ``` 15 | 16 | 17 | ## Examples 18 | 19 | Let's say you want to register new user and verify him using his phone number 20 | 21 | Verify phone number - Create user if not exists 22 | 23 | ```js 24 | var userPhone = '+972545999999'; 25 | // Request for sms phone verification -- please note before receiving SMS you should Follow the SMS Integration tutorial below 26 | Accounts.requestPhoneVerification(userPhone, function(){}); 27 | //Debug: Verify the user phone isn't confirmed it. 28 | console.log('Phone verification status is :', Accounts.isPhoneVerified()); 29 | 30 | // After receiving SMS let user enter his code and verify account by sending it to the server 31 | var verificationCode = 'CodeRecivedBySMS'; 32 | 33 | Accounts.verifyPhone(userPhone, verificationCode, function(){}); 34 | //Debug: Verify the user phone is confirmed. 35 | console.log('Phone verification status is :', Accounts.isPhoneVerified()); 36 | ``` 37 | 38 | ## SMS Integration 39 | 40 | If you are using twilio : 41 | you can just put your twilio credentials on server. 42 | ```js 43 | SMS.twilio = {ACCOUNT_SID: 'XXXXXXXXXXXXXXXXXXXXX', AUTH_TOKEN: 'XXXXXXXXXXXXXXXXXXXX'}; 44 | ``` 45 | 46 | otherwise you can just override the function 47 | ```js 48 | SMS.send = function (options) {}; 49 | ``` 50 | Where the parameter options is an object containing : 51 | * @param options 52 | * @param options.from {String} - The sending SMS number 53 | * @param options.to {String} - The receiver SMS number 54 | * @param options.body {String} - The content of the SMS 55 | 56 | Moreover to control the Sending number and the message content you can override the phone Template 57 | 58 | ```js 59 | SMS.phoneTemplates = { 60 | from: '+9729999999', 61 | text: function (user, code) { 62 | return 'Welcome your invitation code is: ' + code; 63 | } 64 | }; 65 | ``` 66 | 67 | * Note: it can only be done on server 68 | 69 | 70 | ## Simple API 71 | ```js 72 | 73 | /** 74 | * @summary Request a new verification code. create user if not exist 75 | * @locus Client 76 | * @param {String} phone - The phone we send the verification code to. 77 | * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure. 78 | */ 79 | Accounts.requestPhoneVerification = function (phone, callback) { }; 80 | 81 | /** 82 | * @summary Marks the user's phone as verified. Optional change passwords, Logs the user in afterwards.. 83 | * @locus Client 84 | * @param {String} phone - The phone number we want to verify. 85 | * @param {String} code - The code retrieved in the SMS. 86 | * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure. 87 | */ 88 | Accounts.verifyPhone = function (phone, code, callback) {...}; 89 | 90 | 91 | /** 92 | * Returns whether the current user phone is verified 93 | * @returns {boolean} Whether the user phone is verified 94 | */ 95 | Accounts.isPhoneVerified = function () { }; 96 | 97 | ``` 98 | 99 | ## Settings - you can control 100 | 101 | - verificationCodeLength : The length of the verification code 102 | - verificationMaxRetries : The number of SMS verification tries before verification temporary lock 103 | - verificationRetriesWaitTime : The verification lock time after max retries 104 | - verificationWaitTime : The verification lock time if between two retries 105 | - sendPhoneVerificationCodeOnCreation : Whether to send phone number verification on user creation 106 | - forbidClientAccountCreation: Don't let client create user on server 107 | - phoneVerificationMasterCode: Optional master code if exists let user verify account by entering this code for example '1234' 108 | - adminPhoneNumbers: Optional array of admin phone numbers - don't need to be valid phone numbers for example ['+972123456789', '+972987654321'] 109 | 110 | 111 | In order to change those settings just override the property under : 112 | 113 | Accounts._options 114 | 115 | For example to change the verificationMaxRetries to 3 all we need to do is: 116 | ```js 117 | Accounts._options.verificationMaxRetries = 3; 118 | ``` 119 | 120 | 121 | ## More code samples 122 | 123 | 124 | Creating new user 125 | ```js 126 | // Create a user. 127 | 128 | var options = {phone:'+972545999999'}; 129 | // You can also create user with password 130 | options.password = 'VeryHardPassword'; 131 | 132 | 133 | Accounts.createUserWithPhone(options, function (){}); 134 | // Debug: Verify the user phone isn't confirmed it. 135 | console.log('Phone verification status is :', Accounts.isPhoneVerified()); 136 | ``` 137 | 138 | ```js 139 | var userPhone = '+972545999999'; 140 | // Request for sms phone verification -- please note before receiving SMS you should Follow the SMS Integration tutorial below 141 | Accounts.requestPhoneVerification(userPhone, function(){}); 142 | //Debug: Verify the user phone isn't confirmed it. 143 | console.log('Phone verification status is :', Accounts.isPhoneVerified()); 144 | 145 | // After receiving SMS let user enter his code and verify account by sending it to the server 146 | var verificationCode = 'CodeRecivedBySMS'; 147 | var newPassword = null; 148 | // You can keep your old password by sending null in the password field 149 | Accounts.verifyPhone(userPhone, verificationCode, function(){}); 150 | //Debug: Verify the user phone is confirmed. 151 | console.log('Phone verification status is :', Accounts.isPhoneVerified()); 152 | ``` 153 | 154 | Login existing user - Requires creating user with password 155 | 156 | 157 | ```js 158 | 159 | var userPhone = '+972545999999'; 160 | var password = 'VerySecure'; 161 | var callback = function() {}; 162 | Accounts.createUserWithPhone({phone:userPhone, password:password}, function (){}); 163 | 164 | Meteor.loginWithPhoneAndPassword({phone:userPhone}, password, callback); 165 | ``` 166 | 167 | ## Full API 168 | ```js 169 | 170 | /** 171 | * @summary Log the user in with a password. 172 | * @locus Client 173 | * @param {Object | String} user Either a string interpreted as a phone; or an object with a single key: `phone` or `id`. 174 | * @param {String} password The user's password. 175 | * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure. 176 | */ 177 | Meteor.loginWithPhoneAndPassword = function (selector, password, callback) { }; 178 | 179 | /** 180 | * @summary Create a new user. 181 | * @locus Anywhere 182 | * @param {Object} options 183 | * @param {String} options.phone The user's full phone number. 184 | * @param {String} options.password The user's password. This is __not__ sent in plain text over the wire. 185 | * @param {Object} options.profile The user's profile, typically including the `name` field. 186 | * @param {Function} [callback] Client only, optional callback. Called with no arguments on success, or with a single `Error` argument on failure. 187 | */ 188 | Accounts.createUserWithPhone = function (options, callback) { }; 189 | 190 | /** 191 | * @summary Request a new verification code. 192 | * @locus Client 193 | * @param {String} phone - The phone we send the verification code to. 194 | * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure. 195 | */ 196 | Accounts.requestPhoneVerification = function (phone, callback) { }; 197 | 198 | /** 199 | * @summary Marks the user's phone as verified. Optional change passwords, Logs the user in afterwards.. 200 | * @locus Client 201 | * @param {String} phone - The phone number we want to verify. 202 | * @param {String} code - The code retrieved in the SMS. 203 | * @param {String} newPassword, Optional, A new password for the user. This is __not__ sent in plain text over the wire. 204 | * @param {Function} [callback] Optional callback. Called with no arguments on success, or with a single `Error` argument on failure. 205 | */ 206 | Accounts.verifyPhone = function (phone, code, newPassword, callback) {...}; 207 | 208 | 209 | /** 210 | * Returns whether the current user phone is verified 211 | * @returns {boolean} Whether the user phone is verified 212 | */ 213 | Accounts.isPhoneVerified = function () { }; 214 | 215 | 216 | /** 217 | * @summary Register a callback to be called after a phone verification attempt succeeds. 218 | * @locus Server 219 | * @param {Function} func The callback to be called when phone verification is successful. 220 | * Function gets the userId of the new verified user as first argument 221 | */ 222 | Accounts.onPhoneVerification = function (func) { }; 223 | ``` 224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /phone_server.js: -------------------------------------------------------------------------------- 1 | /// Default Accounts Config vars 2 | 3 | var AccountGlobalConfigs = { 4 | verificationRetriesWaitTime : 10 * 60 * 1000, 5 | verificationWaitTime : 20 * 1000, 6 | verificationCodeLength : 4, 7 | verificationMaxRetries : 2, 8 | forbidClientAccountCreation : false, 9 | sendPhoneVerificationCodeOnCreation: true 10 | }; 11 | 12 | _.defaults(Accounts._options, AccountGlobalConfigs); 13 | 14 | 15 | /// Phone 16 | 17 | var Phone = Npm.require('phone'); 18 | 19 | /// BCRYPT 20 | 21 | var bcrypt = NpmModuleBcrypt; 22 | var bcryptHash = Meteor.wrapAsync(bcrypt.hash); 23 | var bcryptCompare = Meteor.wrapAsync(bcrypt.compare); 24 | 25 | // User records have a 'services.phone.bcrypt' field on them to hold 26 | // their hashed passwords (unless they have a 'services.phone.srp' 27 | // field, in which case they will be upgraded to bcrypt the next time 28 | // they log in). 29 | // 30 | // When the client sends a password to the server, it can either be a 31 | // string (the plaintext password) or an object with keys 'digest' and 32 | // 'algorithm' (must be "sha-256" for now). The Meteor client always sends 33 | // password objects { digest: *, algorithm: "sha-256" }, but DDP clients 34 | // that don't have access to SHA can just send plaintext passwords as 35 | // strings. 36 | // 37 | // When the server receives a plaintext password as a string, it always 38 | // hashes it with SHA256 before passing it into bcrypt. When the server 39 | // receives a password as an object, it asserts that the algorithm is 40 | // "sha-256" and then passes the digest to bcrypt. 41 | 42 | Accounts._bcryptRounds = 10; 43 | 44 | // Given a 'password' from the client, extract the string that we should 45 | // bcrypt. 'password' can be one of: 46 | // - String (the plaintext password) 47 | // - Object with 'digest' and 'algorithm' keys. 'algorithm' must be "sha-256". 48 | // 49 | var getPasswordString = function (password) { 50 | if (typeof password === "string") { 51 | password = SHA256(password); 52 | } else { // 'password' is an object 53 | if (password.algorithm !== "sha-256") { 54 | throw new Error("Invalid password hash algorithm. " + 55 | "Only 'sha-256' is allowed."); 56 | } 57 | password = password.digest; 58 | } 59 | return password; 60 | }; 61 | 62 | // Use bcrypt to hash the password for storage in the database. 63 | // `password` can be a string (in which case it will be run through 64 | // SHA256 before bcrypt) or an object with properties `digest` and 65 | // `algorithm` (in which case we bcrypt `password.digest`). 66 | // 67 | var hashPassword = function (password) { 68 | password = getPasswordString(password); 69 | return bcryptHash(password, Accounts._bcryptRounds); 70 | }; 71 | 72 | // Check whether the provided password matches the bcrypt'ed password in 73 | // the database user record. `password` can be a string (in which case 74 | // it will be run through SHA256 before bcrypt) or an object with 75 | // properties `digest` and `algorithm` (in which case we bcrypt 76 | // `password.digest`). 77 | // 78 | Accounts._checkPhonePassword = function (user, password) { 79 | var result = { 80 | userId: user._id 81 | }; 82 | 83 | password = getPasswordString(password); 84 | 85 | if (!bcryptCompare(password, user.services.phone.bcrypt)) { 86 | result.error = new Meteor.Error(403, "Incorrect password"); 87 | } 88 | 89 | return result; 90 | }; 91 | var checkPassword = Accounts._checkPhonePassword; 92 | 93 | /// 94 | /// LOGIN 95 | /// 96 | 97 | // Users can specify various keys to identify themselves with. 98 | // @param user {Object} with `id` or `phone`. 99 | // @returns A selector to pass to mongo to get the user record. 100 | 101 | var selectorFromUserQuery = function (user) { 102 | if (user.id) 103 | return {_id: user.id}; 104 | else if (user.phone) 105 | return {'phone.number': user.phone}; 106 | throw new Error("shouldn't happen (validation missed something)"); 107 | }; 108 | 109 | var findUserFromUserQuery = function (user) { 110 | var selector = selectorFromUserQuery(user); 111 | 112 | var user = Meteor.users.findOne(selector); 113 | if (!user) 114 | throw new Meteor.Error(403, "User not found"); 115 | 116 | return user; 117 | }; 118 | 119 | // XXX maybe this belongs in the check package 120 | var NonEmptyString = Match.Where(function (x) { 121 | check(x, String); 122 | return x.length > 0; 123 | }); 124 | 125 | var userQueryValidator = Match.Where(function (user) { 126 | check(user, { 127 | id : Match.Optional(NonEmptyString), 128 | phone: Match.Optional(NonEmptyString) 129 | }); 130 | if (_.keys(user).length !== 1) 131 | throw new Match.Error("User property must have exactly one field"); 132 | return true; 133 | }); 134 | 135 | var passwordValidator = Match.OneOf( 136 | String, 137 | { digest: String, algorithm: String } 138 | ); 139 | 140 | // Handler to login with a phone. 141 | // 142 | // The Meteor client sets options.password to an object with keys 143 | // 'digest' (set to SHA256(password)) and 'algorithm' ("sha-256"). 144 | // 145 | // For other DDP clients which don't have access to SHA, the handler 146 | // also accepts the plaintext password in options.password as a string. 147 | // 148 | // (It might be nice if servers could turn the plaintext password 149 | // option off. Or maybe it should be opt-in, not opt-out? 150 | // Accounts.config option?) 151 | // 152 | // Note that neither password option is secure without SSL. 153 | // 154 | Accounts.registerLoginHandler("phone", function (options) { 155 | if (!options.password || options.srp) 156 | return undefined; // don't handle 157 | 158 | check(options, { 159 | user : userQueryValidator, 160 | password: passwordValidator 161 | }); 162 | 163 | var user = findUserFromUserQuery(options.user); 164 | 165 | if (!user.services || !user.services.phone || !(user.services.phone.bcrypt || user.services.phone.srp)) 166 | throw new Meteor.Error(403, "User has no password set"); 167 | 168 | if (!user.services.phone.bcrypt) { 169 | if (typeof options.password === "string") { 170 | // The client has presented a plaintext password, and the user is 171 | // not upgraded to bcrypt yet. We don't attempt to tell the client 172 | // to upgrade to bcrypt, because it might be a standalone DDP 173 | // client doesn't know how to do such a thing. 174 | var verifier = user.services.phone.srp; 175 | var newVerifier = SRP.generateVerifier(options.password, { 176 | identity: verifier.identity, salt: verifier.salt}); 177 | 178 | if (verifier.verifier !== newVerifier.verifier) { 179 | return { 180 | userId: user._id, 181 | error : new Meteor.Error(403, "Incorrect password") 182 | }; 183 | } 184 | 185 | return {userId: user._id}; 186 | } else { 187 | // Tell the client to use the SRP upgrade process. 188 | throw new Meteor.Error(400, "old password format", EJSON.stringify({ 189 | format : 'srp', 190 | identity: user.services.phone.srp.identity 191 | })); 192 | } 193 | } 194 | 195 | return checkPassword( 196 | user, 197 | options.password 198 | ); 199 | }); 200 | 201 | // Handler to login using the SRP upgrade path. To use this login 202 | // handler, the client must provide: 203 | // - srp: H(identity + ":" + password) 204 | // - password: a string or an object with properties 'digest' and 'algorithm' 205 | // 206 | // We use `options.srp` to verify that the client knows the correct 207 | // password without doing a full SRP flow. Once we've checked that, we 208 | // upgrade the user to bcrypt and remove the SRP information from the 209 | // user document. 210 | // 211 | // The client ends up using this login handler after trying the normal 212 | // login handler (above), which throws an error telling the client to 213 | // try the SRP upgrade path. 214 | // 215 | // XXX COMPAT WITH 0.8.1.3 216 | Accounts.registerLoginHandler("phone", function (options) { 217 | if (!options.srp || !options.password) 218 | return undefined; // don't handle 219 | 220 | check(options, { 221 | user : userQueryValidator, 222 | srp : String, 223 | password: passwordValidator 224 | }); 225 | 226 | var user = findUserFromUserQuery(options.user); 227 | 228 | // Check to see if another simultaneous login has already upgraded 229 | // the user record to bcrypt. 230 | if (user.services && user.services.phone && 231 | user.services.phone.bcrypt) 232 | return checkPassword(user, options.password); 233 | 234 | if (!(user.services && user.services.phone 235 | && user.services.phone.srp)) 236 | throw new Meteor.Error(403, "User has no password set"); 237 | 238 | var v1 = user.services.phone.srp.verifier; 239 | var v2 = SRP.generateVerifier( 240 | null, 241 | { 242 | hashedIdentityAndPassword: options.srp, 243 | salt : user.services.phone.srp.salt 244 | } 245 | ).verifier; 246 | if (v1 !== v2) 247 | return { 248 | userId: user._id, 249 | error : new Meteor.Error(403, "Incorrect password") 250 | }; 251 | 252 | // Upgrade to bcrypt on successful login. 253 | var salted = hashPassword(options.password); 254 | Meteor.users.update( 255 | user._id, 256 | { 257 | $unset: { 'services.phone.srp': 1 }, 258 | $set : { 'services.phone.bcrypt': salted } 259 | } 260 | ); 261 | 262 | return {userId: user._id}; 263 | }); 264 | 265 | // Force change the users phone password. 266 | 267 | /** 268 | * @summary Forcibly change the password for a user. 269 | * @locus Server 270 | * @param {String} userId The id of the user to update. 271 | * @param {String} newPassword A new password for the user. 272 | */ 273 | Accounts.setPhonePassword = function (userId, newPlaintextPassword) { 274 | var user = Meteor.users.findOne(userId); 275 | if (!user) 276 | throw new Meteor.Error(403, "User not found"); 277 | 278 | Meteor.users.update( 279 | {_id: user._id}, 280 | { 281 | $unset: { 282 | 'services.phone.srp' : 1, // XXX COMPAT WITH 0.8.1.3 283 | 'services.phone.verify' : 1, 284 | 'services.resume.loginTokens': 1 285 | }, 286 | $set : {'services.phone.bcrypt': hashPassword(newPlaintextPassword)} } 287 | ); 288 | }; 289 | 290 | /// 291 | /// Send phone VERIFICATION code 292 | /// 293 | 294 | // send the user a sms with a code that can be used to verify number 295 | 296 | /** 297 | * @summary Send an SMS with a code the user can use verify their phone number with. 298 | * @locus Server 299 | * @param {String} userId The id of the user to send email to. 300 | * @param {String} [phone] Optional. Which phone of the user's to send the SMS to. This phone must be in the user's `phones` list. Defaults to the first unverified phone in the list. 301 | */ 302 | Accounts.sendPhoneVerificationCode = function (userId, phone, context) { 303 | // XXX Also generate a link using which someone can delete this 304 | // account if they own said number but weren't those who created 305 | // this account. 306 | 307 | // Make sure the user exists, and phone is one of their phones. 308 | var user = Meteor.users.findOne(userId); 309 | if (!user) 310 | throw new Error("Can't find user"); 311 | // pick the first unverified phone if we weren't passed an phone. 312 | if (!phone && user.phone) { 313 | phone = user.phone && user.phone.number; 314 | } 315 | // make sure we have a valid phone 316 | if (!phone) 317 | throw new Error("No such phone for user."); 318 | 319 | // If sent more than max retry wait 320 | var waitTimeBetweenRetries = Accounts._options.verificationWaitTime; 321 | var maxRetryCounts = Accounts._options.verificationMaxRetries; 322 | 323 | var verifyObject = {numOfRetries: 0}; 324 | if (user.services && user.services.phone && user.services.phone.verify) { 325 | verifyObject = user.services.phone.verify; 326 | } 327 | 328 | var curTime = new Date(); 329 | // Check if last retry was too soon 330 | var nextRetryDate = verifyObject && verifyObject.lastRetry && new Date(verifyObject.lastRetry.getTime() + waitTimeBetweenRetries); 331 | if (nextRetryDate && nextRetryDate > curTime) { 332 | var waitTimeInSec = Math.ceil(Math.abs((nextRetryDate - curTime) / 1000)), 333 | errMsg = "Too often retries, try again in " + waitTimeInSec + " seconds."; 334 | // TODO: fix retry limits 335 | //throw new Error(errMsg); 336 | } 337 | // Check if there where too many retries 338 | if (verifyObject.numOfRetries > maxRetryCounts) { 339 | // Check if passed enough time since last retry 340 | var waitTimeBetweenMaxRetries = Accounts._options.verificationRetriesWaitTime; 341 | nextRetryDate = new Date(verifyObject.lastRetry.getTime() + waitTimeBetweenMaxRetries); 342 | if (nextRetryDate > curTime) { 343 | var waitTimeInMin = Math.ceil(Math.abs((nextRetryDate - curTime) / 60000)), 344 | errMsg = "Too many retries, try again in " + waitTimeInMin + " minutes."; 345 | //throw new Error(errMsg); 346 | } 347 | } 348 | verifyObject.code = getRandomCode(Accounts._options.verificationCodeLength); 349 | verifyObject.phone = phone; 350 | verifyObject.lastRetry = curTime; 351 | verifyObject.numOfRetries++; 352 | 353 | Meteor.users.update( 354 | {_id: userId}, 355 | {$set: {'services.phone.verify': verifyObject}}); 356 | 357 | // before passing to template, update user object with new token 358 | Meteor._ensure(user, 'services', 'phone'); 359 | user.services.phone.verify = verifyObject; 360 | 361 | var options = { 362 | to : phone, 363 | from: SMS.phoneTemplates.from, 364 | body: SMS.phoneTemplates.text(user, verifyObject.code, context) 365 | }; 366 | 367 | try { 368 | SMS.send(options); 369 | } catch (e) { 370 | console.log('SMS Failed, Something bad happened!', e); 371 | } 372 | }; 373 | 374 | Meteor.methods({ 375 | attachCodeToUser: function(userId, phone){ 376 | var user = Meteor.users.findOne({_id: userId, 'phone.number': phone}); 377 | 378 | if (! user) { 379 | throw new Error("can't find user"); 380 | } 381 | 382 | var code = getRandomCode(Accounts._options.verificationCodeLength); 383 | 384 | var verifyObject = { 385 | code: code, 386 | phone: phone, 387 | lastRetry: new Date(), 388 | numOfRetries: 0, 389 | }; 390 | 391 | Meteor.users.update({_id: userId}, {$set: { 392 | 'services.phone.verify': verifyObject 393 | }}); 394 | 395 | return code; 396 | } 397 | }); 398 | 399 | // Send SMS with code to user. 400 | Meteor.methods({requestPhoneVerification: function (phone) { 401 | if (phone) { 402 | check(phone, String); 403 | // Change phone format to international SMS format 404 | phone = normalizePhone(phone); 405 | } 406 | 407 | if (!phone) { 408 | throw new Meteor.Error(403, "Not a valid phone"); 409 | } 410 | 411 | var userId = this.userId; 412 | if (!userId) { 413 | // Get user by phone number 414 | var existingUser = Meteor.users.findOne({'phone.number': phone}, {fields: {'_id': 1}}); 415 | if (existingUser) { 416 | userId = existingUser && existingUser._id; 417 | } else { 418 | // Create new user with phone number 419 | userId = createUser({phone:phone}); 420 | } 421 | } 422 | Accounts.sendPhoneVerificationCode(userId, phone); 423 | }}); 424 | 425 | // Take code from sendVerificationPhone SMS, mark the phone as verified, 426 | // Change password if needed 427 | // and log them in. 428 | Meteor.methods({verifyPhone: function (phone, code, newPassword) { 429 | var self = this; 430 | // Check if needs to change password 431 | 432 | return Accounts._loginMethod( 433 | self, 434 | "verifyPhone", 435 | arguments, 436 | "phone", 437 | function () { 438 | check(code, String); 439 | check(phone, String); 440 | 441 | if (!code) { 442 | throw new Meteor.Error(403, "Code is must be provided to method"); 443 | } 444 | 445 | var user = Meteor.users.findOne({ 446 | "phone.number": phone 447 | }); 448 | if (!user) 449 | throw new Meteor.Error(403, "Not a valid phone"); 450 | 451 | // Verify code is accepted or master code 452 | if (!user.services.phone || !user.services.phone.verify || !user.services.phone.verify.code || 453 | (user.services.phone.verify.code != code && !isMasterCode(code))) { 454 | throw new Meteor.Error(403, "Not a valid code"); 455 | } 456 | 457 | var setOptions = {'phone.verified': true}, 458 | unSetOptions = {'services.phone.verify': 1}; 459 | 460 | // If needs to update password 461 | if (newPassword) { 462 | check(newPassword, passwordValidator); 463 | var hashed = hashPassword(newPassword); 464 | 465 | // NOTE: We're about to invalidate tokens on the user, who we might be 466 | // logged in as. Make sure to avoid logging ourselves out if this 467 | // happens. But also make sure not to leave the connection in a state 468 | // of having a bad token set if things fail. 469 | var oldToken = Accounts._getLoginToken(self.connection.id); 470 | Accounts._setLoginToken(user._id, self.connection, null); 471 | var resetToOldToken = function () { 472 | Accounts._setLoginToken(user._id, self.connection, oldToken); 473 | }; 474 | 475 | setOptions['services.phone.bcrypt'] = hashed; 476 | unSetOptions['services.phone.srp'] = 1; 477 | } 478 | 479 | try { 480 | var query = { 481 | _id : user._id, 482 | 'phone.number' : phone, 483 | 'services.phone.verify.code': code 484 | }; 485 | // Allow master code from settings 486 | if (isMasterCode(code)) { 487 | delete query['services.phone.verify.code']; 488 | } 489 | // Update the user record by: 490 | // - Changing the password to the new one 491 | // - Forgetting about the verification code that was just used 492 | // - Verifying the phone, since they got the code via sms to phone. 493 | var affectedRecords = Meteor.users.update( 494 | query, 495 | {$set : setOptions, 496 | $unset: unSetOptions}); 497 | if (affectedRecords !== 1) 498 | return { 499 | userId: user._id, 500 | error : new Meteor.Error(403, "Invalid phone") 501 | }; 502 | successfulVerification(user._id); 503 | } catch (err) { 504 | resetToOldToken(); 505 | throw err; 506 | } 507 | 508 | // Replace all valid login tokens with new ones (changing 509 | // password should invalidate existing sessions). 510 | Accounts._clearAllLoginTokens(user._id); 511 | 512 | return {userId: user._id}; 513 | } 514 | ); 515 | }}); 516 | 517 | /// 518 | /// CREATING USERS 519 | /// 520 | 521 | // Shared createUser function called from the createUser method, both 522 | // if originates in client or server code. Calls user provided hooks, 523 | // does the actual user insertion. 524 | // 525 | // returns the user id 526 | var createUser = function (options) { 527 | // Unknown keys allowed, because a onCreateUserHook can take arbitrary 528 | // options. 529 | check(options, Match.ObjectIncluding({ 530 | phone : Match.Optional(String), 531 | password: Match.Optional(passwordValidator) 532 | })); 533 | 534 | var phone = options.phone; 535 | if (!phone) 536 | throw new Meteor.Error(400, "Need to set phone"); 537 | 538 | var existingUser = Meteor.users.findOne( 539 | {'phone.number': phone}); 540 | 541 | if (existingUser) { 542 | throw new Meteor.Error(403, "User with this phone number already exists"); 543 | } 544 | 545 | var user = {services: {}}; 546 | if (options.password) { 547 | var hashed = hashPassword(options.password); 548 | user.services.phone = { bcrypt: hashed }; 549 | } 550 | 551 | user.phone = {number: phone, verified: false}; 552 | 553 | try { 554 | return Accounts.insertUserDoc(options, user); 555 | } catch (e) { 556 | 557 | // XXX string parsing sucks, maybe 558 | // https://jira.mongodb.org/browse/SERVER-3069 will get fixed one day 559 | if (e.name !== 'MongoError') throw e; 560 | var match = e.err.match(/E11000 duplicate key error index: ([^ ]+)/); 561 | if (!match) throw e; 562 | if (match[1].indexOf('users.$phone.number') !== -1) 563 | throw new Meteor.Error(403, "Phone number already exists, failed on creation."); 564 | throw e; 565 | } 566 | }; 567 | 568 | // method for create user. Requests come from the client. 569 | Meteor.methods({createUserWithPhone: function (options) { 570 | var self = this; 571 | 572 | check(options, Object); 573 | if (options.phone) { 574 | check(options.phone, String); 575 | } 576 | 577 | return Accounts._loginMethod( 578 | self, 579 | "createUserWithPhone", 580 | arguments, 581 | "phone", 582 | function () { 583 | if (Accounts._options.forbidClientAccountCreation) 584 | return { 585 | error: new Meteor.Error(403, "Signups forbidden") 586 | }; 587 | 588 | // Create user. result contains id and token. 589 | var userId = createUser(options); 590 | // safety belt. createUser is supposed to throw on error. send 500 error 591 | // instead of sending a verification email with empty userid. 592 | if (!userId) 593 | throw new Error("createUser failed to insert new user"); 594 | 595 | // client gets logged in as the new user afterwards. 596 | return {userId: userId}; 597 | } 598 | ); 599 | }}); 600 | 601 | // Create user directly on the server. 602 | // 603 | // Unlike the client version, this does not log you in as this user 604 | // after creation. 605 | // 606 | // returns userId or throws an error if it can't create 607 | // 608 | // XXX add another argument ("server options") that gets sent to onCreateUser, 609 | // which is always empty when called from the createUser method? eg, "admin: 610 | // true", which we want to prevent the client from setting, but which a custom 611 | // method calling Accounts.createUser could set? 612 | // 613 | Accounts.createUserWithPhone = function (options, callback) { 614 | options = _.clone(options); 615 | 616 | // XXX allow an optional callback? 617 | if (callback) { 618 | throw new Error("Accounts.createUser with callback not supported on the server yet."); 619 | } 620 | 621 | return createUser(options); 622 | }; 623 | 624 | /// 625 | /// PASSWORD-SPECIFIC INDEXES ON USERS 626 | /// 627 | Meteor.users._ensureIndex('phone.number', 628 | {unique: 1, sparse: 1}); 629 | Meteor.users._ensureIndex('services.phone.verify.code', 630 | {unique: 1, sparse: 1}); 631 | 632 | /*** Control published data *********/ 633 | Meteor.startup(function () { 634 | /** Publish phones to the client **/ 635 | Meteor.publish(null, function () { 636 | if (this.userId) { 637 | return Meteor.users.find({_id: this.userId}, 638 | {fields: {'phone': 1}}); 639 | } else { 640 | this.ready(); 641 | } 642 | }); 643 | 644 | /** Disable user profile editing **/ 645 | Meteor.users.deny({ 646 | update: function () { 647 | return true; 648 | } 649 | }); 650 | }); 651 | 652 | /************* Phone verification hook *************/ 653 | 654 | // Callback exceptions are printed with Meteor._debug and ignored. 655 | var onPhoneVerificationHook = new Hook({ 656 | debugPrintExceptions: "onPhoneVerification callback" 657 | }); 658 | 659 | /** 660 | * @summary Register a callback to be called after a phone verification attempt succeeds. 661 | * @locus Server 662 | * @param {Function} func The callback to be called when phone verification is successful. 663 | */ 664 | Accounts.onPhoneVerification = function (func) { 665 | return onPhoneVerificationHook.register(func); 666 | }; 667 | 668 | var successfulVerification = function (userId) { 669 | onPhoneVerificationHook.each(function (callback) { 670 | callback(userId); 671 | return true; 672 | }); 673 | }; 674 | 675 | // Give each login hook callback a fresh cloned copy of the attempt 676 | // object, but don't clone the connection. 677 | // 678 | var cloneAttemptWithConnection = function (connection, attempt) { 679 | var clonedAttempt = EJSON.clone(attempt); 680 | clonedAttempt.connection = connection; 681 | return clonedAttempt; 682 | }; 683 | /************* Helper functions ********************/ 684 | 685 | // Return normalized phone format 686 | var normalizePhone = function (phone) { 687 | // If phone equals to one of admin phone numbers return it as-is 688 | if (phone && Accounts._options.adminPhoneNumbers && Accounts._options.adminPhoneNumbers.indexOf(phone) != -1) { 689 | return phone; 690 | } 691 | return Phone(phone)[0]; 692 | }; 693 | 694 | /** 695 | * Check whether the given code is the defined master code 696 | * @param code 697 | * @returns {*|boolean} 698 | */ 699 | var isMasterCode = function (code) { 700 | return code && Accounts._options.phoneVerificationMasterCode && 701 | code == Accounts._options.phoneVerificationMasterCode; 702 | } 703 | 704 | /** 705 | * Get random phone verification code 706 | * @param length 707 | * @returns {string} 708 | */ 709 | var getRandomCode = function (length) { 710 | length = length || 4; 711 | var output = ""; 712 | while (length-- > 0) { 713 | 714 | output += getRandomDigit(); 715 | } 716 | return output; 717 | } 718 | 719 | /** 720 | * Return random 1-9 digit 721 | * @returns {number} 722 | */ 723 | var getRandomDigit = function () { 724 | return Math.floor((Math.random() * 9) + 1); 725 | } 726 | 727 | -------------------------------------------------------------------------------- /phone_tests.js: -------------------------------------------------------------------------------- 1 | Accounts._noConnectionCloseDelayForTest = true; 2 | 3 | if (Meteor.isServer) { 4 | Meteor.methods({ 5 | getUserId : function () { 6 | return this.userId; 7 | }, 8 | getVerificationCode: function () { 9 | var code = Meteor.users.findOne(this.userId).services.phone.verify.code; 10 | return code; 11 | } 12 | }); 13 | } 14 | 15 | if (Meteor.isClient) (function () { 16 | 17 | // XXX note, only one test can do login/logout things at once! for 18 | // now, that is this test. 19 | 20 | Accounts._isolateLoginTokenForTest(); 21 | 22 | var logoutStep = function (test, expect) { 23 | Meteor.logout(expect(function (error) { 24 | test.equal(error, undefined); 25 | test.equal(Meteor.user(), null); 26 | })); 27 | }; 28 | var loggedInAs = function (somePhone, test, expect) { 29 | return expect(function (error) { 30 | test.equal(error, undefined); 31 | var user = Meteor.user(); 32 | test.equal(user.phone.number, somePhone); 33 | }); 34 | }; 35 | var waitForLoggedOutStep = function (test, expect) { 36 | pollUntil(expect, function () { 37 | return Meteor.userId() === null; 38 | }, 10 * 1000, 100); 39 | }; 40 | var invalidateLoginsStep = function (test, expect) { 41 | Meteor.call("testInvalidateLogins", 'fail', expect(function (error) { 42 | test.isFalse(error); 43 | })); 44 | }; 45 | var hideActualLoginErrorStep = function (test, expect) { 46 | Meteor.call("testInvalidateLogins", 'hide', expect(function (error) { 47 | test.isFalse(error); 48 | })); 49 | }; 50 | var validateLoginsStep = function (test, expect) { 51 | Meteor.call("testInvalidateLogins", false, expect(function (error) { 52 | test.isFalse(error); 53 | })); 54 | }; 55 | 56 | testAsyncMulti("phones - basic login with password", [ 57 | function (test, expect) { 58 | // setup 59 | this.phone = '+97254580' + (Math.abs(Math.floor(Math.random() * 1000 - 1000)) + 1000); 60 | this.password = 'password'; 61 | 62 | Accounts.createUserWithPhone( 63 | {phone: this.phone, password: this.password}, 64 | loggedInAs(this.phone, test, expect)); 65 | }, 66 | function (test, expect) { 67 | test.notEqual(Meteor.userId(), null); 68 | }, 69 | logoutStep, 70 | function (test, expect) { 71 | Meteor.loginWithPhoneAndPassword(this.phone, this.password, 72 | loggedInAs(this.phone, test, expect)); 73 | }, 74 | logoutStep, 75 | // This next step tests reactive contexts which are reactive on 76 | // Meteor.user(). 77 | function (test, expect) { 78 | // Set up a reactive context that only refreshes when Meteor.user() is 79 | // invalidated. 80 | var loaded = false; 81 | var handle = Tracker.autorun(function () { 82 | if (Meteor.user() && Meteor.user().phone) 83 | loaded = true; 84 | }); 85 | // At the beginning, we're not logged in. 86 | test.isFalse(loaded); 87 | Meteor.loginWithPhoneAndPassword(this.phone, this.password, expect(function (error) { 88 | test.equal(error, undefined); 89 | test.notEqual(Meteor.userId(), null); 90 | // By the time of the login callback, the user should be loaded. 91 | test.isTrue(Meteor.user().phone); 92 | // Flushing should get us the rerun as well. 93 | Tracker.flush(); 94 | test.isTrue(loaded); 95 | handle.stop(); 96 | })); 97 | }, 98 | logoutStep, 99 | function (test, expect) { 100 | Meteor.loginWithPhoneAndPassword({phone: this.phone}, this.password, 101 | loggedInAs(this.phone, test, expect)); 102 | }, 103 | logoutStep 104 | ]); 105 | 106 | testAsyncMulti("passwords - plain text passwords", [ 107 | function (test, expect) { 108 | // setup 109 | this.phone = '+97254580' + (Math.abs(Math.floor(Math.random() * 1000 - 1000)) + 1000); 110 | this.password = 'password'; 111 | 112 | // create user with raw password (no API, need to invoke callLoginMethod 113 | // directly) 114 | Accounts.callLoginMethod({ 115 | methodName : 'createUserWithPhone', 116 | methodArguments: [ 117 | {phone: this.phone, password: this.password} 118 | ], 119 | userCallback : loggedInAs(this.phone, test, expect) 120 | }); 121 | }, 122 | logoutStep, 123 | // check can login normally with this password. 124 | function (test, expect) { 125 | Meteor.loginWithPhoneAndPassword({phone: this.phone}, this.password, 126 | loggedInAs(this.phone, test, expect)); 127 | }, 128 | logoutStep, 129 | // plain text password. no API for this, have to invoke callLoginMethod 130 | // directly. 131 | function (test, expect) { 132 | Accounts.callLoginMethod({ 133 | // wrong password 134 | methodArguments: [ 135 | {user: {phone: this.phone}, password: 'wrong'} 136 | ], 137 | userCallback : expect(function (error) { 138 | test.isTrue(error); 139 | test.isFalse(Meteor.user()); 140 | })}); 141 | }, 142 | function (test, expect) { 143 | Accounts.callLoginMethod({ 144 | // right password 145 | methodArguments: [ 146 | {user : {phone: this.phone}, 147 | password: this.password} 148 | ], 149 | userCallback : loggedInAs(this.phone, test, expect) 150 | }); 151 | }, 152 | logoutStep 153 | ]); 154 | 155 | // 156 | // testAsyncMulti("passwords - changing passwords", [ 157 | // function (test, expect) { 158 | // // setup 159 | // this.phone = '+97254580'+ Math.floor(Math.random() * 1000 - 1000) + 1000; 160 | // this.password = 'password'; 161 | // this.password2 = 'password2'; 162 | // 163 | // Accounts.createUser( 164 | // {phone: this.phone, password: this.password}, 165 | // loggedInAs(this.phone, test, expect)); 166 | // }, 167 | // // Send a password reset email so that we can test that password 168 | // // reset tokens get deleted on password change. 169 | // function (test, expect) { 170 | // Meteor.call("forgotPassword", { email: this.email }, expect(function (error) { 171 | // test.isFalse(error); 172 | // })); 173 | // }, 174 | // function (test, expect) { 175 | // var self = this; 176 | // Meteor.call("getResetToken", expect(function (err, token) { 177 | // test.isFalse(err); 178 | // test.isTrue(token); 179 | // self.token = token; 180 | // })); 181 | // }, 182 | // // change password with bad old password. we stay logged in. 183 | // function (test, expect) { 184 | // var self = this; 185 | // Accounts.changePassword('wrong', 'doesntmatter', expect(function (error) { 186 | // test.isTrue(error); 187 | // test.equal(Meteor.user().username, self.username); 188 | // })); 189 | // }, 190 | // // change password with good old password. 191 | // function (test, expect) { 192 | // Accounts.changePassword(this.password, this.password2, 193 | // loggedInAs(this.username, test, expect)); 194 | // }, 195 | // function (test, expect) { 196 | // Meteor.call("getResetToken", expect(function (err, token) { 197 | // test.isFalse(err); 198 | // test.isFalse(token); 199 | // })); 200 | // }, 201 | // logoutStep, 202 | // // old password, failed login 203 | // function (test, expect) { 204 | // Meteor.loginWithPassword(this.email, this.password, expect(function (error) { 205 | // test.isTrue(error); 206 | // test.isFalse(Meteor.user()); 207 | // })); 208 | // }, 209 | // // new password, success 210 | // function (test, expect) { 211 | // Meteor.loginWithPassword(this.email, this.password2, 212 | // loggedInAs(this.username, test, expect)); 213 | // }, 214 | // logoutStep 215 | // ]); 216 | // 217 | // testAsyncMulti("passwords - changing password logs out other clients", [ 218 | // function (test, expect) { 219 | // this.username = Random.id(); 220 | // this.email = Random.id() + '-intercept@example.com'; 221 | // this.password = 'password'; 222 | // this.password2 = 'password2'; 223 | // Accounts.createUser( 224 | // { username: this.username, email: this.email, password: this.password }, 225 | // loggedInAs(this.username, test, expect)); 226 | // }, 227 | // // Log in a second connection as this user. 228 | // function (test, expect) { 229 | // var self = this; 230 | // 231 | // self.secondConn = DDP.connect(Meteor.absoluteUrl()); 232 | // self.secondConn.call('login', 233 | // { user: { username: self.username }, password: self.password }, 234 | // expect(function (err, result) { 235 | // test.isFalse(err); 236 | // self.secondConn.setUserId(result.id); 237 | // test.isTrue(self.secondConn.userId()); 238 | // 239 | // self.secondConn.onReconnect = function () { 240 | // self.secondConn.apply( 241 | // 'login', 242 | // [{ resume: result.token }], 243 | // { wait: true }, 244 | // function (err, result) { 245 | // self.secondConn.setUserId(result && result.id || null); 246 | // } 247 | // ); 248 | // }; 249 | // })); 250 | // }, 251 | // function (test, expect) { 252 | // var self = this; 253 | // Accounts.changePassword(self.password, self.password2, expect(function (err) { 254 | // test.isFalse(err); 255 | // })); 256 | // }, 257 | // // Now that we've changed the password, wait until the second 258 | // // connection gets logged out. 259 | // function (test, expect) { 260 | // var self = this; 261 | // pollUntil(expect, function () { 262 | // return self.secondConn.userId() === null; 263 | // }, 10 * 1000, 100); 264 | // } 265 | // ]); 266 | // 267 | // 268 | // testAsyncMulti("passwords - new user hooks", [ 269 | // function (test, expect) { 270 | // // setup 271 | // this.username = Random.id(); 272 | // this.email = Random.id() + '-intercept@example.com'; 273 | // this.password = 'password'; 274 | // }, 275 | // // test Accounts.validateNewUser 276 | // function(test, expect) { 277 | // Accounts.createUser( 278 | // {username: this.username, password: this.password, 279 | // // should fail the new user validators 280 | // profile: {invalid: true}}, 281 | // expect(function (error) { 282 | // test.equal(error.error, 403); 283 | // test.equal(error.reason, "User validation failed"); 284 | // })); 285 | // }, 286 | // logoutStep, 287 | // function(test, expect) { 288 | // Accounts.createUser( 289 | // {username: this.username, password: this.password, 290 | // // should fail the new user validator with a special 291 | // // exception 292 | // profile: {invalidAndThrowException: true}}, 293 | // expect(function (error) { 294 | // test.equal( 295 | // error.reason, 296 | // "An exception thrown within Accounts.validateNewUser"); 297 | // })); 298 | // }, 299 | // // test Accounts.onCreateUser 300 | // function(test, expect) { 301 | // Accounts.createUser( 302 | // {username: this.username, password: this.password, 303 | // testOnCreateUserHook: true}, 304 | // loggedInAs(this.username, test, expect)); 305 | // }, 306 | // function(test, expect) { 307 | // test.equal(Meteor.user().profile.touchedByOnCreateUser, true); 308 | // }, 309 | // logoutStep 310 | // ]); 311 | // 312 | // 313 | // testAsyncMulti("passwords - Meteor.user()", [ 314 | // function (test, expect) { 315 | // // setup 316 | // this.username = Random.id(); 317 | // this.password = 'password'; 318 | // 319 | // Accounts.createUser( 320 | // {username: this.username, password: this.password, 321 | // testOnCreateUserHook: true}, 322 | // loggedInAs(this.username, test, expect)); 323 | // }, 324 | // // test Meteor.user(). This test properly belongs in 325 | // // accounts-base/accounts_tests.js, but this is where the tests that 326 | // // actually log in are. 327 | // function(test, expect) { 328 | // var self = this; 329 | // var clientUser = Meteor.user(); 330 | // Accounts.connection.call('testMeteorUser', expect(function (err, result) { 331 | // test.equal(result._id, clientUser._id); 332 | // test.equal(result.username, clientUser.username); 333 | // test.equal(result.username, self.username); 334 | // test.equal(result.profile.touchedByOnCreateUser, true); 335 | // test.equal(err, undefined); 336 | // })); 337 | // }, 338 | // function(test, expect) { 339 | // // Test that even with no published fields, we still have a document. 340 | // Accounts.connection.call('clearUsernameAndProfile', expect(function() { 341 | // test.isTrue(Meteor.userId()); 342 | // var user = Meteor.user(); 343 | // test.equal(user, {_id: Meteor.userId()}); 344 | // })); 345 | // }, 346 | // logoutStep, 347 | // function(test, expect) { 348 | // var clientUser = Meteor.user(); 349 | // test.equal(clientUser, null); 350 | // test.equal(Meteor.userId(), null); 351 | // Accounts.connection.call('testMeteorUser', expect(function (err, result) { 352 | // test.equal(err, undefined); 353 | // test.equal(result, null); 354 | // })); 355 | // } 356 | // ]); 357 | // 358 | // testAsyncMulti("passwords - allow rules", [ 359 | // // create a second user to have an id for in a later test 360 | // function (test, expect) { 361 | // this.otherUsername = Random.id(); 362 | // Accounts.createUser( 363 | // {username: this.otherUsername, password: 'dontcare', 364 | // testOnCreateUserHook: true}, 365 | // loggedInAs(this.otherUsername, test, expect)); 366 | // }, 367 | // function (test, expect) { 368 | // this.otherUserId = Meteor.userId(); 369 | // }, 370 | // function (test, expect) { 371 | // // real setup 372 | // this.username = Random.id(); 373 | // this.password = 'password'; 374 | // 375 | // Accounts.createUser( 376 | // {username: this.username, password: this.password, 377 | // testOnCreateUserHook: true}, 378 | // loggedInAs(this.username, test, expect)); 379 | // }, 380 | // // test the default Meteor.users allow rule. This test properly belongs in 381 | // // accounts-base/accounts_tests.js, but this is where the tests that 382 | // // actually log in are. 383 | // function(test, expect) { 384 | // this.userId = Meteor.userId(); 385 | // test.notEqual(this.userId, null); 386 | // test.notEqual(this.userId, this.otherUserId); 387 | // // Can't update fields other than profile. 388 | // Meteor.users.update( 389 | // this.userId, {$set: {disallowed: true, 'profile.updated': 42}}, 390 | // expect(function (err) { 391 | // test.isTrue(err); 392 | // test.equal(err.error, 403); 393 | // test.isFalse(_.has(Meteor.user(), 'disallowed')); 394 | // test.isFalse(_.has(Meteor.user().profile, 'updated')); 395 | // })); 396 | // }, 397 | // function(test, expect) { 398 | // // Can't update another user. 399 | // Meteor.users.update( 400 | // this.otherUserId, {$set: {'profile.updated': 42}}, 401 | // expect(function (err) { 402 | // test.isTrue(err); 403 | // test.equal(err.error, 403); 404 | // })); 405 | // }, 406 | // function(test, expect) { 407 | // // Can't update using a non-ID selector. (This one is thrown client-side.) 408 | // test.throws(function () { 409 | // Meteor.users.update( 410 | // {username: this.username}, {$set: {'profile.updated': 42}}); 411 | // }); 412 | // test.isFalse(_.has(Meteor.user().profile, 'updated')); 413 | // }, 414 | // function(test, expect) { 415 | // // Can update own profile using ID. 416 | // Meteor.users.update( 417 | // this.userId, {$set: {'profile.updated': 42}}, 418 | // expect(function (err) { 419 | // test.isFalse(err); 420 | // test.equal(42, Meteor.user().profile.updated); 421 | // })); 422 | // }, 423 | // logoutStep 424 | // ]); 425 | // 426 | // 427 | // testAsyncMulti("passwords - tokens", [ 428 | // function (test, expect) { 429 | // // setup 430 | // this.username = Random.id(); 431 | // this.password = 'password'; 432 | // 433 | // Accounts.createUser( 434 | // {username: this.username, password: this.password}, 435 | // loggedInAs(this.username, test, expect)); 436 | // }, 437 | // 438 | // function (test, expect) { 439 | // // we can't login with an invalid token 440 | // var expectLoginError = expect(function (err) { 441 | // test.isTrue(err); 442 | // }); 443 | // Meteor.loginWithToken('invalid', expectLoginError); 444 | // }, 445 | // 446 | // function (test, expect) { 447 | // // we can login with a valid token 448 | // var expectLoginOK = expect(function (err) { 449 | // test.isFalse(err); 450 | // }); 451 | // Meteor.loginWithToken(Accounts._storedLoginToken(), expectLoginOK); 452 | // }, 453 | // 454 | // function (test, expect) { 455 | // // test logging out invalidates our token 456 | // var expectLoginError = expect(function (err) { 457 | // test.isTrue(err); 458 | // }); 459 | // var token = Accounts._storedLoginToken(); 460 | // test.isTrue(token); 461 | // Meteor.logout(function () { 462 | // Meteor.loginWithToken(token, expectLoginError); 463 | // }); 464 | // }, 465 | // 466 | // function (test, expect) { 467 | // var self = this; 468 | // // Test that login tokens get expired. We should get logged out when a 469 | // // token expires, and not be able to log in again with the same token. 470 | // var expectNoError = expect(function (err) { 471 | // test.isFalse(err); 472 | // }); 473 | // 474 | // Meteor.loginWithPassword(this.username, this.password, function (error) { 475 | // self.token = Accounts._storedLoginToken(); 476 | // test.isTrue(self.token); 477 | // expectNoError(error); 478 | // Accounts.connection.call("expireTokens"); 479 | // }); 480 | // }, 481 | // waitForLoggedOutStep, 482 | // function (test, expect) { 483 | // var token = Accounts._storedLoginToken(); 484 | // test.isFalse(token); 485 | // }, 486 | // function (test, expect) { 487 | // // Test that once expireTokens is finished, we can't login again with our 488 | // // previous token. 489 | // Meteor.loginWithToken(this.token, expect(function (err, result) { 490 | // test.isTrue(err); 491 | // test.equal(Meteor.userId(), null); 492 | // })); 493 | // }, 494 | // logoutStep, 495 | // function (test, expect) { 496 | // var self = this; 497 | // // Test that Meteor.logoutOtherClients logs out a second 498 | // // authentcated connection while leaving Accounts.connection 499 | // // logged in. 500 | // var secondConn = DDP.connect(Meteor.absoluteUrl()); 501 | // var token; 502 | // 503 | // var expectSecondConnLoggedOut = expect(function (err, result) { 504 | // test.isTrue(err); 505 | // }); 506 | // 507 | // var expectAccountsConnLoggedIn = expect(function (err, result) { 508 | // test.isFalse(err); 509 | // }); 510 | // 511 | // var expectSecondConnLoggedIn = expect(function (err, result) { 512 | // test.equal(result.token, token); 513 | // test.isFalse(err); 514 | // Meteor.logoutOtherClients(function (err) { 515 | // test.isFalse(err); 516 | // secondConn.call('login', { resume: token }, 517 | // expectSecondConnLoggedOut); 518 | // Accounts.connection.call('login', { 519 | // resume: Accounts._storedLoginToken() 520 | // }, expectAccountsConnLoggedIn); 521 | // }); 522 | // }); 523 | // 524 | // Meteor.loginWithPassword( 525 | // self.username, 526 | // self.password, 527 | // expect(function (err) { 528 | // test.isFalse(err); 529 | // token = Accounts._storedLoginToken(); 530 | // test.isTrue(token); 531 | // secondConn.call('login', { resume: token }, 532 | // expectSecondConnLoggedIn); 533 | // }) 534 | // ); 535 | // }, 536 | // logoutStep, 537 | // 538 | // // The tests below this point are for the deprecated 539 | // // `logoutOtherClients` method. 540 | // 541 | // function (test, expect) { 542 | // var self = this; 543 | // 544 | // // Test that Meteor.logoutOtherClients logs out a second authenticated 545 | // // connection while leaving Accounts.connection logged in. 546 | // var token; 547 | // self.secondConn = DDP.connect(Meteor.absoluteUrl()); 548 | // 549 | // var expectLoginError = expect(function (err) { 550 | // test.isTrue(err); 551 | // }); 552 | // var expectValidToken = expect(function (err, result) { 553 | // test.isFalse(err); 554 | // test.isTrue(result); 555 | // self.tokenFromLogoutOthers = result.token; 556 | // }); 557 | // var expectSecondConnLoggedIn = expect(function (err, result) { 558 | // test.equal(result.token, token); 559 | // test.isFalse(err); 560 | // // This test will fail if an unrelated reconnect triggers before the 561 | // // connection is logged out. In general our tests aren't resilient to 562 | // // mid-test reconnects. 563 | // self.secondConn.onReconnect = function () { 564 | // self.secondConn.call("login", { resume: token }, expectLoginError); 565 | // }; 566 | // Accounts.connection.call("logoutOtherClients", expectValidToken); 567 | // }); 568 | // 569 | // Meteor.loginWithPassword(this.username, this.password, expect(function (err) { 570 | // test.isFalse(err); 571 | // token = Accounts._storedLoginToken(); 572 | // self.beforeLogoutOthersToken = token; 573 | // test.isTrue(token); 574 | // self.secondConn.call("login", { resume: token }, 575 | // expectSecondConnLoggedIn); 576 | // })); 577 | // }, 578 | // // Test that logoutOtherClients logged out Accounts.connection and that the 579 | // // previous token is no longer valid. 580 | // waitForLoggedOutStep, 581 | // function (test, expect) { 582 | // var self = this; 583 | // var token = Accounts._storedLoginToken(); 584 | // test.isFalse(token); 585 | // this.secondConn.close(); 586 | // Meteor.loginWithToken( 587 | // self.beforeLogoutOthersToken, 588 | // expect(function (err) { 589 | // test.isTrue(err); 590 | // test.isFalse(Meteor.userId()); 591 | // }) 592 | // ); 593 | // }, 594 | // // Test that logoutOtherClients returned a new token that we can use to 595 | // // log in. 596 | // function (test, expect) { 597 | // var self = this; 598 | // Meteor.loginWithToken( 599 | // self.tokenFromLogoutOthers, 600 | // expect(function (err) { 601 | // test.isFalse(err); 602 | // test.isTrue(Meteor.userId()); 603 | // }) 604 | // ); 605 | // }, 606 | // logoutStep, 607 | // 608 | // 609 | // 610 | // function (test, expect) { 611 | // var self = this; 612 | // // Test that deleting a user logs out that user's connections. 613 | // Meteor.loginWithPassword(this.username, this.password, expect(function (err) { 614 | // test.isFalse(err); 615 | // Accounts.connection.call("removeUser", self.username); 616 | // })); 617 | // }, 618 | // waitForLoggedOutStep 619 | // ]); 620 | // 621 | // testAsyncMulti("passwords - validateLoginAttempt", [ 622 | // function (test, expect) { 623 | // this.username = Random.id(); 624 | // this.password = "password"; 625 | // 626 | // Accounts.createUser( 627 | // {username: this.username, password: this.password}, 628 | // loggedInAs(this.username, test, expect)); 629 | // }, 630 | // logoutStep, 631 | // invalidateLoginsStep, 632 | // function (test, expect) { 633 | // Meteor.loginWithPassword( 634 | // this.username, 635 | // this.password, 636 | // expect(function (error) { 637 | // test.isTrue(error); 638 | // test.equal(error.reason, "Login forbidden"); 639 | // }) 640 | // ); 641 | // }, 642 | // validateLoginsStep, 643 | // function (test, expect) { 644 | // Meteor.loginWithPassword( 645 | // "no such user", 646 | // "some password", 647 | // expect(function (error) { 648 | // test.isTrue(error); 649 | // test.equal(error.reason, 'User not found'); 650 | // }) 651 | // ); 652 | // }, 653 | // hideActualLoginErrorStep, 654 | // function (test, expect) { 655 | // Meteor.loginWithPassword( 656 | // "no such user", 657 | // "some password", 658 | // expect(function (error) { 659 | // test.isTrue(error); 660 | // test.equal(error.reason, 'hide actual error'); 661 | // }) 662 | // ); 663 | // }, 664 | // validateLoginsStep 665 | // ]); 666 | // 667 | // testAsyncMulti("passwords - onLogin hook", [ 668 | // function (test, expect) { 669 | // Meteor.call("testCaptureLogins", expect(function (error) { 670 | // test.isFalse(error); 671 | // })); 672 | // }, 673 | // function (test, expect) { 674 | // this.username = Random.id(); 675 | // this.password = "password"; 676 | // 677 | // Accounts.createUser( 678 | // {username: this.username, password: this.password}, 679 | // loggedInAs(this.username, test, expect)); 680 | // }, 681 | // function (test, expect) { 682 | // var self = this; 683 | // Meteor.call("testFetchCapturedLogins", expect(function (error, logins) { 684 | // test.isFalse(error); 685 | // test.equal(logins.length, 1); 686 | // var login = logins[0]; 687 | // test.isTrue(login.successful); 688 | // var attempt = login.attempt; 689 | // test.equal(attempt.type, "password"); 690 | // test.isTrue(attempt.allowed); 691 | // test.equal(attempt.methodName, "createUser"); 692 | // test.equal(attempt.methodArguments[0].username, self.username); 693 | // })); 694 | // } 695 | // ]); 696 | // 697 | // testAsyncMulti("passwords - onLoginFailed hook", [ 698 | // function (test, expect) { 699 | // this.username = Random.id(); 700 | // this.password = "password"; 701 | // 702 | // Accounts.createUser( 703 | // {username: this.username, password: this.password}, 704 | // loggedInAs(this.username, test, expect)); 705 | // }, 706 | // logoutStep, 707 | // function (test, expect) { 708 | // Meteor.call("testCaptureLogins", expect(function (error) { 709 | // test.isFalse(error); 710 | // })); 711 | // }, 712 | // function (test, expect) { 713 | // Meteor.loginWithPassword(this.username, "incorrect", expect(function (error) { 714 | // test.isTrue(error); 715 | // })); 716 | // }, 717 | // function (test, expect) { 718 | // Meteor.call("testFetchCapturedLogins", expect(function (error, logins) { 719 | // test.isFalse(error); 720 | // test.equal(logins.length, 1); 721 | // var login = logins[0]; 722 | // test.isFalse(login.successful); 723 | // var attempt = login.attempt; 724 | // test.equal(attempt.type, "password"); 725 | // test.isFalse(attempt.allowed); 726 | // test.equal(attempt.error.reason, "Incorrect password"); 727 | // })); 728 | // }, 729 | // function (test, expect) { 730 | // Meteor.call("testCaptureLogins", expect(function (error) { 731 | // test.isFalse(error); 732 | // })); 733 | // }, 734 | // function (test, expect) { 735 | // Meteor.loginWithPassword("no such user", "incorrect", expect(function (error) { 736 | // test.isTrue(error); 737 | // })); 738 | // }, 739 | // function (test, expect) { 740 | // Meteor.call("testFetchCapturedLogins", expect(function (error, logins) { 741 | // test.isFalse(error); 742 | // test.equal(logins.length, 1); 743 | // var login = logins[0]; 744 | // test.isFalse(login.successful); 745 | // var attempt = login.attempt; 746 | // test.equal(attempt.type, "password"); 747 | // test.isFalse(attempt.allowed); 748 | // test.equal(attempt.error.reason, "User not found"); 749 | // })); 750 | // } 751 | // ]); 752 | // 753 | // testAsyncMulti("passwords - srp to bcrypt upgrade", [ 754 | // logoutStep, 755 | // // Create user with old SRP credentials in the database. 756 | // function (test, expect) { 757 | // var self = this; 758 | // Meteor.call("testCreateSRPUser", expect(function (error, result) { 759 | // test.isFalse(error); 760 | // self.username = result; 761 | // })); 762 | // }, 763 | // // We are able to login with the old style credentials in the database. 764 | // function (test, expect) { 765 | // Meteor.loginWithPassword(this.username, 'abcdef', expect(function (error) { 766 | // test.isFalse(error); 767 | // })); 768 | // }, 769 | // function (test, expect) { 770 | // Meteor.call("testSRPUpgrade", this.username, expect(function (error) { 771 | // test.isFalse(error); 772 | // })); 773 | // }, 774 | // logoutStep, 775 | // // After the upgrade to bcrypt we're still able to login. 776 | // function (test, expect) { 777 | // Meteor.loginWithPassword(this.username, 'abcdef', expect(function (error) { 778 | // test.isFalse(error); 779 | // })); 780 | // }, 781 | // logoutStep, 782 | // function (test, expect) { 783 | // Meteor.call("removeUser", this.username, expect(function (error) { 784 | // test.isFalse(error); 785 | // })); 786 | // } 787 | // ]); 788 | // 789 | // testAsyncMulti("passwords - srp to bcrypt upgrade via password change", [ 790 | // logoutStep, 791 | // // Create user with old SRP credentials in the database. 792 | // function (test, expect) { 793 | // var self = this; 794 | // Meteor.call("testCreateSRPUser", expect(function (error, result) { 795 | // test.isFalse(error); 796 | // self.username = result; 797 | // })); 798 | // }, 799 | // // Log in with the plaintext password handler, which should NOT upgrade us to bcrypt. 800 | // function (test, expect) { 801 | // Accounts.callLoginMethod({ 802 | // methodName: "login", 803 | // methodArguments: [ { user: { username: this.username }, password: "abcdef" } ], 804 | // userCallback: expect(function (err) { 805 | // test.isFalse(err); 806 | // }) 807 | // }); 808 | // }, 809 | // function (test, expect) { 810 | // Meteor.call("testNoSRPUpgrade", this.username, expect(function (error) { 811 | // test.isFalse(error); 812 | // })); 813 | // }, 814 | // // Changing our password should upgrade us to bcrypt. 815 | // function (test, expect) { 816 | // Accounts.changePassword("abcdef", "abcdefg", expect(function (error) { 817 | // test.isFalse(error); 818 | // })); 819 | // }, 820 | // function (test, expect) { 821 | // Meteor.call("testSRPUpgrade", this.username, expect(function (error) { 822 | // test.isFalse(error); 823 | // })); 824 | // }, 825 | // // And after the upgrade we should be able to change our password again. 826 | // function (test, expect) { 827 | // Accounts.changePassword("abcdefg", "abcdef", expect(function (error) { 828 | // test.isFalse(error); 829 | // })); 830 | // }, 831 | // logoutStep 832 | // ]); 833 | })(); 834 | 835 | if (Meteor.isServer) (function () { 836 | 837 | Tinytest.add( 838 | 'passwords - setup more than one onCreateUserHook', 839 | function (test) { 840 | test.throws(function () { 841 | Accounts.onCreateUser(function () { 842 | }); 843 | }); 844 | }); 845 | 846 | // Tinytest.add( 847 | // 'passwords - createUser hooks', 848 | // function (test) { 849 | // var phone = '+97254580'+ (Math.abs(Math.floor(Math.random() * 1000 - 1000)) + 1000); 850 | // 851 | // test.throws(function () { 852 | // // should fail the new user validators 853 | // Accounts.createUser({phone: phone, profile: {invalid: true}}); 854 | // }); 855 | // 856 | // var userId = Accounts.createUser({phone: phone, 857 | // testOnCreateUserHook: true}); 858 | // 859 | // test.isTrue(userId); 860 | // var user = Meteor.users.findOne(userId); 861 | // test.equal(user.profile.touchedByOnCreateUser, true); 862 | // }); 863 | 864 | Tinytest.add( 865 | 'passwords - setPassword', 866 | function (test) { 867 | var phone = '+97254580' + (Math.abs(Math.floor(Math.random() * 1000 - 1000)) + 1000); 868 | 869 | var userId = Accounts.createUserWithPhone({phone: phone}); 870 | 871 | var user = Meteor.users.findOne(userId); 872 | // no services yet. 873 | test.equal(user.services.phone, undefined); 874 | 875 | // set a new password. 876 | Accounts.setPhonePassword(userId, 'new password'); 877 | user = Meteor.users.findOne(userId); 878 | var oldSaltedHash = user.services.phone.bcrypt; 879 | test.isTrue(oldSaltedHash); 880 | 881 | // Send a request for phone verification 882 | Accounts.sendPhoneVerificationCode(userId, phone); 883 | Accounts._insertLoginToken(userId, Accounts._generateStampedLoginToken()); 884 | test.isTrue(Meteor.users.findOne(userId).services.phone.verify); 885 | test.isTrue(Meteor.users.findOne(userId).services.resume.loginTokens); 886 | 887 | // reset with the same password, see we get a different salted hash 888 | Accounts.setPhonePassword(userId, 'new password'); 889 | user = Meteor.users.findOne(userId); 890 | var newSaltedHash = user.services.phone.bcrypt; 891 | test.isTrue(newSaltedHash); 892 | test.notEqual(oldSaltedHash, newSaltedHash); 893 | // No more tokens. 894 | test.isFalse(Meteor.users.findOne(userId).services.phone.verify); 895 | test.isFalse(Meteor.users.findOne(userId).services.resume.loginTokens); 896 | 897 | 898 | try { 899 | Accounts.createUserWithPhone({phone: phone}); 900 | } catch (e) { 901 | test.isTrue(e, 'Don\'t two users with same phone'); 902 | } 903 | // cleanup 904 | Meteor.users.remove(userId); 905 | }); 906 | 907 | // This test properly belongs in accounts-base/accounts_tests.js, but 908 | // this is where the tests that actually log in are. 909 | Tinytest.add('accounts - user() out of context', function (test) { 910 | // basic server context, no method. 911 | test.throws(function () { 912 | Meteor.user(); 913 | }); 914 | }); 915 | // 916 | // // XXX would be nice to test Accounts.config({forbidClientAccountCreation: true}) 917 | // 918 | // Tinytest.addAsync( 919 | // 'passwords - login token observes get cleaned up', 920 | // function (test, onComplete) { 921 | // var username = Random.id(); 922 | // Accounts.createUser({ 923 | // username: username, 924 | // password: 'password' 925 | // }); 926 | // 927 | // makeTestConnection( 928 | // test, 929 | // function (clientConn, serverConn) { 930 | // serverConn.onClose(function () { 931 | // test.isFalse(Accounts._getUserObserve(serverConn.id)); 932 | // onComplete(); 933 | // }); 934 | // var result = clientConn.call('login', { 935 | // user: {username: username}, 936 | // password: 'password' 937 | // }); 938 | // test.isTrue(result); 939 | // var token = Accounts._getAccountData(serverConn.id, 'loginToken'); 940 | // test.isTrue(token); 941 | // 942 | // // We poll here, instead of just checking `_getUserObserve` 943 | // // once, because the login method defers the creation of the 944 | // // observe, and setting up the observe yields, so we could end 945 | // // up here before the observe has been set up. 946 | // simplePoll( 947 | // function () { 948 | // return !! Accounts._getUserObserve(serverConn.id); 949 | // }, 950 | // function () { 951 | // test.isTrue(Accounts._getUserObserve(serverConn.id)); 952 | // clientConn.disconnect(); 953 | // }, 954 | // function () { 955 | // test.fail("timed out waiting for user observe for connection " + 956 | // serverConn.id); 957 | // onComplete(); 958 | // } 959 | // ); 960 | // }, 961 | // onComplete 962 | // ); 963 | // } 964 | // ); 965 | })(); --------------------------------------------------------------------------------