diff --git a/README.md b/README.md index 278c960..51408c7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ It makes running a Node deployment server as painless as possible. ## How does it work? -After starting Ishiki, an [API](#api) will be made available. With this API, you can deploy applications and manage +After starting Ishiki, an [API](#api) will become available. With this API, you can deploy applications and manage them. If your application requires a specific version of Node, it will be set up automatically for you. Each application will run on its own IP:port internally, while being proxied through the domains specified on your app on whatever public port you want your sites to run on (e.g. 80). @@ -39,6 +39,19 @@ Usage: ishiki ``` +### First time + +When starting Ishiki for the first time, a default admin user will be created for you and a random password will be generated. +Ishiki should output something along the lines of: + +``` +Initial admin account created: +> username: ishiki +> password: 12345667890abcdef +``` + +Make sure you take good note of the password (you can change it later). + ## Configuration By default, Ishiki will run on the following settings: @@ -59,6 +72,11 @@ By default, Ishiki will run on the following settings: "database": "ishiki" }, "logs-size": 100000, + "auth": { + "active": true, + "admin": "ishiki", + "token_expiry": 1800 + }, "haibu": { "env": "development", "advanced-replies": true, @@ -74,7 +92,7 @@ By default, Ishiki will run on the following settings: } ``` -Copy `config.sample.js` to `config.js` and modify if you want your own settings. +Copy `config.sample.js` to `config.js` and modify it if you want your own settings. * `host` is the host Ishiki and its API will run on * `port` is the port Ishiki and its API will run on @@ -83,6 +101,7 @@ Copy `config.sample.js` to `config.js` and modify if you want your own settings. * `port-range` is the range of ports the apps will listen on internally before being proxied * `mongodb` is the configuration for the MongoDB database * `logs-size` is the cap on the `log` MongoDB collection where all the user/app logs go +* `auth` is for authentication. Set `active` to `false` to disable authentication, `admin` is the default admin username, `token_expiry` is the time in seconds a token can remain valid without activity (`false` for no expiry) * `haibu` is whatever settings are available to the haibu module __Running Ishiki over HTTPS__ @@ -105,8 +124,94 @@ the `-k` (or `--insecure`) flag to ignore the verification. ## API -Ishiki provides its own API +Ishiki provides its own API. + +With authentication turned on (default), all calls (except for `/users/login`) will need to explicitly specify a `token` in the URL, such as: +```bash +://:/?token= +``` + +The authentication token can be created with the help of [`/users/login`](#login) + +#### _Permissions_ +With the exception of logging in, permissions are as follow: +* [__users__](#users): admins can perform any action for any user, non-admins can only update their own password +* [__drones__](#drones): admins can performs any action for any user, non-admins can only perform actions relating to their own drones (where `/:userid` is present) +* [__proxies__](#proxies): only admins may use this + + +### Users + +#### `/users` (`GET`) +Returns a list of all users + +#### Call example +```bash +curl -X GET ://:/users?token= +``` + +#### Response +```json +[ { "_id" : "51b12470b4a898d990000001", + "admin" : true, + "last_access" : "2013-06-08T22:47:22.828Z", + "password" : "$2a$10$TtuNxZzX3bHdQSURpLLv4OHZ1QjbW2Fy6yRs3Cv1p6w414OnoOnTi", + "token" : "d22b9961e33700436c76acfab2051ba73276b7fb5aa9e57bb1343fc9e5b1524f", + "username" : "ishiki" + } ] +``` + +--- + +#### `/users` (`POST`) +Creates a new user, if `password` is not provided, one will be generated. Set `admin` to `true` to give the new user admin rights. + +##### Call example +```bash +curl -X POST -H 'Content-Type: application/json' -d '{"username": "myuser"}' ://:/users?token= +``` + +##### Response +```json +{ "_id" : "51b390b90808e68d93000067", + "admin" : false, + "password" : "52360f1b10488ae7", + "username" : "myuser" +} +``` + +--- + + +#### `/users/login` (`POST`) +Returns an authentication token to be used for all other calls + +##### Call example +```bash +curl -X POST -H 'Content-Type: application/json' -d '{"username": "myuser", "password": "mypassword"}' ://:/users/login +``` + +##### Response +```json +{ "token" : "f2623f7d089e58069caf123bda4eba614b30b67e20f90074bf7dfd6241e2e0e1" } +``` + +--- + +#### `/users/:userid` (`POST`) +Updates a user, non-admin users can only update their own password, admins can update any details of any users with the exception of the `username` + +##### Call example +```bash +curl -X POST -H 'Content-Type: application/json' -d '{"password": "mynewpassword"}' ://:/users/myuser?token= +``` + +#### Response +```json +{ "message" : "Updated password" } +``` + ### Drones #### `/drones` (`GET`) @@ -114,7 +219,7 @@ Returns a list of all drones ##### Call example ```bash -curl -X GET :/drones +curl -X GET ://:/drones?token= ``` ##### Response @@ -152,7 +257,7 @@ Returns all drones for a given user ##### Call example ```bash -curl -X GET :/drones/user1 +curl -X GET ://:/drones/user1?token= ``` ##### Response @@ -165,7 +270,7 @@ Returns drone info for given user/app ##### Call example ```bash -curl -X GET :/drones/user1/site1 +curl -X GET ://:/drones/user1/site1?token= ``` ##### Response @@ -178,7 +283,7 @@ Returns all running drones ##### Call example ```bash -curl -X GET :/drones/running +curl -X GET ://:/drones/running?token= ``` ##### Response @@ -191,7 +296,7 @@ Deploys an app from a tarball for given user/app, with Curl from your app's dire ##### Call example ```bash -tar -cz . | curl -XPOST -m 360 -sSNT- :/drones/user1/site1/deploy +tar -cz . | curl -XPOST -m 360 -sSNT- ://:/drones/user1/site1/deploy?token= ``` ##### Response @@ -208,7 +313,7 @@ Starts a previously stopped drone for given user/app ##### Call example ```bash -curl -X POST :/drones/user1/site1/start +curl -X POST ://:/drones/user1/site1/start?token= ``` ##### Response @@ -244,7 +349,7 @@ Stops a running drone for given user/app ##### Call example ```bash -curl -X POST :/drones/user1/site1/stop +curl -X POST ://:/drones/user1/site1/stop?token= ``` ##### Response @@ -257,7 +362,7 @@ Restarts a running drone for given user/app ##### Call example ```bash -curl -X POST :/drones/user1/site1/restart +curl -X POST ://:/drones/user1/site1/restart?token= ``` ##### Response @@ -275,7 +380,7 @@ Returns or streams the logs for a given app with optional filtering ##### Call example - basic ```bash -curl -X GET -H 'Content-Type: application/json' -d '{"limit": 2}' :/drones/user1/site1/logs +curl -X GET -H 'Content-Type: application/json' -d '{"limit": 2}' ://:/drones/user1/site1/logs?token= ``` ##### Response (JSON) @@ -299,7 +404,7 @@ curl -X GET -H 'Content-Type: application/json' -d '{"limit": 2}' ::/drones/user1/site1/logs +curl -X GET -H 'Content-Type: application/json' -d '{"stream": true}' ://:/drones/user1/site1/logs?token= ``` ##### Response (plain text) @@ -309,6 +414,7 @@ curl -X GET -H 'Content-Type: application/json' -d '{"stream": true}' ### Proxy #### `/proxies` (`GET`) @@ -316,7 +422,7 @@ Returns a list of all proxies and associated routes ##### Call example ```bash -`curl -X GET :/proxies` +curl -X GET ://:/proxies?token= ``` ##### Response @@ -348,7 +454,7 @@ Returns a list of all routes for proxy on given port ##### Call example ```bash -curl -X GET :/proxies/80 +curl -X GET ://:/proxies/80?token= ``` ##### Response example @@ -376,7 +482,7 @@ Starts a proxy on given port ##### Call example ```bash -curl -X POST :/proxies/1234 +curl -X POST ://:/proxies/1234?token= ``` ##### Response @@ -392,7 +498,7 @@ and target `host` and `port` provided in `POST`. Routes created like this will b ##### Call example ```bash -curl -X POST -H 'Content-Type: application/json' -d '{"port": "12500","host": "internal.ip","domain": "my.domain"}' :/proxies/80/set +curl -X POST -H 'Content-Type: application/json' -d '{"port": "12500","host": "internal.ip","domain": "my.domain"}' ://:/proxies/80/set?token= ``` ##### Response @@ -407,7 +513,7 @@ Stops and removes proxy and associated routes on given port ##### Call example ```bash -curl -X POST :/proxies/1234/delete_proxy +curl -X POST ://:/proxies/1234/delete_proxy?token= ``` ##### Response @@ -432,7 +538,7 @@ In this case `POST` can have any of the following values for matching: ##### Call example ```bash -curl -X POST -H 'Content-Type: application/json' -d '{"domain":"my.domain"}' :/proxies/1234/delete_route +curl -X POST -H 'Content-Type: application/json' -d '{"domain":"my.domain"}' ://:/proxies/1234/delete_route?token= ``` ##### Response @@ -447,7 +553,7 @@ Returns all routes for given user for proxy on given port ##### Call example ```bash -curl -X GET :/proxies/80/user1 +curl -X GET ://:/proxies/80/user1?token= ``` ##### Response @@ -475,7 +581,7 @@ Returns all routes for given user/app for proxy on given port ##### Call example ```bash -curl -X GET :/proxies/80/user1/site1 +curl -X GET ://:/proxies/80/user1/site1?token= ``` ##### Response @@ -497,7 +603,7 @@ Deletes route for given user/app for proxy on given port ##### Call example ```bash -curl -X POST :/proxies/80/user1/site1/delete +curl -X POST ://:/proxies/80/user1/site1/delete?token= ``` ##### Response @@ -522,11 +628,12 @@ Ishiki will use one of the ports within the proxy port range defined in your con * [union (0.3.6)](https://github.com/flatiron/union/tree/v0.3.6) * [flatiron (0.3.3)](https://github.com/flatiron/flatiron/tree/v0.3.3) -* [haibu (0.9.7)](https://github.com/nodejitsu/haibu) +* [haibu (0.10.1)](https://github.com/nodejitsu/haibu/tree/v0.10.1) * [semver (1.1.2)](https://github.com/isaacs/node-semver/tree/v1.1.2) -* [tar (0.1.14)](https://github.com/isaacs/node-tar/tree/v0.1.14) -* [http-proxy (0.8.7)](https://github.com/nodejitsu/node-http-proxy/tree/v0.8.7) -* [mongodb (1.2.x)](https://github.com/mongodb/node-mongodb-native/tree/V1.2.10) +* [tar (0.1.17)](https://github.com/isaacs/node-tar/tree/v0.1.17) +* [http-proxy (0.10.2)](https://github.com/nodejitsu/node-http-proxy/tree/v0.10.2) +* [mongodb (1.2.x)](https://github.com/mongodb/node-mongodb-native/tree/V1.2.14) +* [bcrypt (0.7.x)](https://github.com/ncb000gt/node.bcrypt.js/tree/0.7.5) ## Requirements diff --git a/config.sample.json b/config.sample.json index 96fa9b6..1f2960a 100644 --- a/config.sample.json +++ b/config.sample.json @@ -18,6 +18,11 @@ "database": "ishiki" }, "logs-size": 100000, + "auth": { + "active": true, + "admin": "ishiki", + "token_expiry": 1800 + }, "haibu": { "env": "development", "advanced-replies": true, diff --git a/index.js b/index.js index 792aa40..c183a08 100644 --- a/index.js +++ b/index.js @@ -51,6 +51,11 @@ app.config.defaults({ database: 'ishiki' }, 'logs-size': 100000, + auth: { + active: true, + admin: 'ishiki', + token_expiry: 1800 + }, haibu: { env: 'development', 'advanced-replies': true, @@ -117,7 +122,6 @@ drone.packagesDir = app.config.get('haibu:directories:packages'); if (app.config.get('haibu')) haibu.config.defaults(app.config.get('haibu')); - //set up proxy var http_proxy = require('./lib/proxy').Proxy, proxy = new http_proxy(app, haibu); @@ -131,6 +135,15 @@ proxy.autoload(); //define routes require('./lib/ishiki')(app, haibu, path, fs, drone, proxy); +if (app.config.get('auth:active')) { + //authentication + var auth = require('./lib/auth').Auth, + user_auth = new auth(app, haibu); + + //check permissions on each request + app.http.before.push(user_auth.check.bind(user_auth)); +} + //start ishiki app.start(app.config.get('port'), app.config.get('host'), function() { console.log('Haibu Ishiki started on ' + app.config.get('host') + ':' + app.config.get('port')); diff --git a/lib/auth.js b/lib/auth.js new file mode 100644 index 0000000..3d646e6 --- /dev/null +++ b/lib/auth.js @@ -0,0 +1,363 @@ +/* + The MIT License + + Copyright (c) 2013 Hadrien Jouet https://github.com/grownseed + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +var crypto = require('crypto'), + bcrypt = require('bcrypt'), + userModel = require('../models/user'); + +var Auth = exports.Auth = function(app, haibu) { + var self = this, + admin_username = app.config.get('auth:admin'); + + this.app = app; + this.haibu = haibu; + + //make sure default admin user is created + userModel.get({username: admin_username}, function(err, users) { + if (users.length == 0) { + self.addUser({username: admin_username, admin: true}, function(err, user) { + if (err) + return console.log(err); + + console.log('Initial admin account created:\n> username: ' + user.username + '\n> password: ' + user.password); + }); + } + }); + + //users api + app.router.path('/users', function() { + //return all users + this.get(function() { + var route = this; + + userModel.get(function(err, users) { + if (err) + return haibu.sendResponse(route.res, 500, err); + + haibu.sendResponse(route.res, 200, users); + }); + }); + + //new user + this.post(function() { + var route = this, + err = null; + + if (route.req.body) { + if (!route.req.body.username || (route.req.body.username && !route.req.body.username.trim())) + err = 'A username is required'; + }else{ + err = 'New user details missing'; + } + + if (err) + return haibu.sendResponse(route.res, 500, {message: err}); + + self.addUser(route.req.body, function(err, user) { + if (err) + return haibu.sendResponse(route.res, 500, err); + + haibu.sendResponse(route.res, 200, user); + }); + }); + + //login + this.post('/login', function() { + var route = this; + + self.login(route.req.body, function(err, token) { + if (err) + return haibu.sendResponse(route.res, 500, err); + + haibu.sendResponse(route.res, 200, {token: token}); + }); + }); + + //switch admin status + this.post('/:userid', function(userid) { + var route = this, + user; + + function updateUser() { + userModel.get({username: userid}, function(err, users) { + if (err) + return haibu.sendResponse(route.res, 500, err); + + if (users.length == 0) + return haibu.sendResponse(route.res, 404, {message: 'User does not exist'}); + + userModel.edit(users[0]._id.toString(), {$set: user}, function(err, result) { + if (err) + return haibu.sendResponse(route.res, 500, err); + + var update_keys = Object.keys(user), + msg = update_keys.length > 0 ? 'Updated ' + update_keys.join(', ') : 'Nothing to update'; + + haibu.sendResponse(route.res, 200, {message: msg}); + }); + }); + } + + //only allow self to update password if not admin + if (!route.req.user.admin) + user = {password: route.req.body.password}; + else + user = route.req.body; + + //don't allow username change + delete user.username; + + if (!user.password || (user.password && !user.password.trim())) { + delete user.password; + + updateUser(); + }else{ + user.password = self._encryptPassword(user.password, function(err, hash) { + if (err) + return haibu.sendResponse(route.res, 500, err); + + user.password = hash; + + updateUser(); + }); + } + }); + }); +}; + +//check permissions +Auth.prototype.check = function(req, res, next) { + var self = this, + url = req.url.split('?'), + route = url[0].split('/'), + params = (url[1] && url[1].trim() ? url[1].split('&') : []), + parsed_params = {}; + + //parse params + if (params.length > 0) { + for (var i = 0, n = params.length; i < n; i++) { + var param = params[i].split('='); + + parsed_params[decodeURIComponent(param[0])] = decodeURIComponent(param[1]); + } + } + + function errorMsg(err, status, expired) { + if (expired) + return self.haibu.sendResponse(res, 401, {message: 'Your authentication token has expired'}); + + self.haibu.sendResponse(res, status, err); + } + + function respond(err, user) { + if (err) + errorMsg(err, 500); + + var now = new Date(), + token_expiry = self.app.config.get('auth:token_expiry'), + expired = false; + + if (user) { + //check for outdated token + if (!user.last_access || (user.last_access && token_expiry)) { + if (!user.last_access || (now.getTime() - user.last_access.getTime()) / 1000 > token_expiry) { + expired = true; + }else{ + //update token expiry + userModel.edit(user._id.toString(), {$set: {last_access: now}}, function(){}); + + //assign user to request + req.user = user; + } + } + + //if admin, no need to check further + if (user.admin && !expired) + return next(); + } + + //0: go ahead, 1: require auth, 2: require admin + var require_auth = 0; + + if (route.length > 1) { + switch (route[1]) { + case 'drones': + case 'users': + if (user) { + if (!route[2] || + (route[2] && + (route[2] != user.username.toString() || + (route[1] == 'users' && route[3] && route[3] == 'admin')))) + require_auth = 2; + }else{ + require_auth = 1; + } + break; + //proxy stuff admin only + case 'proxies': + require_auth = 2; + break; + } + + if (!require_auth) { + return next(); + }else{ + if (require_auth == 1) + errorMsg({message: 'You need to be authenticated to access this resource'}, 401, expired); + else + errorMsg({message: 'You are not authorized to access this resource'}, 403, expired); + } + }else{ + return next(); + } + } + + //only check if route needs to be checked + if ((route.length > 1 && ['drones', 'users', 'proxies'].indexOf(route[1]) == -1) || + req.url == '/users/login') + return next(); + + //look for active token + if (parsed_params.token) { + userModel.get({token: parsed_params.token}, function(err, users) { + if (err) + return respond(err); + + if (users.length == 1) + return respond(null, users[0]); + + respond(); + }); + }else{ + respond(); + } +}; + +//generate a random string +Auth.prototype._randomString = function(n, callback) { + if (!callback) { + callback = n; + n = 8; + } + + crypto.randomBytes(n, function(ex, buf) { + callback(buf.toString('hex')); + }); +}; + +//encrypt password +Auth.prototype._encryptPassword = function(password, callback) { + bcrypt.genSalt(10, function(err, salt) { + if (err) + return callback(err); + + bcrypt.hash(password, salt, callback); + }); +}; + +//log user in and generate new token +Auth.prototype.login = function(user, callback) { + var self = this; + + if (!user.username || !user.password) + return callback({message: 'Please provide a username and a password'}); + + //find user + userModel.get({username: user.username}, function(err, users) { + if (err) + return callback(err); + + //generic message to avoid figuring out usernames + var user_err = {message: 'Username/Password could not be matched'}; + + if (users.length == 0) + return callback(user_err); + + //compare passwords + bcrypt.compare(user.password, users[0].password, function(err, match) { + if (err || !match) + return callback(user_err); + + //save and return token + self._randomString(32, function(token) { + userModel.edit(users[0]._id.toString(), {$set: {token: token, last_access: new Date()}}, function(err, result) { + if (err) + return callback(user_err); + + callback(null, token); + }); + }); + }); + }); +}; + +//create a new user +Auth.prototype.addUser = function(user, callback) { + var self = this; + + if (!user.username) + return callback({message: 'You need to specify a username'}); + + //default to non-admin + if (!user.admin) + user.admin = false; + + //check username doesn't already exist and add it if not + function createUser(user) { + userModel.get({username: user.username}, function(err, users) { + if (err) + return callback(err); + + if (users.length > 0) + return callback({message: 'Username is already taken'}); + + self._encryptPassword(user.password, function(err, password) { + if (err) + return callback(err); + + var clear_password = user.password; + user.password = password; + + userModel.add(user, function(err, result) { + if (err) + return callback(err); + + user.password = clear_password; + + callback(null, user); + }); + }); + }); + } + + //generate password if not provided + if (!user.password) { + this._randomString(function(pw) { + user.password = pw; + createUser(user); + }); + }else{ + createUser(user); + } +}; \ No newline at end of file diff --git a/lib/drone.extend.js b/lib/drone.extend.js index 9df2347..23c44bf 100644 --- a/lib/drone.extend.js +++ b/lib/drone.extend.js @@ -28,12 +28,7 @@ module.exports = function (userId, appId, stream, callback) { // Handle error caused by `zlib.Gunzip` or `tar.Extract` failure // function onError(err) { - err.usage = 'tar -cvz . | curl -sSNT- HOST/deploy/USER/APP'; - err.blame = { - type: 'system', - message: 'Unable to unpack tarball' - }; - return callback(err); + return callback({message: 'Unable to unpack tarball'}); } function onEnd() { @@ -71,6 +66,10 @@ module.exports = function (userId, appId, stream, callback) { if (!pkg.user || (pkg.user && pkg.user.trim() == '')) errors.push('`user` is required'); + //check user in package matches authenticated user + if (!stream.user.admin && pkg.user && pkg.user != stream.user.username) + errors.push('The authenticated user does not match `user`'); + //check for start script if (!pkg.scripts) { errors.push('`scripts` is required'); diff --git a/models/user.js b/models/user.js new file mode 100644 index 0000000..16ed880 --- /dev/null +++ b/models/user.js @@ -0,0 +1,27 @@ +/* + The MIT License + + Copyright (c) 2013 Hadrien Jouet https://github.com/grownseed + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +var BaseModel = require('./_base').BaseModel; + +module.exports = new BaseModel({collection: 'user'}); \ No newline at end of file diff --git a/package.json b/package.json index f486cfe..8251e7b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "haibu-ishiki", - "description": "Wrapper for haibu, http-proxy and carapace with automatic node versions", - "version": "0.1.3", + "description": "Node deployment server - wrapper for haibu, http-proxy and carapace with permissions and automatic node versions", + "version": "0.2.0", "author": { "name": "Hadrien Jouet", "email": "hj@grownseed.net" @@ -25,7 +25,8 @@ "semver": "1.1.2", "tar": "0.1.17", "http-proxy": "0.10.2", - "mongodb": "1.2.x" + "mongodb": "1.2.x", + "bcrypt": "0.7.x" }, "main": "index.js", "preferGlobal": true,