diff --git a/Makefile b/Makefile index c819994..bf49d4a 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ PACKAGEVERSIONSUFFIX := -$(shell uname)-$(shell uname -m) # list of modules MODULES += dashboard MODULES += search +MODULES += auth MODULES += depends MODULES += depends/nodejs MODULES += gui/backend diff --git a/auth/.module.install b/auth/.module.install new file mode 100644 index 0000000..3159541 --- /dev/null +++ b/auth/.module.install @@ -0,0 +1,4 @@ +mindbender-auth bin/mindbender.d/ +start-mongodb bin/mindbender.d/ +stop-mongodb bin/mindbender.d/ + diff --git a/auth/README.md b/auth/README.md new file mode 100644 index 0000000..e32b867 --- /dev/null +++ b/auth/README.md @@ -0,0 +1,63 @@ +# Authentication and Authorization + +It is possible to use Mindbender with authentication and grant access only +to users which you have authorized. + +## Authentication + +You first have to create an account at +``` +https://console.developers.google.com/project +``` + +* Select ```APIs & auth``` on the left, and then ```Credentials```. +* Click ```Add Credentials```. Choose a name. +* Add an ```Authorized JavaScript origin```. For testing, add + ``` + http://localhost:8000 + ``` +* Add an ```Authorized redirect URI```. For testing, add + ``` + http://localhost:8000/auth/google/callback + ``` +* Copy the Client ID and Client secret shown at the top into your + ``` + mindbender/auth/auth-api.coffee + ``` +* Finally, choose if you would like to require users to authenticate + by setting + ``` + REQUIRES_LOGIN = true + ``` + +## Authorization + +In addition to requiring users to authenticate, you can also +selectively grant access to some users. + +* Make sure you are running the authorization mongo backend, by + executing + ``` + mindbender auth start + ``` + Note: You can run ```mindbender auth stop``` to quit this backend. + +* In ```mindbender/auth/auth-api.coffee``` set variable ```AUTHORIZED_ONLY = true```. + +* Now you can use the following commands to define your set of authorized + users. + + ``` + # Show authorized users: + curl http://localhost:8000/api/auth/authorized + + # Add authorized user: + curl -H "Content-Type: application/json" -d '{"googleID":"000000000000000000000"}' http://localhost:8000/api/auth/authorized + + # Remove authorized user: + curl -X DELETE http://localhost:8000/api/auth/authorized/000000000000000000000 + ``` +* Users will see their Google ID when their login fails, with a message to + request access by sending an email to ```REQUEST_EMAIL```. + + diff --git a/auth/config/mongod.yml b/auth/config/mongod.yml new file mode 100644 index 0000000..e69de29 diff --git a/auth/mindbender-auth b/auth/mindbender-auth new file mode 100755 index 0000000..086ccfc --- /dev/null +++ b/auth/mindbender-auth @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# mindbender-auth -- Creates and maintains database of authorized users +# +# To launch the database: +# $ mindbender auth start +# +set -eu +set -o pipefail + +# parse command-line args +case ${1:-} in + start) + ${MONGODB_RUNNING:-false} || + exec start-mongodb + Action=$1; shift + ;; + stop) + exec stop-mongodb + Action=$1; shift + ;; + *) + usage "$0" "Invalid action given: gui, update, status, or drop" +esac + diff --git a/auth/start-mongodb b/auth/start-mongodb new file mode 100755 index 0000000..8a89739 --- /dev/null +++ b/auth/start-mongodb @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# start-mongodb -- Launches mongod instance +# $ start-mongodb +# Sets environment MONGODB_RUNNING=true when running COMMAND. +# +# Modeled after search/keep-elasticsearch-during. +## +# Author: Raphael Hoffmann +# Created: 2015-09-18 +set -eu + +# default settings +: ${MONGODB_BASEURL:=http://localhost:27017} # TODO randomize port? +: ${MONGODB_HOME:="$PWD"/auth} + +# make sure mongodb instance is available +mongodb-is-up() { + STDERR=$(mongo --eval "printjson(db.isMaster())" 2>&1 >/dev/null) + if [[ -z "$STDERR" ]]; then + return 0 # true + else + return 1 # false + fi +} +case $MONGODB_BASEURL in + http://localhost:*) + port=${MONGODB_BASEURL#http://localhost:} + port=${port%%/*} + # make sure search repo is initialized + mkdir -p "$MONGODB_HOME"/{config,data,logs,data/db} + [[ -e "$MONGODB_HOME"/config/mongod.yml ]] || cp -f "$MINDBENDER_HOME"/depends/bundled/mongodb/prefix/*/config/mongod.yml "$MONGODB_HOME"/config/ + terminate-local-mongodb() { + local pidfile="$MONGODB_HOME"/mongodb.pid + local pid=$(cat "$pidfile" 2>/dev/null) + # kill the mongod process + [[ -z "$pid" ]] || kill -TERM $pid || + # or just clean up the stale PID file if can't kill + rm -f "$MONGODB_HOME"/mongodb.pid + } + # terminate if something's running locally but perhaps on a different port + if [[ -e "$MONGODB_HOME"/mongodb.pid ]]; then + mongodb-is-up || terminate-local-mongodb + fi + # if no instance is running here yet + if ! [[ -e "$MONGODB_HOME"/mongodb.pid ]]; then + if mongodb-is-up; then + # port may be occupied + error "$port: port is already used, try another one, e.g.: export MONGODB_BASEURL=http://localhost:270${RANDOM:0:2}" + else + # launch an isolated elasticsearch + msg "Launching Mongodb for $MONGODB_BASEURL from $MONGODB_HOME" + mOpts+=( + #--fork + # in background with a PID file + --pidfilepath "$MONGODB_HOME"/mongodb.pid + + # some paths outside path.home + --config "$MONGODB_HOME"/config/mongod.yml + --dbpath "$MONGODB_HOME"/data/db + --logpath "$MONGODB_HOME"/logs/log + + # override ports + --port $port + ) + mongod "${mOpts[@]:-}" & + fi + fi + # wait until the instance boots up + until mongodb-is-up; do sleep 0.$RANDOM; done + ;; + + *) + # skip setup since MONGODB_BASEURL is not localhost + # make sure the mongodb instance is there + mongodb-is-up || + error "$MONGODB_BASEURL: Mongodb not responding" +esac + +# run given command +export MONGODB_BASEURL MONGODB_HOME MONGODB_RUNNING=true diff --git a/auth/stop-mongodb b/auth/stop-mongodb new file mode 100755 index 0000000..cd8538e --- /dev/null +++ b/auth/stop-mongodb @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# stop-mongodb -- Stops mongod instance +# $ stop-mongodb +# +# Modeled after search/keep-elasticsearch-during. +## +# Author: Raphael Hoffmann +# Created: 2015-09-18 +set -eu + +# default settings +: ${MONGODB_BASEURL:=http://localhost:27017} # TODO randomize port? +: ${MONGODB_HOME:="$PWD"/auth} + +# make sure mongodb instance is available +mongodb-is-up() { + mongo --eval "printjson(db.isMaster())" >/dev/null +} +case $MONGODB_BASEURL in + http://localhost:*) + terminate-local-mongodb() { + local pidfile="$MONGODB_HOME"/mongodb.pid + local pid=$(cat "$pidfile" 2>/dev/null) + echo "terminating pid $pid" + # kill the mongod process + [[ -z "$pid" ]] || { + kill -TERM $pid + rm -f "$MONGODB_HOME"/mongodb.pid + } + } + # terminate if something's running locally but perhaps on a different port + if [[ -e "$MONGODB_HOME"/mongodb.pid ]]; then + terminate-local-mongodb + fi + ;; + + *) + # skip setup since MONGODB_BASEURL is not localhost + # make sure the mongodb instance is there + error "$MONGODB_BASEURL: Not on localhost, cannot stop" +esac + diff --git a/depends/bundle.conf b/depends/bundle.conf index b75dcd4..a6e65bb 100644 --- a/depends/bundle.conf +++ b/depends/bundle.conf @@ -1,5 +1,6 @@ ### List of Embedded Dependencies +mongodb nodejs elasticsearch jq diff --git a/depends/bundled/mongodb.sh b/depends/bundled/mongodb.sh new file mode 100755 index 0000000..4a0a64d --- /dev/null +++ b/depends/bundled/mongodb.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# install mongo +set -eu + +version=${DEPENDS_ON_MONGODB_VERSION:-3.0.6} + +self=$0 +name=`basename "$0" .sh` + +download() { + local url=$1; shift + local file=$1; shift + [ -s "$file" ] || curl -C- -RLO "$url" +} + +# determine os and arch for downloading +os=$(uname -s) +case $os in + Darwin) os=osx ;; + Linux) os=linux ;; + *) + echo >&2 "$os: Unsupported operating system" + os= +esac +if [ -z "$os" ]; then + arch= +else + arch=$(uname -m) + case $arch in + x86_64|amd64) + arch=x86_64 ;; + i686|i386|i86pc) + arch=i686 ;; + *) + echo >&2 "$arch: Unsupported architecture" + os= arch= + esac +fi + +if [ -n "$os" -a -n "$arch" ]; then + # download binary distribution + fullname="mongodb-${os}-${arch}-${version}" + tarball="${fullname}.tgz" + download "https://fastdl.mongodb.org/${os}/${tarball}" "$tarball" + mkdir -p prefix + tar xf "$tarball" -C prefix +fi + +# place symlinks for commands under $DEPENDS_PREFIX/bin/ +symlink-under-depends-prefix bin -x prefix/"$fullname"/bin/* + diff --git a/gui/backend/.module.install b/gui/backend/.module.install index 174b226..9dc1d66 100644 --- a/gui/backend/.module.install +++ b/gui/backend/.module.install @@ -3,6 +3,7 @@ mindbender-utils.coffee gui/ mindtagger/ gui/ dashboard/ gui/ search/ gui/ +auth/ gui/ extensions.js gui/files/mindbender/ extensions.coffee gui/files/mindbender/ diff --git a/gui/backend/auth/auth-api.coffee b/gui/backend/auth/auth-api.coffee new file mode 100644 index 0000000..6be42b6 --- /dev/null +++ b/gui/backend/auth/auth-api.coffee @@ -0,0 +1,137 @@ +### +# Auth +### + + +# Set this to true if you want to require users to login +REQUIRES_LOGIN = false + +# Set this to true if you only want to grant access to users which you have manually added using above commands +ONLY_AUTHORIZED = true + +# Shown when access denied +REQUEST_EMAIL = "email@email" + +GOOGLE_CLIENT_ID = 'YOUR_GOOGLE_CLIENT_ID' +GOOGLE_CLIENT_SECRET = 'YOUR_GOOGLE_CLIENT_SECRET' +GOOGLE_CALLBACK_ENDPOINT = 'http://localhost:8000' + + +fs = require "fs" +util = require "util" +express = require "express" +bodyParser = require 'body-parser' +mongoose = require "mongoose" +cookieParser = require "cookie-parser" +expressSession = require "express-session" +passport = require "passport" +GoogleStrategy = require("passport-google-oauth").OAuth2Strategy + +mongoose.connect "mongodb://localhost/mindbender" + +Rejected = mongoose.model 'Rejected', { + googleID: String + timestamp: Date +} + +Authorized = mongoose.model 'Authorized', { + googleID: String +} + +exports.ensureAuthenticated = (req, res, next) -> + if !REQUIRES_LOGIN || req.isAuthenticated() + next() + else + res.redirect '/auth/google' + + +exports.configureRoutes = (app, args) -> + + app.use cookieParser() + app.use expressSession({ + secret: 'keyboard cat', + resave: true, + saveUninitialized: true + }) + app.use passport.initialize() + app.use passport.session() + + app.use bodyParser.json() + app.use (bodyParser.urlencoded extended: true) + + app.get "/api/auth/authorized", (req, res) -> + Authorized.find {}, (err, users) -> + res.send(users) + + app.post "/api/auth/authorized", (req, res) -> + googleID = req.body.googleID + na = new Authorized { googleID: googleID } + na.save (err, na) -> + if err + return res.send err + return res.send "Ok" + + app.delete "/api/auth/authorized/:googleID", (req, res) -> + Authorized.find({ 'googleID': req.params.googleID }).remove().exec() + return res.send "Ok" + + passport.serializeUser (user,done) -> + console.log "serializeUser" + done null, user + + passport.deserializeUser (obj, done) -> + console.log "deserializeUser" + done null, obj + + passport.use new GoogleStrategy({ + clientID: GOOGLE_CLIENT_ID, + clientSecret: GOOGLE_CLIENT_SECRET, + callbackURL: "#{GOOGLE_CALLBACK_ENDPOINT}/auth/google/callback" + }, + (accessToken, refreshToken, profile, done) -> + Authorized.find { 'googleID': profile.id }, (err, user) -> + if !user + return done null, false + if err + return done err + return done null, profile + + #process.nextTick () -> + # return done null, profile + ) + + app.get '/auth/google', + passport.authenticate('google', { prompt:'select_account', scope: ['https://www.googleapis.com/auth/plus.login'] }), + (req, res) -> '' + # The request will be redirected to Google for authentication, so this + # function will not be called. + + app.get '/auth/google/callback', + passport.authenticate('google', { failureRedirect: '/login' }), + (req, res) -> + googleID = req.user.id + if ONLY_AUTHORIZED + Authorized.findOne { 'googleID': googleID }, (err, user) -> + if !user || err + req.logout() + req.session.destroy (err) -> + return res.send "Access denied: Please send your GoogleID (#{googleID}) to " + + "#{REQUEST_EMAIL} to request access." + else req.session.save (err) -> + return res.redirect '/' + else + req.session.save (err) -> + return res.redirect '/' + + app.get '/user', (req, res) -> + res.send req.user + + app.get '/logout', (req, res) -> + req.logout() + req.session.destroy (err) -> + res.redirect '/' + + + + + diff --git a/gui/backend/package.json b/gui/backend/package.json index 4404b59..ca79c28 100644 --- a/gui/backend/package.json +++ b/gui/backend/package.json @@ -1,20 +1,25 @@ { - "name": "mindbender-gui-backend", - "private": true, - "version": "0.0.0", - "dependencies": { - "express": "4.9.5", - "morgan": "^1.2.2", - "body-parser": "^1.5.2", - "errorhandler": "^1.1.1", - "jade": "1.7.0", - "http-proxy": "1.11.1", - "socket.io": "1.1.0", - "fs-extra": "0.12.0", - "async": "0.9.0", - "tsv": "0.2.0", - "csv": "0.4.0", - "byline": "4.2.1", - "underscore": "1.7.0" - } + "name": "mindbender-gui-backend", + "private": true, + "version": "0.0.0", + "dependencies": { + "async": "0.9.0", + "body-parser": "^1.5.2", + "byline": "4.2.1", + "cookie-parser": "^1.3.5", + "csv": "0.4.0", + "errorhandler": "^1.1.1", + "express": "4.9.5", + "express-session": "^1.11.3", + "fs-extra": "0.12.0", + "http-proxy": "1.11.1", + "jade": "1.7.0", + "mongoose": "^4.1.7", + "morgan": "^1.2.2", + "passport": "^0.3.0", + "passport-google-oauth": "^0.2.0", + "socket.io": "1.1.0", + "tsv": "0.2.0", + "underscore": "1.7.0" + } } diff --git a/gui/backend/server.coffee b/gui/backend/server.coffee index 879fc92..e823103 100755 --- a/gui/backend/server.coffee +++ b/gui/backend/server.coffee @@ -29,6 +29,7 @@ io = socketIO.listen server app.set "port", (parseInt process.env.PORT ? 8000) app.set "views", "#{__dirname}/views" app.set "view engine", "jade" + # set up logging app.use logger "dev" @@ -47,6 +48,11 @@ process.on "uncaughtException", (err) -> else throw err +## enable authentication ###################################################### + +authApi = require "./auth/auth-api" +authApi.configureRoutes? app, cmdlnArgs + # TODO use a more sophisticated command-line parser cmdlnArgs = process.argv[2..] @@ -75,6 +81,9 @@ app.use (bodyParser.urlencoded extended: true) for component in components component.configureRoutes? app, cmdlnArgs + +app.get '/', authApi.ensureAuthenticated + ############################################################################### # user defined extensions can be put under $PWD/mindbender/extensions.{js,coffee} diff --git a/gui/frontend/src/app-navbar-signin.html b/gui/frontend/src/app-navbar-signin.html new file mode 100644 index 0000000..3f939e0 --- /dev/null +++ b/gui/frontend/src/app-navbar-signin.html @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/gui/frontend/src/app.coffee b/gui/frontend/src/app.coffee index f4f2bcf..61855fd 100644 --- a/gui/frontend/src/app.coffee +++ b/gui/frontend/src/app.coffee @@ -5,6 +5,7 @@ angular.module 'mindbender', [ 'mindbender.dashboard' 'mindbender.search' 'mindbender.extensions' + 'mindbender.auth' ] .config ($routeProvider) -> $routeProvider.when '/', diff --git a/gui/frontend/src/auth.coffee b/gui/frontend/src/auth.coffee new file mode 100644 index 0000000..a290b58 --- /dev/null +++ b/gui/frontend/src/auth.coffee @@ -0,0 +1,34 @@ +angular.module "mindbender.auth", [ + 'ngRoute' +] +.directive 'signInOutMenu', -> + scope: true + templateUrl: 'app-navbar-signin.html' + controller: ($scope, $routeParams, $location, Authentication) -> + $scope.user = Authentication.user + + +.service "Authentication", ($http, $q) -> + class Authentication + constructor: -> + @user = { + isSignedIn : false + name : '' + photo : '' + } + $http.get "/user" + .success (data) => + if data != '' + @user.isSignedIn = true + @user.name = data.displayName + if data.photos.length == 0 + @user.thumb = '' + else + @user.thumb = data.photos[0].value + else + @user.isSignedIn = false + .error (err) => + console.error err.message + init: () => + new Authentication() + diff --git a/gui/frontend/src/search/search.coffee b/gui/frontend/src/search/search.coffee index 62a14ed..6aac3b2 100644 --- a/gui/frontend/src/search/search.coffee +++ b/gui/frontend/src/search/search.coffee @@ -2,6 +2,7 @@ angular.module "mindbender.search", [ 'elasticsearch' 'json-tree' 'ngSanitize' + 'mindbender.auth' ] .config ($routeProvider) -> @@ -259,8 +260,10 @@ angular.module "mindbender.search", [ tags_schema: "styled" fields: _.object ([f,{}] for f in fieldsSearchable) @queryRunning = query + console.log JSON.stringify(query) elasticsearch.search query .then (data) => + console.log JSON.stringify(data) @error = null @queryRunning = null @query = query diff --git a/gui/frontend/src/search/search.html b/gui/frontend/src/search/search.html index 1d20d0f..b41d6f0 100644 --- a/gui/frontend/src/search/search.html +++ b/gui/frontend/src/search/search.html @@ -3,6 +3,7 @@ diff --git a/gui/frontend/src/search/searchbar.html b/gui/frontend/src/search/searchbar.html index c6315d7..115ea77 100644 --- a/gui/frontend/src/search/searchbar.html +++ b/gui/frontend/src/search/searchbar.html @@ -1,4 +1,4 @@ -