Auth handlers

This commit is contained in:
Omar 2018-12-23 01:44:45 -08:00
commit 208fc56323
14 changed files with 4915 additions and 0 deletions

25
.eslintrc Normal file
View File

@ -0,0 +1,25 @@
{
"env": {
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2018
},
"rules": {
"indent": ["error", 2],
"linebreak-style": ["error","unix"],
"quotes": ["error", "single"],
"semi": ["error","always"],
"require-jsdoc": ["off", {
"require": {
"FunctionDeclaration": true,
"MethodDefinition": false,
"ClassDeclaration": false,
"ArrowFunctionExpression": false,
"FunctionExpression": false
}
}]
}
}

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.DS_Store
node_modules
store.json
test.js

4293
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "tagfer-server",
"version": "0.0.0",
"description": "Express server hosts the business logic behind tagfer.inc",
"main": "server.js",
"scripts": {
"test": "jest"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mihilmy/tagfer-server.git"
},
"keywords": [
"api"
],
"author": "@mihilmy, @oonyeje",
"license": "ISC",
"bugs": {
"url": "https://github.com/mihilmy/tagfer-server/issues"
},
"homepage": "https://github.com/mihilmy/tagfer-server#readme",
"dependencies": {
"bcrypt": "^3.0.2",
"express": "^4.16.4",
"firebase": "^5.7.0",
"firebase-admin": "^6.4.0",
"lodash": "^4.17.11",
"lokijs": "^1.5.5",
"phone": "^2.3.0",
"twilio": "^3.25.0",
"uuid": "^3.3.2"
},
"devDependencies": {
"eslint": "^5.9.0",
"faker": "^4.1.0"
}
}

14
server.js Normal file
View File

@ -0,0 +1,14 @@
require('./src/config/firebase');
const express = require('express');
const appConfig = require('./src/config/app.json');
const router = require('./src/config/router');
const app = express();
app.use(express.json());
router(app);
// eslint-disable-next-line no-console
app.listen(appConfig.server.port, () => console.log('Listening on port 3000!'));

41
src/config/app.json Normal file
View File

