Project 9: Time Me with Nexmo
July 01, 2015 |
Update July 7th : This project was submitted to Nexmo’s Verify API hackathon and was awarded first place this morning.
What makes hacking these projects together so much fun is finding new ways to do things that already have an established process. There was another online hackathon sponsored by Nexmo, a voice and SMS service provider, which finished up today. Nexmo was looking for hacks that used their Number Verify API.
If you’ve ever used two-factor authentication with services like Gmail, you might have seen how this process works. In Gmail, after providing your username and password, you can opt to receive an one-time use code via SMS. You must enter this unique code as the second step in authenticating it’s really you. Usually only you will have both your password and the phone at the same time.
These one-time use codes work similarly when using Nexmo’s Number Verify API, but also offer a wider range of options in how you use them. As a developer, you specify the phone number you want to verify (usually provided by the user). Nexmo will call the phone number and speak a four digit number to the user. The user provides you this number, and you make another API call to the Number Verify API and check that the code is correct. In this system, only Nexmo knows the right code, which makes it pretty secure for the three parties involved.
For my ninth project in my 15 projects in 30 days challenge, I’m going to use Number Verify API as an authentication method for the web portion of the app. The app comes in two forms. First, the SMS side where you can send text messages to Time Me to start and end timers for tasks. Second, a web interface to view all the timers created by a phone number.
Nexmo
You’ll need an API key and secret from Nexmo, and a phone number which will receive text messages from users and respond back. You can sign up here.
Setup
There are three files for this project. The web version of this app will use AngularJS with the files index.html and timeme.js. The backend will be a Node.js app, app.js, powering both the web app and serving as the callback that Nexmo sends text messages to.
You’ll need to install Node.js and MongoDB. My other projects have instructions on how to do this.
// Filename: app.js
var NEXMO_API_KEY = '';
var NEXMO_API_SECRET = '';
var BRAND_NAME = 'Time Me';
var START_COMMAND = 'begin';
var FINISH_COMMAND = 'finish';
var MONGODB_ADDRESS = 'mongodb://127.0.0.1:27017/test';
var SESSION_SECRET = '';
var PORT = 8080;
var express = require('express');
var bodyParser = require('body-parser');
var request = require('request');
var url = require('url');
var cookieParser = require('cookie-parser');
var session = require('express-session');
var MongoStore = require('connect-mongo')(session);
var mongoose = require('mongoose');
mongoose.connect(MONGODB_ADDRESS);
var app = express();
app.use(cookieParser());
app.use(session({
secret: SESSION_SECRET,
store: new MongoStore({ mongooseConnection: mongoose.connection })
}));
app.listen(PORT);
app.use(bodyParser.json());
var Task = mongoose.model("TimeTest6", {
number: String,
task: String,
starttime: Number,
endtime: Number
});
app.get('/api/verify', function(req, res) {
var query = url.parse(req.url, true).query;
query.number = query.number;
request.get('https://api.nexmo.com/verify/json?number='+query.number+'&brand='+encodeURIComponent(BRAND_NAME)+'&api_key='+NEXMO_API_KEY+'&api_secret='+NEXMO_API_SECRET, function(err, response) {
if(err) {
res.send({error: 'Stale request id'});
return;
}
var js = JSON.parse(response.body);
if(js.error_text) {
res.send({error: js.error_text});
return;
}
req.session.unverified_number = query.number;
req.session.request_id = js.request_id;
res.send({requestid: js.request_id});
});
});
app.post('/api/check', function(req, res) {
var query = url.parse(req.url, true).query;
var requestId = query.requestid;
var code = query.code;
if(req.session.request_id != requestId) {
res.send({error: 'Stale request id'});
return;
}
request.get('https://api.nexmo.com/verify/check/json?request_id='+requestId+'&code='+code+'&api_key='+NEXMO_API_KEY+'&api_secret='+NEXMO_API_SECRET, function(err, response) {
if(err) {
res.send({error: 'Invalid code'});
return;
}
var js = JSON.parse(response.body);
if(js.error_text) {
res.send({error: js.error_text}
);
return;
}
if(js.status == '0') {
req.session.number = req.session.unverified_number;
res.send({number: req.session.number});
} else {
res.send({error: 'Invalid code'});
}
});
});
app.get('/api/me', function(req, res) {
if(req.session.number) {
res.send({number: req.session.number});
} else {
res.send({error: 'Not validated'});
}
});
app.get('/api/nexmo', function(req, res) {
var query = url.parse(req.url, true).query;
var timeNow = new Date();
if(query.text.substring(0, START_COMMAND.length).toLowerCase() == START_COMMAND) {
Task.create({
number: query.msisdn,
task: query.text.substring(START_COMMAND.length+1),
starttime: timeNow.getTime(),
endtime: 0
}, function(err, doc) {
if(err || !doc) {
console.log(err);
return;
}
sendMessage(query.to, query.msisdn, 'Starting '+doc.task, function(err, response) {
if(err)
console.log(err);
});
});
res.send('success');
return;
}
if(query.text.substring(0, FINISH_COMMAND.length).toLowerCase() == FINISH_COMMAND) {
Task.findOne({
number: query.msisdn,
task: query.text.substring(FINISH_COMMAND.length+1),
endtime: 0
}, function(err, doc) {
if(err || !doc) {
sendMessage(query.to, query.msisdn, 'I could not find a task to stop by that name.', function(err, response) {
if(err)
console.log(err);
});
return;
}
Task.update({_id: doc._id}, {endtime: timeNow.getTime()}, function(err, numAffected) {
});
sendMessage(query.to, query.msisdn, 'Finished '+doc.task, function(err, response) {
if(err)
console.log(err);
});
});
res.send('success');
return;
}
Task.find({
number: query.msisdn,
task: query.text,
}).sort({_id: -1}).limit(1).exec(function(err, doc) {
if(err || doc.length == 0) {
sendMessage(query.to, query.msisdn, 'I could not find a task by that name.', function(err, response) {
if(err)
console.log(err);
});
return;
}
var startTime = new Date();
startTime.setTime(doc[0].starttime);
var message = 'Started at '+startTime.format('m/dd HH:MM');
if(doc[0].endtime > 0) {
var endTime = new Date();
endTime.setTime(doc[0].endtime);
message += ' to '+endTime.format('m/dd HH:MM');
}
var timeNow = new Date();
var elapsedTimeSeconds = Math.floor(((endTime > 0 ? endTime : timeNow.getTime())-doc[0].starttime)/1000);
var pad = function(num, size) {
var s = num+'';
while (s.length < size) s = '0' + s;
return s;
}
message += ' Elapsed: '+pad(Math.floor(elapsedTimeSeconds/86400),2)+':'+
pad(Math.floor(elapsedTimeSeconds/3600),2)+':'+
pad(Math.floor(elapsedTimeSeconds/60),2)+':'+
pad((elapsedTimeSeconds%60),2);
sendMessage(query.to, query.msisdn, message, function(err, response) {
if(err)
console.log(err);
});
});
res.send('success');
});
app.get('/api/tasks', function(req, res) {
Task.find({number: req.session.number}, function(err, tasks) {
if(err) {
console.log(err);
return;
}
var results = [];
var timeNow = new Date();
for(var i in tasks) {
results.push({
task: tasks[i].task,
starttime: tasks[i].starttime,
endtime: tasks[i].endtime,
elapsed: (tasks[i].endtime == 0 ? timeNow.getTime() : tasks[i].endtime)-tasks[i].starttime
});
}
res.send(results);
});
});
app.get('/api/logout', function(req, res) {
req.session.destroy();
res.status(200);
res.send();
})
app.use(express.static(__dirname + '/public'));
console.log('Application listening on port '+PORT);
function sendMessage(from, to, message, callback) {
request.get('https://rest.nexmo.com/sms/json?api_key='+NEXMO_API_KEY+'&api_secret='+NEXMO_API_SECRET+'&from='+from+'&to='+to+'&text='+message, function(err, response) {
callback(err, response);
});
}
/*
* Date Format 1.2.3
* (c) 2007-2009 Steven Levithan
* MIT license
*
* Includes enhancements by Scott Trenda
* and Kris Kowal
*
* Accepts a date, a mask, or a date and a mask.
* Returns a formatted version of the given date.
* The date defaults to the current date/time.
* The mask defaults to dateFormat.masks.default.
*/
var dateFormat = function () {
var token = /d{1,4}|m{1,4}|yy(?:yy)?|([HhMsTt])\1?|[LloSZ]|"[^"]*"|'[^']*'/g,
timezone = /\b(?:[PMCEA][SDP]T|(?:Pacific|Mountain|Central|Eastern|Atlantic) (?:Standard|Daylight|Prevailing) Time|(?:GMT|UTC)(?:[-+]\d{4})?)\b/g,
timezoneClip = /[^-+\dA-Z]/g,
pad = function (val, len) {
val = String(val);
len = len || 2;
while (val.length 99 ? Math.round(L / 10) : L),
t: H < 12 ? "a" : "p",
tt: H < 12 ? "am" : "pm",
T: H < 12 ? "A" : "P",
TT: H 0 ? "-" : "+") + pad(Math.floor(Math.abs(o) / 60) * 100 + Math.abs(o) % 60, 4),
S: ["th", "st", "nd", "rd"][d % 10 > 3 ? 0 : (d % 100 - d % 10 != 10) * d % 10]
};
return mask.replace(token, function ($0) {
return $0 in flags ? flags[$0] : $0.slice(1, $0.length - 1);
});
};
}();
// Some common format strings
dateFormat.masks = {
"default": "ddd mmm dd yyyy HH:MM:ss",
shortDate: "m/d/yy",
mediumDate: "mmm d, yyyy",
longDate: "mmmm d, yyyy",
fullDate: "dddd, mmmm d, yyyy",
shortTime: "h:MM TT",
mediumTime: "h:MM:ss TT",
longTime: "h:MM:ss TT Z",
isoDate: "yyyy-mm-dd",
isoTime: "HH:MM:ss",
isoDateTime: "yyyy-mm-dd'T'HH:MM:ss",
isoUtcDateTime: "UTC:yyyy-mm-dd'T'HH:MM:ss'Z'"
};
// Internationalization strings
dateFormat.i18n = {
dayNames: [
"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat",
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
],
monthNames: [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"
]
};
// For convenience...
Date.prototype.format = function (mask, utc) {
return dateFormat(this, mask, utc);
};
// Filename: public/timeme.js
angular.module('TimeMeApp', [])
.controller('timeMeCtrl', ['$scope', '$http', function($scope, $http) {
$scope.phonenumber = '';
$scope.requestid = null;
$scope.isLoggedIn = false;
$scope.tasks = [];
$scope.getTasks = function() {
$http.get('/api/tasks').then(function(response) {
if(response.data.error) {
$scope.isLoggedIn = false;
return;
}
$scope.tasks = response.data;
});
}
$scope.verify = function() {
$http.get('/api/verify?number='+$scope.phonenumber).then(function(response) {
if(response.data.error) {
alert('Unable to verify number: '+response.data.error);
return;
}
$scope.requestid = response.data.requestid;
});
}
$scope.check = function() {
$http.post('/api/check?requestid='+$scope.requestid+'&code='+$scope.code).then(function(response) {
if(response.data.error) {
alert('Unable to verify number: '+response.data.error);
return;
}
$scope.isLoggedIn = true;
$scope.getTasks();
});
}
$scope.logout = function() {
$http.get('/api/logout').then(function() {
$scope.isLoggedIn = false;
$scope.tasks = [];
$scope.phonenumber = '';
$scope.code = '';
});
}
$scope.elapsedTime = function(elapsedTime) {
var pad = function(num, size) {
var s = num+"";
while (s.length < size) s = "0" + s;
return s;
}
var elapseTimeSeconds = elapsedTime/1000;
return pad(Math.floor(elapseTimeSeconds/86400),2)+':'+
pad(Math.floor((elapseTimeSeconds%86400)/3600),2)+':'+
pad(Math.floor((elapseTimeSeconds%3600)/60),2)+':'+
pad(Math.floor(elapseTimeSeconds%60),2);
}
$http.get('/api/me').then(function(response) {
if(response.data.error) {
$scope.isLoggedIn = false;
return;
}
$scope.phonenumber = response.data.number;
$scope.isLoggedIn = true;
$scope.getTasks();
});
}]);
<!-- Filename: public/index.html -->
<html ng-app="TimeMeApp">
<head>
<title>Time Me!</title>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.16/angular.min.js"></script>
<script src="timeme.js"></script>
</head>
<body ng-controller="timeMeCtrl" style="font-family:Arial">
## Time Me
<div ng-hide="isLoggedIn">
Login using your mobile phone number:<br />
<input type="text" name="number" ng-model="phonenumber" /> <input type="button" ng-click="verify()" value="Next" />
<input type="hidden" name="requestid" value="{{requestid}}" />
<div ng-show="requestid">
Calling you. Please enter verification code:<br />
<input type="text" ng-model="code" /> <input type="button" ng-click="check()" value="Login" />
</div>
</div>
<div ng-show="isLoggedIn">
<div style="float:right">Welcome {{phonenumber}}! <a href="#" ng-click="logout()">Logout</a></div>
<h2>My Tasks</h2>
<table width="50%">
<tr>
<td>Task</td>
<td>Started</td>
<td>Ended</td>
<td>Timer</td>
</tr>
<tr ng-repeat="task in tasks">
<td>{{task.task}}</td>
<td>{{task.starttime | date:'M/dd HH:mm'}}</td>
<td><span ng-show="task.endtime > 0">{{task.endtime | date:'M/dd HH:mm'}}</span></td>
<td>{{elapsedTime(task.elapsed)}}</td>
</tr>
</table>
<h2>How to use</h2>
<p>Text begin &lt;task&gt; to start timing a task.<br />
Text finish &lt;task&gt; to stop timing a task.<br />
Text &lt;task&gt; to get start, end, and elapsed time.</p>
</div>
</body>
</html>
package.json
{
"name": "time-me",
"description": "Time Me application for Node.js",
"version": "0.0.1",
"private": true,
"dependencies": {
"express": "*",
"mongoose": "*",
"url": "*",
"body-parser": "*",
"cookie-parser": "*",
"express-session": "*",
"connect-mongo": "*",
"request": "*"
}
}
To install the dependencies:
npm install
And run the Node.js app:
nodejs app
Time Me
You can start using the app in one of two ways: either by accessing the web address of the Node.js app in the browser, or by sending a text message to the Nexmo phone number you were assigned. I’m going to start by sending a text message so there’s a timer that’s active in the system.
To add a task to start timing, text begin
Time me will respond back with a confirmation that the task timer has been created and started.
When a text message is received by Nexmo, Nexmo will send a JSON body containing the phone number and text message to the Node.js endpoint, /api/nexmo. Time Me determines the text message starts with the keyword begin and creates a timer named demo (the rest of the message) with a start time of now. Since sending a text message from a phone number isn’t easy to spoof, we can be reasonably assured that the sender is really who they are.
If we text just the name of the task to Time Me, we can see how long the timer has been running for.
On the web version of the app, logging in with a phone number can be easily spoofed. I could easily say I’m the owner of 555-555-5534. So let’s use Nexmo’s Number Verify API to send the user a one-time use code to the phone number they provide. If they provide the correct four digit code, we can be reasonably assured they own this phone number.
When you click on Next, the AngularJS app submits the phone number to the Node.js app, which in turns contacts the Number Verify API and provides the phone number. Nexmo will return a request id that we need to save (in the session) for when we check the code later on. Nexmo will generate a four-digit code and make a call to the phone number, announce the four-digit code, and then end the call.
The user returns to the web app and enters the four-digit number. The AngularJS app submits the code, where the Node.js app combines it with the request id that was saved from the first API call to Nexmo and contacts Nexmo to check if it’s the right code. If correct, Nexmo will send us a successful response.
If successfully verified, Time Me will login the user. No need for passwords to login with. Time Me will look for any timers created by the phone number. When we texted Time Me earlier, it created an entry in the MongoDB. Querying for the phone number returns one result, which is sent back to the AngularJS app, which is then displayed as a task timer.
Great. Let’s send another text message, this time stopping the timer. To stop a timer, text finish
If we want the elapsed time of the task, we can again text the name of the task. This time, there’s an end time added to the message.
The web app offers the same information. Refresh the web app and we see the timer now has an end time and the elapsed time. The web interface is easier to use when you want to see the complete list of all timers created and their status.
You can create multiple timers that run concurrently. However, if you start another timer with the same task name, things might get complicated. By default, the oldest timer will be stopped first.
Another edge case would be where you start and end a timer with the same task name twice. If you text the task name to Time Me, you will only receive the last timer’s info.
That’s it for this project. There are a couple of things that could be done:
- If only using the text message side of this app, you can lose access to a timer if you create another timer with the same task. If there are multiple timers with the same name, send a text message asking the user which timer's info they would like.
- The web version doesn’t allow for a new timer to be created, or for active timers to be stopped. Add some buttons to make this happen.
- The timer values for active timers in the web interface are static. Make them live.
Source Code
You can find the repo on GitHub.
Post-mortem
Apps that use text messaging have always been fascinating to me. Text messages are somewhat stateless. It was fun to create a flow where multiple text messages were required to proceed through a connected process.
This project also used the Nexmo Verify API. For the most part, I’ve seen these codes only used to verify that I own the phone number being texted or called, or to provide me a code as a second-factor authentication. To use it purely as the authentication method is something new for me. I took this approach because I usually keep control of my phone. So the code is pretty much like a one-time use password, replacing the need to actually have a password in this project.
15 Projects in 30 Days Challenge
This blog post is part of my 15 projects in 30 days challenge. I’m hacking together 15 projects with different APIs, services, and technologies that I’ve had little to no exposure to. If my code isn’t completely efficient or accurate, please understand it isn’t meant to be complete and bulletproof. When something is left out, I try to mention it. Reach out to me and kindly teach me if I go towards the dark side. ?
This challenge serves a couple of purposes. First, I’ve always enjoyed hacking new things together and using APIs. And I haven’t had the chance (more like a reason) to dive in head first with things like AngularJS, NodeJS, and Nexmo. This project demonstrated AngularJS, Node.js, and Nexmo.