UNPKG

google-location-sharing

Version:
779 lines (758 loc) 27.8 kB
const request = require('request'); const cheerio = require('cheerio'); /** * Challenges map with challenges details. */ const challengesMap = [{ fragment: '/signin/challenge/az/', code: 'p', description: 'Cell phone verification', implementation: true }, { fragment: '/signin/challenge/totp/', code: 'a', description: 'Google Authenticator', implementation: false }, { fragment: '/signin/challenge/ipp/', code: 's', description: 'SMS or Voice call', implementation: false }, { fragment: '/signin/challenge/iap/', code: 's', description: 'SMS or Voice call', implementation: false }, { fragment: '/signin/challenge/sk/', code: 'k', description: 'Security key', implementation: false }]; let showStage = false; let showOwnerLocation = true; let googlePassword = ''; let stageData = {}; let minimalRequestTimeInterval = 60; let lastSharedLocation = null; let nextRequestAfter = null; let credentials = { email: '', authenticated: false, ownerId: '', ownerName: '', ownerShortname: '', ownerPhotoUrl: '', cookies: {} }; module.exports = { /** * Authentication status (true if authentication completed successfully). */ get authenticated() { return credentials['authenticated']; }, /** * Set authentication status to true to skip first verification step (stage 1) after set new cookies. * This can speed up data acquisition, but the credentials will not be checked. */ set authenticated(value) { credentials['authenticated'] = value === true; }, /** * Credentials status. True if 'login and password' or credentials are set. */ get credentialsSpecified() { return credentials['email'] !== '' && (googlePassword !== '' || credentials['cookies']); }, /** * Set Google account email. */ set googleEmail(value) { credentials['email'] = value; }, get googleEmail() { throw (new Error('Unauthorized')); }, /** * Set Google account password. */ set googlePassword(value) { googlePassword = value; }, get googlePassword() { throw (new Error('Unauthorized')); }, /** * Get last detected shared location from google map. */ get lastSharedLocation() { return lastSharedLocation; }, /** * Set debugging mode (stage number is visible in console before any error). */ set debug(state) { showStage = state; }, /** * Get actual cookies for google.com domain. */ get cookies() { return credentials['cookies']; }, /** * Set cookies for google.com domain. Authentication status is set to false (if you change cookies). */ set cookies(value) { credentials['cookies'] = value; credentials['authenticated'] = false; }, /** * Get owner id */ get ownerId() { return credentials['ownerId']; }, /** * Set owner Id (it is not possible to detect it form location file) */ set ownerId(value) { credentials['ownerId'] = value; }, /** * Get owner name */ get ownerName() { return credentials['ownerName']; }, /** * Set owner name (it is not possible to detect it form location file) */ set ownerName(value) { credentials['ownerName'] = value; }, /** * Get owner shortname */ get ownerShortname() { return credentials['ownerShortname']; }, /** * Set owner shortname (it is not possible to detect it form location file) */ set ownerShortname(value) { credentials['ownerShortname'] = value; }, /** * Get owner photo url */ get ownerPhotoUrl() { return credentials['ownerPhotoUrl']; }, /** * Set owner photo url (it is not possible to detect it form location file) */ set ownerPhotoUrl(value) { credentials['ownerPhotoUrl'] = value; }, /** * Get credentials for active user. * Credentials contains email, username, lint to picture and cookies. * You can save this data to authenticate on google without password next time (using cookies). */ get credentials() { return credentials; }, /** * If you set credentials before call authentication, you can skip most of authentication steps. * Cookies from credentials data will be used to verify user identity on google servers. */ set credentials(value) { credentials = value; }, /** * Requests should not be too frequent. * The time interval between each request are set to this minimum value in seconds (default 60). * If the request for location is call in shortest time, then the last stored location data is returned. */ get minimalRequestTimeInterval() { return minimalRequestTimeInterval; }, /** * Set minimal interval for location request to google maps. */ set minimalRequestTimeInterval(value) { minimalRequestTimeInterval = value; }, /** * The earliest time when a new value can be requested from google maps and returned as new result. */ get nextRequestAfter() { return nextRequestAfter || new Date(); }, /** * Load location data for authenticated user (not only for other users). */ get showOwnerLocation() { return showOwnerLocation; }, /** * If true (default), first item in location data will be active user location (if it is known to google). * If false, only shared location data for other users will be returned. */ set showOwnerLocation(value) { showOwnerLocation = value; }, /** * Reset account data. */ resetAccount() { credentials['email'] = ''; credentials['ownerId'] = ''; credentials['ownerName'] = ''; credentials['ownerShortname'] = ''; credentials['ownerPhotoUrl'] = ''; }, /** * Reset authentication status (clear cookies and set authenticated to false). * If you need cancel second step from 2FA, you can call this method to clear authentication data. */ resetAuthentication() { credentials['authenticated'] = false; credentials['cookies'] = {}; }, /** * Reset last location data. */ resetLocationData() { lastSharedLocation = null; }, /** * Reset credentials (login and password), authentication (cookies) and last location data. */ reset() { this.resetAccount(); this.resetAuthentication(); this.resetLocationData(); }, /** * Authenticate on google accounts service. */ async authenticate() { return new Promise((resolve, reject) => { connectStage1().then(() => { return connectStage2(); }, reject1 => { reject(reject1); return Promise.reject(); }).then(() => { return connectStage3(); }, reject2 => { reject(reject2); return Promise.reject(); }).then(() => { return connectStage4(); }, reject3 => { reject(reject3); return Promise.reject(); }).then(() => { return connectStage5(); }, reject4 => { reject(reject4); return Promise.reject(); }).then((data) => { if (data['authenticated']) { googlePassword = ''; } return resolve(data['authenticated']); }, reject5 => { reject(reject5); }); }); }, /** * Get shared location from google map. * Try to authenicate if is not authenticated already. */ async getLocations() { if (lastSharedLocation && nextRequestAfter && nextRequestAfter > new Date()) { return Promise.resolve(lastSharedLocation); } return new Promise((resolve, reject) => { this.authenticate().then(loggedIn => { if (!loggedIn) { reject('Not authenticated'); return Promise.reject(); } return getSharedLocations(); }) .then(data => { lastSharedLocation = data; return resolve(data); }) .catch(failure => { return reject(failure); }); }); } } /** * Compose cokies object for the header. */ function getCookies() { if (!credentials['cookies']) { return {}; } let cookieStr = ''; for (var cookie in credentials['cookies']) { cookieStr += cookie + '=' + credentials['cookies'][cookie] + ';' } return { "Cookie": cookieStr.slice(0, -1) }; } /** * Save cookies as key value. */ function setCookies(cookies, clearFirst = false) { if (clearFirst) { credentials['cookies'] = {}; } if (!cookies) { return; } cookies.forEach(cookie => { const [ key, value, ] = cookie.split(';')[0].split('='); credentials['cookies'][key] = value; }); } /** * Check if cookie is defined. */ function checkCookie(cookieKey) { return credentials['cookies'][cookieKey] !== undefined; } /** * Open intial page and get GAPS cookie. * Extract login form. */ function connectStage1() { const stage = 'stage 1'; if (credentials['authenticated'] || stageData['twoFactorConfirmation']) { return Promise.resolve(credentials); } return new Promise((resolve, reject) => { request({ url: "https://accounts.google.com/ServiceLogin", headers: { ...getCookies(), "Upgrade-Insecure-Requeste": "1", "Connection": "keep-alive" }, method: "GET", qs: { "rip": "1", "nojavascript": "1", "flowName": "GlifWebSignIn", "flowEntry": "ServiceLogin" }, followRedirect: false }, function (err, response, body) { if (err || !response) { if (showStage) { console.warn(err, stage + ' request/response error'); } return reject(new Error('Request error')); } if (response.statusCode === 302 && checkCookie('SID')) { credentials['authenticated'] = true; return resolve(credentials); } if (response.statusCode !== 200) { if (showStage) { console.warn(response, stage + ' status code ' + response.statusCode); } return reject(new Error('Response not OK')); } if (response.hasOwnProperty('headers') && response.headers.hasOwnProperty('set-cookie')) { setCookies(response.headers['set-cookie'], true); } else { if (showStage) { console.warn(response.headers, stage + ' missing set-cookie header'); } return reject(new Error('No cookies')); } const $ = cheerio.load(response.body); const error = $('.error-msg').text().trim(); if (error) { if (showStage) { console.warn(error, stage + ' error message on webpage'); } return reject(new Error('Error on webpage')); } const googleEmailForm = $("form").serializeArray() .reduce((r, x) => Object.assign({}, r, { [x.name]: x.value, }), {}); if (!googleEmailForm) { console.info('If logins are being blocked, try to allow it here: https://accounts.google.com/b/0/DisplayUnlockCaptcha'); return reject(new Error('Missing login form')); } stageData['form'] = googleEmailForm; return resolve(credentials); }); }); } /** * Get GAPS and GALX cookies. Now we have the Google login form for set user name. * Check for signin form with password input box. */ function connectStage2() { const stage = 'stage 2'; if (credentials['authenticated'] || stageData['twoFactorConfirmation']) { return Promise.resolve(credentials); } if (!stageData['form']) { if (showStage) { console.warn(stageData, stage + ' missing form'); } return Promise.reject(new Error('Missing form')); } stageData['form']['Email'] = credentials['email']; return new Promise((resolve, reject) => { request({ url: "https://accounts.google.com/signin/v1/lookup", headers: { ...getCookies(), "Referer": "https://accounts.google.com/ServiceLogin?rip=1&nojavascript=1", "Origin": "https://accounts.google.com" }, method: "POST", form: stageData['form'] }, function (err, response, body) { delete stageData['form']; if (err || !response) { if (showStage) { console.warn(err, stage + ' request/response error'); } return reject(new Error('Request error')); } if (response.hasOwnProperty('headers') && response.headers.hasOwnProperty('set-cookie')) { setCookies(response.headers['set-cookie']); } else { if (showStage) { console.warn(response.headers, stage + ' missing set-cookie header'); } return reject(new Error('No cookies')); } const $ = cheerio.load(response.body); const error = $('.error-msg').text().trim(); if (error) { if (showStage) { console.warn(error, stage + ' error message on webpage'); } return reject(new Error('Error on webpage')); } const googlePasswordForm = $("form").serializeArray() .reduce((r, x) => Object.assign({}, r, { [x.name]: x.value, }), {}); stageData['form'] = googlePasswordForm; return resolve(credentials); }); }); } /** * We have the GAPS cookie and the GALX identifier. * Start username and password challenge now. */ function connectStage3() { const stage = 'stage 3'; if (credentials['authenticated'] || stageData['twoFactorConfirmation']) { return Promise.resolve(credentials); } if (!stageData['form']) { if (showStage) { console.warn(stageData, stage + ' missing form'); } return Promise.reject(new Error('Missing form')); } stageData['form']['Passwd'] = googlePassword; return new Promise((resolve, reject) => { request({ url: "https://accounts.google.com/signin/challenge/sl/password", headers: { ...getCookies(), "Referer": "https://accounts.google.com/signin/v1/lookup", "Origin": "https://accounts.google.com" }, method: "POST", form: stageData['form'] }, function (err, response, body) { delete stageData['form']; if (err || !response) { if (showStage) { console.warn(err, stage + ' request/response error'); } return reject(new Error('Request error')); } const $ = cheerio.load(response.body); const error = $('.error-msg').text().trim(); if (error) { if (showStage) { console.warn(error, stage + ' error message on webpage'); } return reject(new Error('Error on webpage')); } if (response.hasOwnProperty('headers') && response.headers.hasOwnProperty('set-cookie')) { setCookies(response.headers['set-cookie']); if (checkCookie('SID')) { credentials['authenticated'] = true; return resolve(credentials); } } if (response.headers['location']) { if (showStage) { console.warn('Possible 2FA. Try to call again with twoFactorConfirmation = true, after confirmation on mobile phone.'); } stageData['url'] = response.headers['location']; stageData['referer'] = response.request.href; return resolve(credentials); } if (showStage) { console.warn(response.headers, stage + ' missing set-cookie header or redirection'); } return reject(new Error('Nothing to follow')); }); }); } /** * Check if we need to redirect for 2FA confirmation. * If there is only one form after redirect, user need to click "Yes" confirmation on phone. * If there is multiple forms, google asks for exact confirmation type. * This script can only continue with authentication via phone confirmation. */ function connectStage4() { const stage = 'stage 4'; if (credentials['authenticated'] || stageData['twoFactorConfirmation']) { return Promise.resolve(credentials); } if (!stageData['url'] || !stageData['referer']) { if (showStage) { console.warn(stageData, stage + ' missing url'); } return Promise.reject(new Error('Missing url')); } return new Promise((resolve, reject) => { request({ url: stageData['url'], headers: { ...getCookies(), "Referer": stageData['referer'], "Origin": "https://accounts.google.com" }, method: "GET" }, function (err, response, body) { delete stageData['url']; delete stageData['referer']; if (err || !response) { if (showStage) { console.warn(err, stage + ' request/response error'); } return reject(new Error('Request error')); } const $ = cheerio.load(response.body); const error = $('.error-msg').text().trim(); if (error) { if (showStage) { console.warn(error, stage + ' error message on webpage'); } return reject(new Error('Error on webpage')); } if (response.hasOwnProperty('headers') && response.headers.hasOwnProperty('set-cookie')) { setCookies(response.headers['set-cookie']); if (checkCookie('SID')) { credentials['authenticated'] = true; return resolve(credentials); } } var challenge = undefined; var forms = $('form'); if (forms.length > 0) { const challenges = challengesMap .filter(c => c.implementation) .map(c => { const form = forms.toArray().find(f => (f['attribs'] && f['attribs']['action'] || '').indexOf(c.fragment) >= 0); return { exists: form != null, challengeFragment: c.fragment, challengeCode: c.code, challengeDescription: c.description, form: form }; }); if (challenges) { challenge = challenges.find(c => c.exists); } if (challenge === undefined) { if (showStage) { console.warn(forms, stage + ' unsupported challenge forms'); } return reject(new Error('Unsupported challenge')); } } else { if (showStage) { console.warn(response.body, stage + ' no challenge forms found'); } return reject(new Error('Nothing to follow')); } switch (challenge.challengeCode) { case 'p': if (showStage) { console.warn(challenge, stage + ' waiting for verification by cell phone'); } stageData['action'] = (challenge.form['attribs']['action'] || '').startsWith('http') ? challenge.form['attribs']['action'] : 'https://accounts.google.com' + (challenge.form['attribs']['action'] || ''); stageData['form'] = $(challenge.form).serializeArray() .reduce((r, x) => Object.assign({}, r, { [x.name]: x.value, }), {}); stageData['twoFactorConfirmation'] = true; return reject(new Error('Cell phone verification')); default: return reject(new Error('Unsupported challenge')); } }); }); } /** * Submit form after cell phone verification is done */ function connectStage5() { const stage = 'stage 5'; if (stageData['twoFactorConfirmation']) { delete stageData['twoFactorConfirmation']; } else { return Promise.resolve(credentials); } if (!stageData['action'] || !stageData['form']) { if (showStage) { console.warn(stageData, stage + ' missing form'); } credentials['authenticated'] = false; return Promise.reject(new Error('Nothing to follow')); } return new Promise((resolve, reject) => { request({ url: stageData['action'], headers: { ...getCookies(), "Referer": "https://accounts.google.com/signin/challenge/sl/password", "Origin": "https://accounts.google.com" }, method: "POST", form: stageData['form'] }, function (err, response, body) { delete stageData['action']; delete stageData['form']; if (err || !response) { if (showStage) { console.warn(err, stage + ' request/response error'); } credentials['authenticated'] = false; return reject(new Error('Request error')); } const $ = cheerio.load(response.body); const error = $('.error-msg').text().trim(); if (error) { if (showStage) { console.warn(error, stage + ' error message on webpage'); } credentials['authenticated'] = false; return reject(new Error('Error on webpage')); } if (response.hasOwnProperty('headers') && response.headers.hasOwnProperty('set-cookie')) { setCookies(response.headers['set-cookie']); } else { if (showStage) { console.warn(response.headers, stage + ' missing set-cookie header'); } credentials['authenticated'] = false; return reject(new Error('No cookies')); } credentials['authenticated'] = checkCookie('SID'); return resolve(credentials); }); }) } /** * Return shared location from google map for authenticated user. */ function getSharedLocations() { const requestTime = new Date(); nextRequestAfter = new Date(requestTime.getTime() + minimalRequestTimeInterval * 1000) return new Promise((resolve, reject) => { request({ url: "https://www.google.com/maps/preview/locationsharing/read", headers: { ...getCookies() }, method: "GET", qs: { "authuser": 0, "pb": "" } }, function (err, response, body) { if (err || !response) { return reject(new Error('Locationsharing response error: ' + err)); } if (response.statusCode !== 200) { // connection established but auth failure return reject(new Error('Locationsharing response status error: HTTP Status ' + response.statusCode)); } // Parse and save user locations const locationData = JSON.parse(body.split('\n').slice(1, -1).join('')); // Shared location data is contained in the first element const otherUsersData = locationData[0] || []; const users = otherUsersData.map(data => ({ "id": data[0][0], "name": data[0][3], "shortname": data[6] && data[6][3], "visible": (data[1] && data[1][2]) != null, "lat": data[1] && data[1][1][2], "lng": data[1] && data[1][1][1], "locationname": data[1] && data[1][4], "photoURL": data[0][1], "battery": data[13] && data[13][1] || null, "lastupdateepoch": data[1] && data[1][2] })); // google account owner location data if (showOwnerLocation) { const activeUserData = locationData[9] && { "id": credentials['ownerId'] || '0', "name": credentials['ownerName'], "shortname": credentials['ownerShortname'], "visible": locationData[8] != null, "lat": locationData[9] && locationData[9][1] && locationData[9][1][1] && locationData[9][1][1][2], "lng": locationData[9] && locationData[9][1] && locationData[9][1][1] && locationData[9][1][1][1], "locationname": locationData[9] && locationData[9][1] && locationData[9][1][4], "photoURL": credentials['ownerPhotoUrl'], "battery": undefined, "lastupdateepoch": locationData[8] }; if (activeUserData) { users.push(activeUserData); } } if (users.length > 0) return resolve(users); else return resolve([]); }); }) }