luma_third_party: Token-based authentication

Backend changes to CrowdSTF that allow tokens to authenticate
users into using a phone for a time-based session. The system previously
required users to sign up and authenticate via username/password. This
change allows user to authenticate directly into the system for a
given amount of time with a URL token.

Change-Id: I5b93539a3b13f28b72e085fec6ae42ea96ed95e6
diff --git a/crowdstf/README.google b/crowdstf/README.google
index aec2bc3..c9d0b65 100644
--- a/crowdstf/README.google
+++ b/crowdstf/README.google
@@ -7,6 +7,8 @@
 Control and manage Android devices from your browser. https://openstf.io
 
 Local Modifications:
-Change 23400
+Change http://v.io/c/23400
   Added screen captures save-to-disk
   Added gesture capture persistence to db
+Change http://v.io/c/23714
+  Added token-based authentication (backend)
diff --git a/crowdstf/lib/db/api.js b/crowdstf/lib/db/api.js
index 5e76b78..7531894 100644
--- a/crowdstf/lib/db/api.js
+++ b/crowdstf/lib/db/api.js
@@ -6,6 +6,16 @@
 
 var dbapi = Object.create(null)
 
+db.connect().then(function(conn) {
+  r.table('kicks').changes().run(conn, function(err, cursor) {
+    cursor.each(function(err, item) {
+      if (dbapi.kickCallback) {
+        dbapi.kickCallback(item.new_val.serial);
+      }
+    });
+  });
+});
+
 dbapi.DuplicateSecondaryIndexError = function DuplicateSecondaryIndexError() {
   Error.call(this)
   this.name = 'DuplicateSecondaryIndexError'
@@ -45,6 +55,10 @@
   return db.run(r.table('users').get(email))
 }
 
+dbapi.deleteUser = function(email) {
+  return db.run(r.table('users').get(email).delete());
+};
+
 dbapi.updateUserSettings = function(email, changes) {
   return db.run(r.table('users').get(email).update({
     settings: changes
@@ -207,6 +221,10 @@
   }))
 }
 
+dbapi.getDeviceOwner = function(serial) {
+  return db.run(r.table('devices').get(serial));
+};
+
 dbapi.setDeviceOwner = function(serial, owner) {
   return db.run(r.table('devices').get(serial).update({
     owner: owner
@@ -219,6 +237,14 @@
   }))
 }
 
+dbapi.publishKickedSerial = function(serial) {
+  return db.run(r.table('kicks').insert({serial: serial}));
+};
+
+dbapi.setKickCallback = function(callback) {
+  dbapi.kickCallback = callback;
+};
+
 dbapi.setDevicePresent = function(serial) {
   return db.run(r.table('devices').get(serial).update({
     present: true
@@ -366,4 +392,19 @@
   }))
 }
 
+dbapi.getToken = function(token) {
+  return db.run(r.table('tokens').get(token));
+};
+
+dbapi.updateToken = function(tokenObj) {
+  return db.run(r.table('tokens').get(tokenObj.token).update(tokenObj));
+};
+
+dbapi.expireToken = function(token) {
+  return db.run(r.table('tokens').get(token).update({
+    status: "expired",
+    expiredTime: Date.now()
+  }));
+};
+
 module.exports = dbapi
diff --git a/crowdstf/lib/db/tables.js b/crowdstf/lib/db/tables.js
index 5dc2607..46bbfe3 100644
--- a/crowdstf/lib/db/tables.js
+++ b/crowdstf/lib/db/tables.js
@@ -57,4 +57,10 @@
 , deviceEvents: {
     primaryKey: 'id'
   }
+, tokens: {
+    primaryKey: 'token'
+  }
+, kicks: {
+    primaryKey: 'id'
+  }
 }
diff --git a/crowdstf/lib/units/app/index.js b/crowdstf/lib/units/app/index.js
index e355bd9..db71397 100644
--- a/crowdstf/lib/units/app/index.js
+++ b/crowdstf/lib/units/app/index.js
@@ -153,6 +153,28 @@
       })
   })
 