@ -0,0 +1,41 @@
{
"server": {
"port": 3000
},
"keys": {
"appSecret": "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJujjNO60eZu3GUU+1Tx9ruhVzlbjlpfZlvROONBqwj6jOgd86dTPtbfvBZO7dKlwiIikLJNP6tpFeNohxO6EcsCAwEAAQ",
"firebaseAdmin": {
"type": "service_account",
"project_id": "tagfer-inc",
"private_key_id": "bd5a13081cb521ea1b43f374d3bdc1f09b13ef99",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDLbi762wbF2uO9\ngPalp+FT3HxPmt4PSstdCwUofjFVrbfsS0hupcu+y2p81moyfi60upGPE63oQOdi\natmkvtJ1L2jiFwqB9NXuEWNJr/hY804sVzwka0NnOc2DYWA5B5Jh0PFyw0jqt2+y\nNG02/8viL0Qtw8ghnnkRpA5j5SByACkmdpYi9OpDB9GBp0II3R9l/CNHJyyG/v8Q\n1OLweURYE+53fMnqUjhmQazq6EMQwjrxyrTojUUyYZUq+n3D20bJteDl8Hn1vUyh\nHz3zwvUD+2CjUXwxrg026ArIsBy4Hq9mjQjjjzIEcGpzA3SxRSkr2FweJ8ZZgsbU\n5lEZpv6tAgMBAAECggEACeH+np7mYNVQoWOzAnrklk43VydcaLIcWBb8If/4xe33\nFJ/W/dJzJ//k2qS7VbGjSOdpa+HWXdqirbl9fPns6oF/nxWPI/TGMdcevjUyI6F0\n/PAjUqzyrNX2NyQ+EvcxtCvSQbOIBtMbi4pC0LGzm3eVYIMgOrXisX80AxleA/u0\nwC3wuEMRX5DMwkSqYFdD/06G6Z1FM+07vueeixEwE1aj4yXoCAbxdxrHYIeygpgz\nIb68giI78j9tw7bKYAwrdwsDqKk10T0CFp+YUKolPY5aEF3lAHuG0bUvdUI/OyPp\nNgSxSXrwa9KlZovZbrjxUWvjM0RoX+7bEZcpZqcdFwKBgQD59w/lpjzuImL83kKt\n9YyDVilhOnx4JQRl0s7pbk/BJjp+9uXZEC/3z1xESy5duDgV8noIzLWg2pXUBG+L\n+DSAQnxG1BcaVyD/85oBgnLdcnG+AlsMqWct+UPzdsm9o26vvBpUipKfvJnGg1qR\nJJvQv55hDnQg4CCpBHzUXTg/awKBgQDQV4IrbOi9iLBiDc2eFePOY+mu1Z0ymsqA\nd09qoABHh/y+FvBkR9LFeGOfAd6O0ZZ0jOGA9FfpBlPhmKzJ0eEb9MRJTQ1RUyZi\nY+utttBVjxPsoAck2EdbZVx9Svtbtnrt3IyEYoKMq9u92gmDFUFBnJnh1AE9iP2H\nNEV+JQo4RwKBgQC/I98yGnZJGl5bQpH2d+eknoQx5wk6zgOY4SR7d3DhH5xnbeDA\npRIpCpVhW6Pu4mlwzuPmSrMwdzVO1L1/aKKs2Soy9wdbivie/+Xp9ZhkIZk8VIzP\nF9LgYtVFHLaTnp+LHel8cCJCp3NnSxY8GqRTcdNoICdI5FnVJKtXsJjMVQKBgQCM\neTRPS1Nx1+P1eREWcfPziPJa67TeFfhLviZR4ifOEyaalKTpOHQoqQ+ieoQxD6e+\nVe8GH7nWaGnORj7apSR+0P433jgIiWPsGyshKY424g2xEgU/FoSmXyWJZTEtmVAx\naO9lo3YamxXCYGzhcUdakdg/p85eSyuGKfxhHWBSqQKBgQC9eBtfcoK9TeK8M6SD\nLOI3Unl36CEAdLG9KVZnbrw8cFjXRoSdeHGiTQGwnjKGFl85P0N118ds/thngA/i\nuc2DbkLczXSFaPNFLlEijwAeVROzl19KxuP9PwhOdJjdKQOazc7BiEyaMlPp6lfE\neFGb6qu0ZoT7vaEt4rSA2iLpPw==\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-tcgaf@tagfer-inc.iam.gserviceaccount.com",
"client_id": "112076836493715153722",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-tcgaf%40tagfer-inc.iam.gserviceaccount.com"
},
"firebase": {
"apiKey": "AIzaSyCtyieRAu2Vl_0i9KvnD8aYfAqFGUnWDkY",
"authDomain": "tagfer-inc.firebaseapp.com",
"databaseURL": "https://tagfer-inc.firebaseio.com",
"projectId": "tagfer-inc",
"storageBucket": "tagfer-inc.appspot.com",
"messagingSenderId": "497562483601"
},
"twilio": {
"accountSid": "ACf62469621684ed007c01dfc9aa0923fa",
"authToken": "62bbc8dc0bd42503dcc46eb17fb26895",
"phoneNumber": "+17027108370 "
}
},
"dbPath": {
"users": "users"
},
"loki" : {
"verificationTTL": 300000,
"verificationTTLClear": 1800000
}
}

15
src/config/errors.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
AUTH_INVALID_EMAIL: 'auth/invalid-email',
AUTH_INVALID_PHONE_NUMBER: 'auth/invalid-phone-number',
AUTH_USER_NOT_FOUND: 'auth/user-not-found',
AUTH_WRONG_PASSWORD: 'auth/wrong-password',
AUTH_UID_ALREADY_EXISTS: 'auth/uid-already-exists',
AUTH_EMAIL_ALREADY_EXISTS: 'auth/email-already-exists',
AUTH_PHONE_ALREADY_EXISTS: 'auth/phone-number-already-exists',
AUTH_PHONE_NOT_CACHED: 'auth/phone-number-not-cached',
AUTH_VERIFICATION_CODE_MISMATCH: 'auth/phone-verification-code-mismatch',
APP_NETWORK_ERROR: 'app/network-error',
APP_NETWORK_TIMEOUT: 'app/network-timeout',
APP_UNABLE_TO_PARSE_RESPONSE: 'app/unable-to-parse-response',
MISSING_BODY_ATTRIBUTES: 'request/missing-body-attributes'
};

11
src/config/firebase.js Normal file
View File

@ -0,0 +1,11 @@
const admin = require('firebase-admin');
const firebase = require('firebase');
const appConfig = require('./app.json');
admin.initializeApp({
credential: admin.credential.cert(appConfig.keys.firebaseAdmin),
databaseURL: 'https://tagfer-inc.firebaseio.com'
});
firebase.initializeApp(appConfig.keys.firebase);

8
src/config/http.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
INTERNAL_SERVER_ERROR: 500
};

27
src/config/loki.js Normal file
View File

