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