+  app.delete('/app/api/v1/token/:token', function(req, res) {
+    var token = req.params.token;
+
+    dbapi.getToken(token).then(function(tokenObj) {
+      if (tokenObj.status === 'expired') {
+        return res.send(200);
+      }
+
+      var serial = tokenObj.serial;
+
+      dbapi.deleteUser(token).then(function() {
+        dbapi.expireToken(token).then(function() {
+          dbapi.publishKickedSerial(serial).then(function() {
+            dbapi.unsetDeviceOwner(serial).then(function() {
+              res.send(200);
+            });
+          });
+        });
+      });
+    });
+  });
+
   app.get('/app/api/v1/devices', function(req, res) {
     dbapi.loadDevices()
       .then(function(cursor) {
diff --git a/crowdstf/lib/units/app/middleware/auth.js b/crowdstf/lib/units/app/middleware/auth.js
index e960f4f..02e3238 100644
--- a/crowdstf/lib/units/app/middleware/auth.js
+++ b/crowdstf/lib/units/app/middleware/auth.js
@@ -8,7 +8,11 @@
     if (req.query.jwt) {
       // Coming from auth client
       var data = jwtutil.decode(req.query.jwt, options.secret)
+      var serial = req.query.serial;
       var redir = urlutil.removeParam(req.url, 'jwt')
+      if (serial) {
+        redir = urlutil.removeParam(req.url, 'serial') + '#!/control/' + serial;
+      }
       if (data) {
         // Redirect once to get rid of the token
         dbapi.saveUserAfterLogin({
diff --git a/crowdstf/lib/units/auth/token.js b/crowdstf/lib/units/auth/token.js
new file mode 100644
index 0000000..95d5026
--- /dev/null
+++ b/crowdstf/lib/units/auth/token.js
@@ -0,0 +1,195 @@
+var http = require('http');
+
+var express = require('express');
+var validator = require('express-validator');
+var cookieSession = require('cookie-session');
+var bodyParser = require('body-parser');
+var csrf = require('csurf');
+var Promise = require('bluebird');
+var basicAuth = require('basic-auth');
+var request = require('request');
+
+var logger = require('../../util/logger');
+var requtil = require('../../util/requtil');
+var jwtutil = require('../../util/jwtutil');
+var pathutil = require('../../util/pathutil');
+var urlutil = require('../../util/urlutil');
+var lifecycle = require('../../util/lifecycle');
+var dbapi = require('../../db/api');
+
+const JWT_EXPIRE_LENGTH = 24 * 3600;
+const DEFAULT_EXPIRE_MINS = 5.0;
+const MILLIS_IN_ONE_MINUTE = 60 * 1000;
+
+module.exports = function(options) {
+  var log = logger.createLogger('auth-token');
+  var app = express();
+  var server = Promise.promisifyAll(http.createServer(app));
+
+  lifecycle.observe(function() {
+    log.info('Waiting for client connections to end');
+    return server.closeAsync();
+  });
+
+  var basicAuthMiddleware = function(req, res, next) {
+    function unauthorized(res) {
+      res.set('WWW-Authenticate', 'Basic realm=Authorization Required');
+      return res.send(401);
+    }
+
+    var user = basicAuth(req);
+
+    if (!user || !user.name || !user.pass) {
+      return unauthorized(res);
+    }
+
+    if (user.name === options.mock.basicAuth.username &&
+        user.pass === options.mock.basicAuth.password
+    ) {
+      return next();
+    }
+    else {
+      return unauthorized(res);
+    }
+  };
+
+  app.set('strict routing', true);
+  app.set('case sensitive routing', true);
+
+  app.use(cookieSession({
+    name: options.ssid,
+    keys: [options.secret]
+  }));
+  app.use(bodyParser.json());
+  app.use(csrf());
+  app.use(validator());
+
+  app.use(function(req, res, next) {
+    res.cookie('XSRF-TOKEN', req.csrfToken());
+    next();
+  });
+
+  if (options.mock.useBasicAuth) {
+    app.use(basicAuthMiddleware);
+  }
+
+  app.get('/auth/token/', function(req, res) {
+    res.clearCookie('XSRF-TOKEN');
+    res.clearCookie('ssid');
+    res.clearCookie('ssid.sig');
+    res.status(401);
+    res.send('401 Unauthorized, your auth token may have been expired.');
+  });
+
+  app.get('/auth/token/:token', function(req, res) {
+    res.clearCookie('XSRF-TOKEN');
+    res.clearCookie('ssid');
+    res.clearCookie('ssid.sig');
+    var token = req.params.token;
+    if (token) {
+      // Check if token is in db and is valid.
+      dbapi.getToken(token).then(function(tokenObj) {
+        if (tokenObj) {
+          var log = logger.createLogger('auth-token');
+          log.setLocalIdentifier(req.ip);
+
+          log.info('Authenticated token "%s"', tokenObj.token);
+          var jwtOptions = {
+            payload: {
+              email: token,
+              name: 'token'
+            },
+            secret: options.secret,
+            header: {
+              exp: Date.now() + JWT_EXPIRE_LENGTH
+            }
+          };
+          var jwtToken = jwtutil.encode(jwtOptions);
+
+          var authRedirURL = urlutil.addParams(options.appUrl, {
+            jwt: jwtToken,
+            serial: tokenObj.serial
+          });
+
+          tokenObj.jwtToken = jwtToken;
+          tokenObj.jwtOptions = jwtOptions;
+          tokenObj.status = 'active';
+          tokenObj.activeTimeStart = Date.now();
+          tokenObj.activeIP = req.ip;
+
+          if (tokenObj.expireMinutes) {
+            tokenObj.expireMinutes = parseFloat(tokenObj.expireMinutes);
+          } else {
+            tokenObj.expireMinutes = DEFAULT_EXPIRE_MINS;
+          }
+
+          setTimeout(function() {
+            console.log('Kicking user, reached timeout for token',
+                        tokenObj.token);
+            request(options.appUrl + '/app/api/v1/expireToken/' +
+                    tokenObj.token + '?authed=true',
+                    function(error, response, body) {
+                      if (error) {
+                        throw error;
+                      }
+                    });
+          }, tokenObj.expireMinutes * MILLIS_IN_ONE_MINUTE);
+
+          dbapi.updateToken(tokenObj).then(function() {
+            log.info('Marked token "%s" active', tokenObj.token);
+          });
+
+          log.info('Issuing auth redirect to "%s"', authRedirURL);
+          res.redirect(authRedirURL);
+        }
+        else {
+          return res.json(401, {
+            success: false
+          });
+        }
+      }).catch(function(err) {
+        log.error('Failed to load token "%s": ', token, err.stack);
+        return res.json(500, {
+          success: false
+        });
+      });
+    } else {
+      return res.status(400).json({
+        success: false,
+        error: 'ValidationError',
+        validationErrors: ['No token provided']
+      });
+    }
+  });
+
+  app.get('/auth/token-google', function(req, res) {
+    res.clearCookie('XSRF-TOKEN');
+    res.clearCookie('ssid');
+    res.clearCookie('ssid.sig');
+    var log = logger.createLogger('auth-token');
+    log.setLocalIdentifier(req.ip);
+
+    log.info('Authenticated Admin Token Google');
+    var jwtOptions = {
+      payload: {
+        email: 'google@google.com',
+        name: 'google'
+      },
+      secret: options.secret,
+      header: {
+        exp: Date.now() + 24 * 3600
+      }
+    };
+    var jwtToken = jwtutil.encode(jwtOptions);
+
+    var authRedirURL = urlutil.addParams(options.appUrl + '#!/devices-home', {
+      jwt: jwtToken
+    });
+
+    log.info('Issuing auth redirect to "%s"', authRedirURL);
+    res.redirect(authRedirURL);
+  });
+
+  server.listen(options.port);
+  log.info('Listening on port %d', options.port);
+};
diff --git a/crowdstf/lib/units/websocket/index.js b/crowdstf/lib/units/websocket/index.js
index e3e25f0..4b3b465 100644
--- a/crowdstf/lib/units/websocket/index.js
+++ b/crowdstf/lib/units/websocket/index.js
@@ -91,6 +91,10 @@
 
   io.use(auth)
 
+  dbapi.setKickCallback(function(serial) {
+    io.sockets.emit('forceKick', serial);
+  });
+
   io.on('connection', function(socket) {
     var req = socket.request
     var user = req.user