@ -0,0 +1,27 @@
const Loki = require('lokijs');
const appConfig = require('./app.json');
const loki = new Loki('store.json', { autosave: true, autoload: true, autoloadCallback: lokiInit });
/**
* Initializes the collections in our local loki loki
*/
function lokiInit() {
let sessions = loki.getCollection('sessions');
let verifications = loki.getCollection('verifications');
if (sessions == null) {
sessions = loki.addCollection('sessions', { unique: ['id'] });
}
if(verifications == null) {
verifications = loki.addCollection('verifications', {
unique: ['phoneNumber'],
ttl: appConfig.loki.verificationTTL,
ttlInterval: appConfig.loki.verificationTTLClear
});
}
}
module.exports = loki;

20
src/config/router.js Normal file
View File

@ -0,0 +1,20 @@
const AuthHandlers = require('../ups/auth/handlers');
/**
* Main router for our application, include all routes here. No logic should be added in this file.
* @param {Express} app
*/
function router(app) {
// Auth Endpoints
app.get('/auth/email/:email/exists', AuthHandlers.doesAttributeExist );
app.get('/auth/tagferId/:tagferId/exists', AuthHandlers.doesAttributeExist );
app.post('/auth/phone/code', AuthHandlers.sendPhoneCode);
app.post('/auth/phone/verify', AuthHandlers.verifyPhoneCode);
app.post('/auth/signin', AuthHandlers.signin);
app.put('/auth/signup', AuthHandlers.signup);
// Users Endpoints
app.get('/users/by/phone', AuthHandlers.findUsersNetwork);
}
module.exports = router;

30
src/config/twilio.js Normal file
View File

@ -0,0 +1,30 @@
const Twilio = require('twilio');
const appConfig = require('./app.json');
const authToken = appConfig.keys.twilio.authToken;
const accountSid = appConfig.keys.twilio.accountSid;
const tagferPhone = appConfig.keys.twilio.phoneNumber;
const client = new Twilio(accountSid, authToken);
/**
*
*/
function sendSMSTo(clientPhone, message, callback) {
const sms = {
from: tagferPhone,
body: message,
to: clientPhone
};
client.messages.create(sms).then(() => {
callback({result: true});
}).catch(error => {
//TODO: add twilio error handler
callback({error: error.message});
}).done();
}
module.exports = {
sendSMSTo
};

216
src/ups/auth/dao.js Normal file
View File

