diff --git a/server.js b/server.js index e90218b..c424653 100644 --- a/server.js +++ b/server.js @@ -3,105 +3,162 @@ * * Requires express >= 4 */ - +var common = require ('./server/common'), + authorize = require ('./server/authorization'); /* -var common = require ('./server/common'), - authorize = require ('./server/authorization'), dbs = require ('./server/dbs'), files = require ('./server/files'); */ -var fs = require ('fs'), - http = require ('http'), - https = require ('https'), - express = require ('express'), - session = require ('express-session'), // session management - morgan = require ('morgan'), // logger +const fs = common.fs, // file sync, read certificates + http = common.http, // http handler + https = require ('https'), // https handler + express = require ('express'), // node server framework + session = require ('express-session'), // session management (security) + morgan = require ('morgan'), // logger //serveFavicon = require ('serve-favicon'), - bodyParser = require ('body-parser'); - //MongoStore = require ('connect-mongo')(session); // uss mongodb as session storage -var Message = require('./message.model.js'); + bodyParser = require ('body-parser'), // post request bodyparser + MongoStore = require ('connect-mongo')(session), // use mongodb as session storage + Message = require('./database/message.model.js'); var app = express(); -var http_port=8013; - https_port=8889; - /* * Init */ -/*ll common .init (); authorize.init (common); -dbs .init (common); -files .init (common); -*/ +//dbs .init (common); +//files .init (common); -// Security -app.disable ('x-powered-by'); // TODO: Disable Header information: Powerd by Express -> Information disclosure + +/* + * Security + * + * TODO: Install helmet + * https://expressjs.com/de/advanced/best-practice-security.html + * + * (Disable Header information: Powerd by Express) + * -> Information disclosure + */ +app.disable ('x-powered-by'); + +// Session Management +app.set('trust proxy', 1) // trust first proxy, neccessary for cookie secure: true flag +app.use (session({ + secret: 'ahhgylhuvh', // caesar(3) 2 letter surname + resave: false, + saveUninitialized: false, + cookie: { + maxAge: 30*24*3600*1000, // TODO: ttl for session as well (Store) + secure: true, // true for https only (since our app works only with https) + }, + name: 'om.sid', + store: new MongoStore ({mongooseConnection: common.mongoose.connection, ttl: 30*24*3600}), // mongoose + connect-mongo + //store: new MemoryStore ({checkPeriod: 24*3600*1000}), // memorystore +})); /* * Route Control */ -// Logger -app.use (morgan ('dev')); -//app.use(express.logger ( { format: 'default', stream: output_stream } )); - // Fastpaths //app.use (serveFavicon (__dirname + '/public/favicon.ico')); -// Session Management -app.use (session({ - secret: 'adluhohks', - resave: false, - saveUninitialized: false, - cookie: { - maxAge: 30*24*3600*1000, // TODO: ttl for session as well (Store) - secure: false, // true for https only - }, - name: 'om.sid', - //store: new MongoStore ({mongooseConnection: dbs.mongoose.connection, ttl: 30*24*3600}), // mongoose + connect-mongo - //store: new MemoryStore ({checkPeriod: 24*3600*1000}), // memorystore -})); -// Args +// Minimal Logging +//app.use (morgan ('dev')); +// Advanced Logging +morgan.token ('user', function (req, res) { return (req.session && req.session.user) || '-'; }); +morgan.token ('userColored', function (req, res) { + var color = 0; + if (req.session && req.session.roles) + color = req.session.roles.admin ? 31 // red + : req.session.roles.user ? 34 // blue + : 0; // no color + return '\x1b[' + color + 'm' + ((req.session && req.session.user) || '-') + '\x1b[0m'; +}); +morgan.token ('statusColored', function (req, res) { + var color = res.statusCode >= 500 ? 31 // red + : res.statusCode >= 400 ? 33 // yellow + : res.statusCode >= 300 ? 36 // cyan + : res.statusCode >= 200 ? 32 // green + : 0; // no color + return '\x1b[' + color + 'm' + (res.headersSent ? res.statusCode : '-') + '\x1b[0m'; +}); +app.use (morgan (':date[iso] :statusColored :method :url :userColored :response-time ms :res[content-length]')); + +// BodyParser +// Returns middleware that only parses json bodies. +// (https://www.npmjs.com/package/body-parser#bodyparserjsonoptions) app.use (bodyParser.json()); +// Returns middleware that only parses urlencoded bodies +// with qs library (https://www.npmjs.com/package/qs#readme) app.use (bodyParser.urlencoded({extended: true})); // API -//var api_routes = express.Router(); // express app-object routing -//app.use ('/api', api_routes); +var api_routes = express.Router(); // express app-object routing -app.use (function (req, res, done) { - console.log(req.url); - done(); -}); - -//global.__basedir = __dirname; +app.use ('/api', api_routes); // Static Files -app.use(express.static(__dirname + '/public')); // Allow server access to 'public' folder +// Allow server access to 'public' folder +app.use(express.static(__dirname + '/public')); -//app.use(express.static('resources')); +// Other stuff is NOT authorized unless logged in +//app.use (authorize.genCheckAuthorized ('user')); + +// Uploaded files +//app.use ('/uploads', express.static(__dirname + '/uploads')); // Configuring the database -var dbConfig = require('./mongodb.config.js'); -var mongoose = require('mongoose'); +//var dbConfig = require('./mongodb.config.js'); -mongoose.Promise = global.Promise; +common.mongoose.Promise = global.Promise; // Connecting to the database -//mongoose.connect(`mongodb://${server}/${dbConfig.url}`) -mongoose.connect(dbConfig.url, {useNewUrlParser: true}).then(() => { +// Local db: common.config.dbLocalConn +// Efi db: common.config.dbConn +common.mongoose.connect (common.config.dbLocalConn, {useNewUrlParser: true}) .then( () => { console.log("Successfully connected to MongoDB."); -}).catch(err => { +}).catch( err => { console.log('Could not connect to MongoDB.'); process.exit(); }); +// No error so far? Then it's a 404! +//app.use (function (req, res, next) { next (common.genError (404, req.url)); }); +//app.use (routes.errorHandler (true)); /* true: show stack traces */ -//require('./app/routes/message.route.js')(app); + +/* + * API + */ +/* +// API allowed for all +api_routes.post ('/login', authorize.login); // /api/login + +// Validate all other API calls +api_routes.use (authorize.genCheckAuthorized ('user')); +api_routes.post ('/logout', authorize.logout); + +function addRoutes (r) { + for (var e in r.routes) { + var params = r.routes[e].params ? "/" + r.routes[e].params : ""; + console.log ("Adding routes for /" + e + params + ":" + + (r.routes[e].get ? " get":" ") + (r.routes[e].post ? " post":" ") + + (r.routes[e].put ? " put":" ") + (r.routes[e].delete ? " delete":" ")); + if (r.routes[e].get) + api_routes.get ('/' + e + params, r.routes[e].get); + if (r.routes[e].post) + api_routes.post ('/' + e + params, r.routes[e].post); + if (r.routes[e].put) + api_routes.put ('/' + e + params, r.routes[e].put); + if (r.routes[e].delete) + api_routes.delete ('/' + e + params, r.routes[e].delete); + } +} +*/ app.get ('/api/ids', function (req, res) { Message.find({},{id: true}) .exec () .then(results => { @@ -169,62 +226,17 @@ app.post("/api/createMsg", function(req, res){ }); }); -// Other stuff is NOT authorized unless logged in -//app.use (authorize.genCheckAuthorized ('user')); - -// Uploaded files -//app.use ('/uploads', expr ess.static(__dirname + '/uploads')); - -// Other stuff is NOT authorized unless logged in -//app.use (authorize.genCheckAuthorized ('user')); - -// Uploaded files -//app.use ('/uploads', express.static(__dirname + '/uploads')); - -// Errors -// No error so far? Then it's a 404! -//app.use (function (req, res, next) { next (common.genError (404, req.url)); }); -//app.use (routes.errorHandler (true)); /* true: show stack traces */ // TODO: Error Handler - - /* - * API - */ -/* -// API allowed for all -api_routes.post ('/login', authorize.login); // /api/login - -// Validate all other API calls -api_routes.use (authorize.genCheckAuthorized ('user')); -api_routes.post ('/logout', authorize.logout); - -function addRoutes (r) { - for (var e in r.routes) { - var params = r.routes[e].params ? "/" + r.routes[e].params : ""; - console.log ("Adding routes for /" + e + params + ":" + - (r.routes[e].get ? " get":" ") + (r.routes[e].post ? " post":" ") + - (r.routes[e].put ? " put":" ") + (r.routes[e].delete ? " delete":" ")); - if (r.routes[e].get) - api_routes.get ('/' + e + params, r.routes[e].get); - if (r.routes[e].post) - api_routes.post ('/' + e + params, r.routes[e].post); - if (r.routes[e].put) - api_routes.put ('/' + e + params, r.routes[e].put); - if (r.routes[e].delete) - api_routes.delete ('/' + e + params, r.routes[e].delete); - } -} - addRoutes (dbs); +addRoutes (admin); addRoutes (files); */ /* * Servers */ - -http.createServer (app) .listen (http_port, function () { - console.log ("Express http server listening on port " + http_port); +http.createServer (app) .listen (common.config.httpPort, function () { + console.log ("Express http server listening on port " + common.config.httpPort); }); /* @@ -238,36 +250,36 @@ http.createServer (app) .listen (http_port, function () { * openssl x509 -req -in certrequest.csr -signkey privatekey.pem -out certificate.pem * rm certrequest.csr */ - -var options; -try { - try { - // In case it's a real certificate: add CA chain cersts (TODO: use array if required) - var ca = fs.readFileSync ('keys/ca_cert.pem'); - } catch (e) { - ca = undefined; - console.log ("Note: Can't read CA bundle: "+e); - } - if (ca != null) { - - options = { - key: fs.readFileSync ('keys/omkey.pem'), - cert: fs.readFileSync ('keys/certificate.pem'), - ca: ca - }; - https.createServer (options, app) .listen (https_port, function () { - console.log ("Express https server listening on port " + https_port); - }); +if (common.config.httpsPort) { + var options; + try { + try { + // In case it's a real certificate: add CA chain cersts (TODO: use array if required) + var ca = fs.readFileSync ('keys/ca_cert.pem'); + } catch (e) { + ca = undefined; + console.log ("Note: Can't read CA bundle: "+e); + } + if (ca != undefined) { + options = { + key: fs.readFileSync ('keys/omkey.pem'), + cert: fs.readFileSync ('keys/certificate.pem'), + ca: ca + }; + https.createServer (options, app) .listen (common.config.httpsPort, function () { + console.log ("Express https server listening on port " + common.config.httpsPort); + }); + } + } catch (e) { + console.log ("Note: Can't read SSL keys/certs: "+e+"\nDisabling https server"); } -} catch (e) { - console.log ("Note: Can't read SSL keys/certs: "+e+"\nDisabling https server"); +} else { + console.log("Note: https server disabled by config"); } - /* * Uncaught Exceptions */ - process.on ("uncaughtException", function (err) { console.error ("*** Uncaught Exception:"); console.error (err.stack); diff --git a/server/authorization.js b/server/authorization.js new file mode 100644 index 0000000..ebd0e5f --- /dev/null +++ b/server/authorization.js @@ -0,0 +1,119 @@ +/* + * Authorization + */ + +var common, User; +const ldap = require ('./ldap_ohm'); +//const crypto = require ("./crypto"); + +// deactivated is not used yet +const serverVisibleSession = { user: true, name: true, type: true, mail: true, roles: true, deactivated: true, host: true }; +const clientVisibleSession = { user: true, name: true, type: true, mail: true, roles: true }; + + +// Fill in session object +function fillSession (req, user, roles, cb) { + if (req.session === undefined) + next (common.genError (500, "Error")); + req.session.regenerate (function (err) { + if (user !== undefined && ! err) { + common.shallowCopy (user, serverVisibleSession, {roles: true}, req.session); + if (user._id) { + req.session.user = user._id; + } + req.session.roles = roles; + } + return cb (err); + }); +} + +const authorization = { + // Generate Error object suitible for throwing or next()ing + genCheckAuthorized: function (group) { + return function (req, res, next) { + if (req.session === undefined || req.session.user === undefined || + req.session.roles === undefined) + return next (common.genError (403, "Unauthorized")); + if (req.session.roles[group] === undefined) + return next (common.genError (403, "Unauthorized")); + next (); + } + }, + + // Login route: requires .user and .pwd params + login: function (req, res, next) { + var user = req.body.user || ''; + var pwd = req.body.pwd || ''; + + // Helper: Return valid session Object + function returnSession () { + // Only export client visible parts of session object + var copy = common.shallowCopy (req.session, clientVisibleSession); + return res.json (copy); + } + // Helper: Return error + function returnError () { + fillSession (req, undefined, undefined, function (err) { + next (common.genError (401, "Unauthorized")); + }); + } + + // TODO Auth: validate session ID + // Check whether to just validate current session ID + if (user === '' && pwd === '') { + console.log ("auth revalidate: " + req.session.user); + if (req.session.user === undefined) + return returnError(); + return returnSession (); + } + + // check local database + User.findById (req.body.user) .exec (function (err, entry) { + // If there is a local user AND it has a password associated, test against this, and only this + /* + if (entry != null && entry.pwd) { + if (crypto.checkLocalAuth (entry, req.body.pwd)) { + return fillSession (req, entry, common.arrayToHash(entry.roles), returnSession); + } + return returnError (); + } + */ + + // check ldap + ldap.authorize (user.toLowerCase(), pwd, function (found) { + console.log ("ldap authorize " + user + " returns " + JSON.stringify (found)); + // No ldap entry either -> unauthorized + if (found == null) { + return returnError (); + } + // If there is an entry w/o password, use it for roles etc. + if (entry) { + if (! entry.name || entry.name === "") + entry.name = found.name; + if (! entry.mail || entry.mail === "") + entry.mail = found.mail; + if (! entry.type || entry.type === "") + entry.type = found.type; + if (! entry.orclgender || entry.orclgender === "") + entry.orclgender = found.orclgender; + return fillSession (req, entry, entry.roles.length > 0 ? common.arrayToHash(entry.roles) : {user:true}, returnSession); + } + // Otherwise create standard user entry + return fillSession (req, found, {user:true}, returnSession); + }); + }); + }, + logout: function (req, res, next) { + fillSession (req, undefined, undefined, function (err) { + return res.json ({}); + }); + }, + init: function (_common) { + common = _common; + ldap.init (_common); + User = require('../database/user.model.js');; + }, +}; + + +module.exports = authorization; diff --git a/server/common.js b/server/common.js new file mode 100644 index 0000000..dd2e716 --- /dev/null +++ b/server/common.js @@ -0,0 +1,78 @@ +/* + * Common functions and imports + */ + +var common = { + fs: require('fs'), // file sync + http: require('http'), + mongoose: require('mongoose'), // Needed for db connection. + //util: require('util'), // Why is it needed here? + //fork: require('child_process') .fork, // What does that? + + // Generate Error object suitible for throwing or next()ing + // For a better exception handling + genError: function (code, message) { + var err = new Error (common.http.STATUS_CODES[code] + (message != undefined && message != "" ? ": "+message : "")); + err.status = code; + // to generally disable stack traces for these manually created error Objects: + delete err.stack; + return err; + }, + + // Generate deep copy + // Only include properties incl (all if undefined), strip properties excl (associative arrays) + deepCopy: function (inp, incl, excl) { + // For now, JSON is considered fastest / easiest + var obj = JSON.parse (JSON.stringify (inp)); + if (incl) { + for (var k in obj) { + if (incl[k] === undefined) + delete obj[k]; + } + } + if (excl) { + for (var k in excl) { + delete obj[k]; + } + } + return obj; + }, + + // Create shallow (1 level) copy of object, use obj if already present (merge) + // Only include properties incl (all if undefined), strip properties excl (associative arrays) + shallowCopy: function (inp, incl, excl, obj) { + var keys = inp; + if (obj === undefined) + obj = {}; + if (typeof inp == "array") + obj = []; + if (incl !== undefined) + keys = incl; + for (var k in keys) { + if (inp[k] !== undefined && (excl === undefined || ! excl[k])) + obj[k] = inp[k]; + } + return obj; + }, + + // Create hash of 'true' entries for array/mongoose object + arrayToHash: function (array) { + var hash = {}; + for (var e=0; e < array.length; e++) { + hash[array[e]] = true; + } + return hash; + }, + + // Log output session cookie + debug: function (req) { + console.log ("- " + req.headers.cookie + "\n+ " + req.session.id + "\n " + JSON.stringify (req.session)); + }, + + // Init config data + init: function () { + this.config = JSON.parse (this.fs.readFileSync ("server_config.json")); + }, +}; + +module.exports = common; diff --git a/server/ldap_ohm.js b/server/ldap_ohm.js new file mode 100644 index 0000000..99e0977 --- /dev/null +++ b/server/ldap_ohm.js @@ -0,0 +1,104 @@ +/* + * Valdiate ohm logins with ldap service + */ +const ldap = require('ldapjs'); +const ldap_escape = require('ldap-escape'); + + +// TODO: Where do I get the URL from?? A: Is given. +var ldap_client = ldap.createClient({ + //url: 'ldap://gso2.ads1.fh-nuernberg.de/', + url: 'ldap://sso.cs.ohm-hochschule.de:389/', + //url: 'ldaps://sso.cs.ohm-hochschule.de:636/', + reconnect: true, + // timeouts don't work reliably +}); + +// TODO: Where do I get the 'bindpath' parameters info from? A: Is given. +const ldap_config = { + bindpath: 'cn=Users,dc=ohm-hochschule,dc=de', + timeout: 2000 +}; + +const ldap_ohm = { + init: function () { + }, + + // Authorize user with password + // Calls callback with null if unauthorized + // Calls callback with object describing user if successful: + authorize: function (user, pwd, cb) { + if (typeof user != 'string' || typeof pwd != 'string') + return cb (null); + // Empty passwords *may* bind successfully anonymously + if (user.length < 1 || pwd.length < 1) + return cb (null); + + /* Same function, different writing style */ + /* Escape ldap login input */ + //escaped = ldap_escape.dn`cn=${user},`+ldap_config.bindpath; + escaped = ldap_escape.dn (['cn=',','+ldap_config.bindpath], user); + + // Timeout handler: call callback, + // make sure later ldap returns don't do anything weird + var return_object = {}; + var timeoutHandle = setTimeout (function () { + console.log('ldap timeout'); + return_object = null; + cb (null); + }, ldap_config.timeout); + + // Bind ldap to user (authorize) + ldap_client.bind (escaped, pwd, function (err, res) { + if (return_object === null) + return; // Timeout, cb has already been called + if (err !== null) { + console.log ("ldap bind: failed for user " + user + ": " + err); + clearTimeout (timeoutHandle); + return cb (null); + } + + // Search for user entry of just bound user + // There should be only one... + ldap_client.search (escaped, { sizeLimit: 1 }, function (err, res) { + if (return_object === null) + return; // Timeout, cb has already been called + if (err !== null) { + console.log ("ldap search: search after bind didn't work for user " + + user + ": " + err); + clearTimeout (timeoutHandle); + return cb (null); + } + // Populate return with search results + res.on('searchEntry', function(entry) { + if (return_object === null) + return; // Timeout, cb has already been called + return_object.user = user; + return_object.name = entry.object.displayname; + return_object.type = entry.object.employeetype; + return_object.mail = entry.object.mail; + return_object.gender = entry.object.orclgender; + + // Calling cb here, not in 'end', because of potential bugs with + // concurrency failures, and we have our single(!) entry + // https://github.com/joyent/node-ldapjs/pull/424 + clearTimeout (timeoutHandle); + if (typeof return_object.mail != 'string' || return_object.mail.length < 1) { + console.log("ldap search error after bind for user " + user); + return cb (null); + } + return cb (return_object); + }); + res.on('error', function(err) { + console.log('ldap error: ' + err.message); + }); + res.on('end', function(result) { + // TODO: Did we forget something? + // TODO: analyze result.status? + }); + }); + }); + } +}; + +module.exports = ldap_ohm; diff --git a/server/ldap_test.js b/server/ldap_test.js new file mode 100644 index 0000000..a899f74 --- /dev/null +++ b/server/ldap_test.js @@ -0,0 +1,35 @@ +// Terminal call: node server/ldap_test.js - needs VPN or eduroam +const inquirer = require('inquirer'), + ldap = require('./ldap_ohm.js'); + +inquirer.prompt([ + { + name: 'username', + type: 'input', + message: 'Enter your VirtuOhm username:', + validate: function( value ) { + if (value.length) { + return true; + } else { + return 'Please enter your username.'; + } + } + }, + { + name: 'password', + type: 'password', + message: 'Enter your password:', + validate: function(value) { + if (value.length) { + return true; + } else { + return 'Please enter your password.'; + } + } + }]) + .then(answers => { + ldap.init(null); + ldap.authorize(answers.username,answers.password,function(user) { + console.log(JSON.stringify(user)); + }); + }); diff --git a/server_config.json b/server_config.json new file mode 100644 index 0000000..bd0f6aa --- /dev/null +++ b/server_config.json @@ -0,0 +1,9 @@ +{ + "dbConn": "mongodb://127.0.0.1:27017/om", + "dbLocalConn": "mongodb://localhost:27017/omdb", + "dbUser": "om", + "dbPwd": "aeg1phuKeDaixese", + "initialReservedTime": 120, + "httpPort": 8013, + "httpsPort": 8889 +}