@ -0,0 +1,216 @@
const firebase = require('firebase');
const admin = require('firebase-admin');
const uuid = require('uuid/v4');
const _ = require('lodash');
const errors = require('../../config/errors');
const loki = require('../../config/loki');
const twilio = require('../../config/twilio');
/**
* Checks if the email exists in our auth system
*/
async function doesEmailExist(email) {
return _doesKeyValueExist({email});
}
/**
* Checks if the tagferId exists in our auth system
*/
async function doesTagferIdExist(tagferId) {
return _doesKeyValueExist({tagferId});
}
/**
* Checks if the phone number exists in our auth system
*/
async function doesPhoneExist(phoneNumber) {
return _doesKeyValueExist({phoneNumber});
}
/**
* Sign in user using firebase client api
* @param {String} email
* @param {String} password
*/
async function signinWithEmail(email, password) {
return firebase.auth().signInWithEmailAndPassword(email, password).then();
}
/**
* Sign in with tagferId using the admin sdk to get email and then the firebase client to sign in
* @param {String} tagferId
* @param {String} password
* @param {Function} callback
*/
async function signinWithTagferId(tagferId, password) {
return admin.auth().getUser(tagferId).then(user => signinWithEmail(user.email, password) );
}
/**
* Creates a new user in firebase auth
* @param {Object} user
* @param {Function} callback
*/
async function createNewUser(user) {
return admin.auth().createUser({
uid: user.tagferId.toLowerCase(),
email: user.email,
password: user.password,
phoneNumber: user.phoneNumber,
displayName: _.startCase(user.fullName)
});
}
/**
* Filters contacts into three batches:
* 1. Contacts that are in the tagfer network.
* 2. Contacts that are out of the network.
* 3. Contacts that failed to be processed.
* @param {Array} contacts
* @returns {Object} groups inNetwork, outNetwork, failedBatch
*/
async function filterContactsIntoBatches(contacts) {
const promises = [];
const inNetworkBatch = [];
const outNetworkBatch = [];
const failedBatch = [];
const addToBatch = (contact) => contact.inNetwork? inNetworkBatch.push(contact.tagferId) : contact.outNetwork? outNetworkBatch.push(contact.phoneNumber) : failedBatch.push(contact.phoneNumber);
for (let i = 0; i < contacts.length; i++) {
if (contacts[i]) {
promises.push(_getContactStatus(contacts[i]));
}
}
const results = await Promise.all(promises);
for(let i = 0; i < results.length; i++) {
if (results[i])
addToBatch(results[i]);
}
return {inNetworkBatch, outNetworkBatch, failedBatch};
}
/**
*
* @param {string} phone
* @param {number} usrCode
*/
function isVerificationCodeCorrect(phoneNumber, usrCode) {
try {
const sysCode = getVerificationCode(phoneNumber);
return {result: usrCode == sysCode};
} catch (error) {
return {error};
}
}
/**
* Sends the verification code by calling twilio and saving the code to our local storgae
* @param {string} phoneNumber
* @param {function} callback
*/
function sendVerificationCode(phoneNumber, callback) {
const code = createNewVerificationCode(phoneNumber);
const message = `Tagfer PIN: ${code}`;
twilio.sendSMSTo(phoneNumber, message, callback);
}
/**
* Creates a new session for security of users and to maintain states of the requests.
* @returns Session identifier to manage state of the user
*/
function createNewSessionId(tagferId) {
const sessionId = uuid();
const sessions = loki.getCollection('sessions');
sessions.insert({ id: sessionId, tagferId });
return sessionId;
}
/**
* Create new verification code for the phone number
*/
function createNewVerificationCode(phoneNumber) {
const code = Math.floor(((Math.random() * 899999) + 100000));
const verifications = loki.getCollection('verifications');
try {
verifications.insert({ phoneNumber, code });
} catch (error){
const record = verifications.by('phoneNumber', phoneNumber);
record.code = code;
verifications.update(record);
}
return code;
}
/**
* Gets the verification code from loki by phone number
* @param {string} phoneNumber phone number
*/
function getVerificationCode(phoneNumber) {
const verifications = loki.getCollection('verifications');
const record = verifications.by('phoneNumber', phoneNumber);
if (!record) {
throw errors.AUTH_PHONE_NOT_CACHED;
}
return record.code;
}
async function _doesKeyValueExist({email, tagferId, phoneNumber}) {
let promise;
if (email) { promise = admin.auth().getUserByEmail(email); }
if (tagferId) { promise = admin.auth().getUser(tagferId);}
if (phoneNumber) {promise = admin.auth().getUserByPhoneNumber(phoneNumber); }
return promise.then(() => { return true; })
.catch(error => {
if (error.code === errors.AUTH_USER_NOT_FOUND) {
return false;
} else {
throw { error: error.code };
}
});
}
async function _getContactStatus(phoneNumber) {
return admin.auth().getUserByPhoneNumber(phoneNumber).then( user => {
return {inNetwork: true, tagferId: user.uid};
}).catch( error => {
return error === errors.AUTH_USER_NOT_FOUND ? { outNetwork: true, phoneNumber } : { error: error.code, phoneNumber};
});
}
module.exports = {
doesEmailExist,
doesTagferIdExist,
doesPhoneExist,
sendVerificationCode,
isVerificationCodeCorrect,
filterContactsIntoBatches,
signinWithTagferId,
signinWithEmail,
createNewUser,
createNewSessionId
};
// function signinWithTagferId1(tagferId, password, callback) {
// const path = `users/${tagferId}/hash`;
// db.ref(path).once('value').then( data => {
// if(!data.exists()) {
// callback({error: errors.AUTH_USER_NOT_FOUND});
// }
// const hash = data.val();
// bcrypt.compare(password, hash).then(result => callback({result}));
// });
// }

174
src/ups/auth/handlers.js Normal file
View File

@ -0,0 +1,174 @@
const phone = require('phone');
const dao = require('./dao');
const appConfig = require('../../config/app.json');
const errors = require('../../config/errors');
const http = require('../../config/http');
// Handlers
/**
* Endpoints: auth/email/:email/exists or auth/tagferId/:tagferId/exists
* Extracts the parameter from the request and calls the appropriate function to check if the attribute exists.
* @param {Object} req
* @param {Object} res {result: Boolean} | {error: String}
*/
function doesAttributeExist(req, res) {
const { email, tagferId } = req.params;
if (!_isAppSecretValid(req,res)) {
return;
}
var promise = email? dao.doesEmailExist(email) : dao.doesTagferIdExist(tagferId);
promise.then((result) => res.json({ result }) ).catch(error => res.json(error));
}
/**
* Handles the request/response for auth/phone/code. Uses the data layer to generate a unique code,
* if the phone number is not in our system.
* @param {Object} req request body contains number
* @param {Object} res {code: String} | {error: String}
*/
function sendPhoneCode(req, res) {
const verifier = () => req.body.phoneNumber;
if (!_isAppSecretValid(req,res) || !_isBodyValid(verifier, res)) {
return;
}
const phoneNumber = req.body.phoneNumber;
dao.doesPhoneExist(phoneNumber).then( result => {
if (result === false) {
dao.sendVerificationCode(phoneNumber, status => res.json(status));
} else{
throw { error: errors.AUTH_PHONE_ALREADY_EXISTS };
}
}).catch( error => res.json(error) );
}
/**
* Handles the request/response for auth/phone/verify. Uses the data layer to verify that the code is correct.
* @param {Object} req request is a json that has both attributes { phone, code }
* @param {Object} res express request object
*/
function verifyPhoneCode(req, res) {
const verifier = () => req.body.phoneNumber && req.body.code;
if (!_isAppSecretValid(req,res) || !_isBodyValid(verifier, res)) {
return;
}
const phoneNumber = req.body.phoneNumber;
const code = req.body.code;
res.json(dao.isVerificationCodeCorrect(phoneNumber, code));
}
/**
* Endpoint: users/by/phone
* Seperates the phone numbers into batches in/out of Tagfer network, receiver should use this information to either
* add people who already in network, or send invites or repeat calls for failed batches.
* @param {Object} req {contacts: Array<PhoneNumber>}
* @param {Object} res {inNetworkBatch : Array<PhoneNumber>, outNetworkBatch: Array<PhoneNumber>, failedBatch: Array<PhoneNumber> }
*/
async function findUsersNetwork(req, res) {
const contacts = req.body.contacts;
const verifier = () => contacts && Array.isArray(contacts);
if (!_isAppSecretValid(req,res) || !_isBodyValid(verifier, res)) {
return;
}
const batches = await dao.filterContactsIntoBatches(contacts.map((number) => phone(number)[0]));
res.json(batches);
}
/**
* Endpoint: auth/signin
* Signs the user in using firebase auth
* @param {Object} req {email: String, password: String} | {tagferId: String, password: String}
* @param {Object} res {sessionId: String} | {error: String}
*/
function signin(req, res) {
const { email, tagferId, password } = req.body;
const verifier = () => (email || tagferId) && password;
if (!_isAppSecretValid(req,res) || !_isBodyValid(verifier, res)) {
return;
}
var promise;
if (email) {
promise = dao.signinWithEmail(email, password);
} else {
promise = dao.signinWithTagferId(tagferId, password);
}
promise.then( ({ user })=> {
const sessionId = dao.createNewSessionId(user.uid);
res.json({sessionId});
}).catch(error => {
res.json( {error: error.code} );
});
}
/**
* Endpoint: auth/signup
* Signs up the user by creating a new user in firebase admin.
* @param {Object} req { tagferId: String, email: String, password: String, phoneNumber: String, fullName: String }
* @param {Object} res { sessionId: String } | { error: String }
*/
function signup(req, res) {
const user = req.body;
const verifier = () => user.tagferId && user.email && user.password && user.phoneNumber && user.fullName;
if (!_isAppSecretValid(req,res) || !_isBodyValid(verifier, res)) {
return;
}
dao.createNewUser(user).then( user => {
const sessionId = dao.createNewSessionId(user.uid);
res.json({sessionId});
}).catch(error => {
res.json( {error: error.code} );
});
}
// HELPERS
// ###################################################################################################################
// ###################################################################################################################
// HELPERS
/**
* Verifies if the request is valid by checking if the request has the right app secret.
* @param {Object} req express request object
* @param {Object} res express response object
* @returns true if the app secret is valid and false otherwise
*/
function _isAppSecretValid(req, res) {
const usrToken = req.headers.authorization;
const sysToken = appConfig.keys.appSecret;
if( usrToken !== sysToken) {
res.status(http.UNAUTHORIZED).json({ error: 'Unauthorized access into api' });
return false;
}
return true;
}
/**
* Checks if the body is valid
* @param {Function} isValid verifier
*/
function _isBodyValid(isValid, response) {
if (!isValid()) {
response.status(http.BAD_REQUEST).json({error: errors.MISSING_BODY_ATTRIBUTES});
return false;
}
return true;
}
module.exports = {
doesAttributeExist,
sendPhoneCode,
verifyPhoneCode,
findUsersNetwork,
signin,
signup
};