'use strict'; //////////////////////////////////////////////////////////////////////////////// /// @brief Foxx Authentication /// /// @file /// /// DISCLAIMER /// /// Copyright 2013 triagens GmbH, Cologne, Germany /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. /// You may obtain a copy of the License at /// /// http://www.apache.org/licenses/LICENSE-2.0 /// /// Unless required by applicable law or agreed to in writing, software /// distributed under the License is distributed on an "AS IS" BASIS, /// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. /// See the License for the specific language governing permissions and /// limitations under the License. /// /// Copyright holder is triAGENS GmbH, Cologne, Germany /// /// @author Jan Steemann, Lucas Dohmen /// @author Copyright 2013, triAGENS GmbH, Cologne, Germany //////////////////////////////////////////////////////////////////////////////// var db = require("@arangodb").db, crypto = require("@arangodb/crypto"), internal = require("internal"), is = require("@arangodb/is"), _ = require("lodash"), errors = internal.errors, defaultsFor = {}; function createAuthenticationMiddleware(auth, applicationContext) { return function (req) { var users = new Users(applicationContext), authResult = auth.authenticate(req); if (authResult.errorNum === errors.ERROR_NO_ERROR.code) { req.currentSession = authResult.session; req.user = users.get(authResult.session.identifier); } else { req.currentSession = null; req.user = null; } }; } function createSessionUpdateMiddleware() { return function (req) { var session = req.currentSession; if (is.existy(session)) { session.update(); } }; } function createAuthObject(applicationContext, opts) { var sessions, cookieAuth, auth, options = opts || {}; checkAuthenticationOptions(options); sessions = new Sessions(applicationContext, options); cookieAuth = new CookieAuthentication(applicationContext, options); auth = new Authentication(applicationContext, sessions, cookieAuth); return auth; } function checkAuthenticationOptions(options) { if (options.type !== "cookie") { throw new Error("Currently only the following auth types are supported: cookie"); } if (is.falsy(options.cookieLifetime)) { throw new Error("Please provide the cookieLifetime"); } if (is.falsy(options.sessionLifetime)) { throw new Error("Please provide the sessionLifetime"); } } defaultsFor.login = { usernameField: "username", passwordField: "password", onSuccess: function (req, res) { res.json({ user: req.user.identifier, key: req.currentSession._key }); }, onError: function (req, res) { res.status(401); res.json({ error: "Username or Password was wrong" }); } }; function createStandardLoginHandler(auth, users, opts) { var options = _.defaults(opts || {}, defaultsFor.login); return function (req, res) { var username = req.body()[options.usernameField], password = req.body()[options.passwordField]; if (users.isValid(username, password)) { req.currentSession = auth.beginSession(req, res, username, {}); req.user = users.get(req.currentSession.identifier); options.onSuccess(req, res); } else { options.onError(req, res); } }; } defaultsFor.logout = { onSuccess: function (req, res) { res.json({ notice: "Logged out!" }); }, onError: function (req, res) { res.status(401); res.json({ error: "No session was found" }); } }; function createStandardLogoutHandler(auth, opts) { var options = _.defaults(opts || {}, defaultsFor.logout); return function (req, res) { if (is.existy(req.currentSession)) { auth.endSession(req, res, req.currentSession._key); req.user = null; req.currentSession = null; options.onSuccess(req, res); } else { options.onError(req, res); } }; } defaultsFor.registration = { usernameField: "username", passwordField: "password", acceptedAttributes: [], defaultAttributes: {}, onSuccess: function (req, res) { res.json({ user: req.user }); }, onError: function (req, res) { res.status(401); res.json({ error: "Registration failed" }); } }; function createStandardRegistrationHandler(auth, users, opts) { var options = _.defaults(opts || {}, defaultsFor.registration); return function (req, res) { var username = req.body()[options.usernameField], password = req.body()[options.passwordField], data = _.defaults({}, options.defaultAttributes); options.acceptedAttributes.forEach(function (attributeName) { var val = req.body()[attributeName]; if (is.existy(val)) { data[attributeName] = val; } }); if (users.exists(username)) { throw new UserAlreadyExistsError(); } req.user = users.add(username, password, true, data); req.currentSession = auth.beginSession(req, res, username, {}); options.onSuccess(req, res); }; } defaultsFor.changePassword = { passwordField: "password", onSuccess: function (req, res) { res.json({ notice: "Changed password!" }); }, onError: function (req, res) { res.status(401); res.json({ error: "No session was found" }); } }; function createStandardChangePasswordHandler(users, opts) { var options = _.defaults(opts || {}, defaultsFor.changePassword); return function (req, res) { var password = req.body()[options.passwordField], successfull = false; if (is.existy(req.currentSession)) { successfull = users.setPassword(req.currentSession.identifier, password); if (successfull) { options.onSuccess(req, res); } else { options.onError(req, res); } } else { options.onError(req, res); } }; } //////////////////////////////////////////////////////////////////////////////// /// @brief constructor //////////////////////////////////////////////////////////////////////////////// function generateToken() { return internal.genRandomAlphaNumbers(32); } //////////////////////////////////////////////////////////////////////////////// /// @brief deep-copies a document //////////////////////////////////////////////////////////////////////////////// function cloneDocument(obj) { var copy, a; if (obj === null || typeof obj !== "object") { return obj; } if (Array.isArray(obj)) { copy = []; obj.forEach(function (i) { copy.push(cloneDocument(i)); }); } else if (obj instanceof Object) { copy = {}; for (a in obj) { if (obj.hasOwnProperty(a)) { copy[a] = cloneDocument(obj[a]); } } } return copy; } //////////////////////////////////////////////////////////////////////////////// /// @brief checks whether the plain text password matches the encoded one //////////////////////////////////////////////////////////////////////////////// function checkPassword(plain, encoded) { var salted = encoded.substr(3, 8) + plain, hex = crypto.sha256(salted); return (encoded.substr(12) === hex); } //////////////////////////////////////////////////////////////////////////////// /// @brief encodes a password //////////////////////////////////////////////////////////////////////////////// function encodePassword(password) { var salt, encoded, random; random = crypto.rand(); if (random === undefined) { random = "time:" + internal.time(); } else { random = "random:" + random; } salt = crypto.sha256(random); salt = salt.substr(0, 8); encoded = "$1$" + salt + "$" + crypto.sha256(salt + password); return encoded; } //////////////////////////////////////////////////////////////////////////////// /// @brief constructor //////////////////////////////////////////////////////////////////////////////// function Users(applicationContext, options) { this._options = options || {}; this._collection = null; if (this._options.hasOwnProperty("collectionName")) { this._collectionName = this._options.collectionName; } else { this._collectionName = applicationContext.collectionName("users"); } } //////////////////////////////////////////////////////////////////////////////// /// @brief returns the collection //////////////////////////////////////////////////////////////////////////////// Users.prototype.storage = function () { if (this._collection === null) { this._collection = db._collection(this._collectionName); if (!this._collection) { throw new Error("users collection not found"); } } return this._collection; }; //////////////////////////////////////////////////////////////////////////////// /// @brief validate a user identifier //////////////////////////////////////////////////////////////////////////////// Users.prototype._validateIdentifier = function (identifier, allowObject) { if (allowObject) { if (typeof identifier === "object" && identifier.hasOwnProperty("identifier")) { identifier = identifier.identifier; } } if (typeof identifier !== "string") { throw new TypeError("invalid type for 'identifier'"); } if (identifier.length === 0) { throw new Error("invalid user identifier"); } return identifier; }; //////////////////////////////////////////////////////////////////////////////// /// @brief sets up the users collection //////////////////////////////////////////////////////////////////////////////// Users.prototype.setup = function (options) { var journalSize, createOptions; if (typeof options === "object" && options.hasOwnProperty("journalSize")) { journalSize = options.journalSize; } createOptions = { journalSize : journalSize || 2 * 1024 * 1024 }; if (!db._collection(this._collectionName)) { db._create(this._collectionName, createOptions); } this.storage().ensureIndex({ type: "hash", fields: [ "identifier" ], sparse: true }); }; //////////////////////////////////////////////////////////////////////////////// /// @brief tears down the users collection //////////////////////////////////////////////////////////////////////////////// Users.prototype.teardown = function () { var c = db._collection(this._collectionName); if (c) { c.drop(); } }; //////////////////////////////////////////////////////////////////////////////// /// @brief flushes all users //////////////////////////////////////////////////////////////////////////////// Users.prototype.flush = function () { this.storage().truncate(); }; //////////////////////////////////////////////////////////////////////////////// /// @brief add a user //////////////////////////////////////////////////////////////////////////////// Users.prototype.add = function (identifier, password, active, data) { var c = this.storage(), user; identifier = this._validateIdentifier(identifier, false); if (typeof password !== "string") { throw new TypeError("invalid type for 'password'"); } if (active !== undefined && typeof active !== "boolean") { throw new TypeError("invalid type for 'active'"); } if (active === undefined) { active = true; } user = { identifier: identifier, password: encodePassword(password), active: active, data: data || {} }; db._executeTransaction({ collections: { write: c.name() }, action: function (params) { var c = db._collection(params.cn), u = c.firstExample({ identifier: params.user.identifier }); if (u === null) { c.save(params.user); } else { c.replace(u._key, params.user); } }, params: { cn: c.name(), user: user } }); delete user.password; return user; }; //////////////////////////////////////////////////////////////////////////////// /// @brief update a user //////////////////////////////////////////////////////////////////////////////// Users.prototype.updateData = function (identifier, data) { var c = this.storage(); identifier = this._validateIdentifier(identifier, true); db._executeTransaction({ collections: { write: c.name() }, action: function (params) { var c = db._collection(params.cn), u = c.firstExample({ identifier: params.identifier }); if (u === null) { throw new Error("user not found"); } c.update(u._key, params.data, true, false); }, params: { cn: c.name(), identifier: identifier, data: data || {} } }); return true; }; //////////////////////////////////////////////////////////////////////////////// /// @brief set activity flag //////////////////////////////////////////////////////////////////////////////// Users.prototype.setActive = function (identifier, active) { var c = this.storage(), user, doc; identifier = this._validateIdentifier(identifier, true); user = c.firstExample({ identifier: identifier }); if (user === null) { return false; } // must clone because shaped json cannot be modified doc = cloneDocument(user); doc.active = active; c.update(doc._key, doc, true, false); return true; }; //////////////////////////////////////////////////////////////////////////////// /// @brief set password //////////////////////////////////////////////////////////////////////////////// Users.prototype.setPassword = function (identifier, password) { var c = this.storage(), user, doc; identifier = this._validateIdentifier(identifier, true); if (typeof password !== "string") { throw new TypeError("invalid type for 'password'"); } user = c.firstExample({ identifier: identifier }); if (user === null) { return false; } doc = cloneDocument(user); doc.password = encodePassword(password); c.update(doc._key, doc, true, false); return true; }; //////////////////////////////////////////////////////////////////////////////// /// @brief remove a user //////////////////////////////////////////////////////////////////////////////// Users.prototype.remove = function (identifier) { var c = this.storage(), user; identifier = this._validateIdentifier(identifier, true); user = c.firstExample({ identifier: identifier }); if (user === null) { return false; } try { c.remove(user._key); } catch (err) { } return true; }; //////////////////////////////////////////////////////////////////////////////// /// @brief returns a user //////////////////////////////////////////////////////////////////////////////// Users.prototype.get = function (identifier) { var c = this.storage(), user; identifier = this._validateIdentifier(identifier, true); user = c.firstExample({ identifier: identifier }); if (user === null) { throw new Error("user not found"); } delete user.password; return user; }; //////////////////////////////////////////////////////////////////////////////// /// @brief checks if a user exists //////////////////////////////////////////////////////////////////////////////// Users.prototype.exists = function (identifier) { var c = this.storage(), user; identifier = this._validateIdentifier(identifier, true); user = c.firstExample({ identifier: identifier }); return (user !== null); }; //////////////////////////////////////////////////////////////////////////////// /// @brief check whether a user is valid //////////////////////////////////////////////////////////////////////////////// Users.prototype.isValid = function (identifier, password) { var c = this.storage(), user; identifier = this._validateIdentifier(identifier, false); user = c.firstExample({ identifier: identifier }); if (user === null) { return false; } if (!user.active) { return false; } if (!checkPassword(password, user.password)) { return false; } delete user.password; return user; }; //////////////////////////////////////////////////////////////////////////////// /// @brief constructor //////////////////////////////////////////////////////////////////////////////// function Sessions(applicationContext, options) { this._applicationContext = applicationContext; this._options = options || {}; this._collection = null; if (!this._options.hasOwnProperty("minUpdateResoultion")) { this._options.minUpdateResolution = 10; } if (this._options.hasOwnProperty("collectionName")) { this._collectionName = this._options.collectionName; } else { this._collectionName = applicationContext.collectionName("sessions"); } } //////////////////////////////////////////////////////////////////////////////// /// @brief create a session object from a document //////////////////////////////////////////////////////////////////////////////// Sessions.prototype._toObject = function (session) { var that = this; return { _changed: false, _key: session._key, identifier: session.identifier, expires: session.expires, data: session.data, set: function (key, value) { this.data[key] = value; this._changed = true; }, get: function (key) { return this.data[key]; }, update: function () { var oldExpires, newExpires; if (!this._changed) { oldExpires = this.expires; newExpires = internal.time() + that._options.sessionLifetime; if (newExpires - oldExpires > that._options.minUpdateResolution) { this.expires = newExpires; this._changed = true; } } if (this._changed) { that.update(this._key, this.data); this._changed = false; } } }; }; //////////////////////////////////////////////////////////////////////////////// /// @brief sets up the sessions //////////////////////////////////////////////////////////////////////////////// Sessions.prototype.setup = function (options) { var journalSize, createOptions; if (typeof options === "object" && options.hasOwnProperty("journalSize")) { journalSize = options.journalSize; } createOptions = { journalSize : journalSize || 4 * 1024 * 1024 }; if (!db._collection(this._collectionName)) { db._create(this._collectionName, createOptions); } this.storage().ensureIndex({ type: "hash", fields: ["identifier"]}); }; //////////////////////////////////////////////////////////////////////////////// /// @brief tears down the sessions //////////////////////////////////////////////////////////////////////////////// Sessions.prototype.teardown = function () { var c = db._collection(this._collectionName); if (c) { c.drop(); } }; //////////////////////////////////////////////////////////////////////////////// /// @brief return the collection //////////////////////////////////////////////////////////////////////////////// Sessions.prototype.storage = function () { if (this._collection === null) { this._collection = db._collection(this._collectionName); if (!this._collection) { throw new Error("sessions collection not found"); } } return this._collection; }; //////////////////////////////////////////////////////////////////////////////// /// @brief generate a new session //////////////////////////////////////////////////////////////////////////////// Sessions.prototype.generate = function (identifier, data) { var storage, token, session; if (typeof identifier !== "string" || identifier.length === 0) { throw new TypeError("invalid type for 'identifier'"); } if (!this._options.hasOwnProperty("sessionLifetime")) { throw new Error("no value specified for 'sessionLifetime'"); } storage = this.storage(); if (!this._options.allowMultiple) { // remove previous existing sessions storage.byExample({ identifier: identifier }).toArray().forEach(function (s) { storage.remove(s); }); } while (true) { token = generateToken(); session = { _key: token, expires: internal.time() + this._options.sessionLifetime, identifier: identifier, data: data || {} }; try { storage.save(session); return this._toObject(session); } catch (err) { // we might have generated the same key again if (err.hasOwnProperty("errorNum") && err.errorNum === internal.errors.ERROR_ARANGO_UNIQUE_CONSTRAINT_VIOLATED.code) { // duplicate key, try again continue; } throw err; } } }; //////////////////////////////////////////////////////////////////////////////// /// @brief update a session //////////////////////////////////////////////////////////////////////////////// Sessions.prototype.update = function (token, data) { this.storage().update(token, { expires: internal.time() + this._options.sessionLifetime, data: data }, true, false); }; //////////////////////////////////////////////////////////////////////////////// /// @brief terminate a session //////////////////////////////////////////////////////////////////////////////// Sessions.prototype.terminate = function (token) { try { this.storage().remove(token); } catch (err) { // some error, e.g. document not found. we don't care } }; //////////////////////////////////////////////////////////////////////////////// /// @brief get an existing session //////////////////////////////////////////////////////////////////////////////// Sessions.prototype.get = function (token) { var storage = this.storage(), session; try { session = storage.document(token); if (session.expires >= internal.time()) { // session still valid return { errorNum: internal.errors.ERROR_NO_ERROR.code, session : this._toObject(session) }; } // expired return { errorNum: internal.errors.ERROR_SESSION_EXPIRED.code }; } catch (err) { // document not found etc. } return { errorNum: internal.errors.ERROR_SESSION_UNKNOWN.code }; }; //////////////////////////////////////////////////////////////////////////////// /// @brief constructor //////////////////////////////////////////////////////////////////////////////// function CookieAuthentication(applicationContext, options) { options = options || {}; this._applicationContext = applicationContext; this._options = { name: options.name || this._applicationContext.name + "-session", cookieLifetime: options.cookieLifetime || 3600, path: options.path || "/", domain: options.domain || undefined, secure: options.secure || false, httpOnly: options.httpOnly || false }; this._collectionName = applicationContext.collectionName("sessions"); this._collection = null; } //////////////////////////////////////////////////////////////////////////////// /// @brief get a cookie from the request //////////////////////////////////////////////////////////////////////////////// CookieAuthentication.prototype.getTokenFromRequest = function (req) { if (!req.hasOwnProperty("cookies")) { return null; } if (!req.cookies.hasOwnProperty(this._options.name)) { return null; } return req.cookies[this._options.name]; }; //////////////////////////////////////////////////////////////////////////////// /// @brief register a cookie in the request //////////////////////////////////////////////////////////////////////////////// CookieAuthentication.prototype.setCookie = function (res, value) { var name = this._options.name, cookie, i, n; cookie = { name: name, value: value, lifeTime: (value === null || value === "") ? 0 : this._options.cookieLifetime, path: this._options.path, secure: this._options.secure, domain: this._options.domain, httpOnly: this._options.httpOnly }; if (!res.hasOwnProperty("cookies")) { res.cookies = [ ]; } if (!Array.isArray(res.cookies)) { res.cookies = [ res.cookies ]; } n = res.cookies.length; for (i = 0; i < n; ++i) { if (res.cookies[i].name === name) { // found existing cookie. overwrite it res.cookies[i] = cookie; return; } } res.cookies.push(cookie); }; //////////////////////////////////////////////////////////////////////////////// /// @brief check whether the request contains authentication data //////////////////////////////////////////////////////////////////////////////// CookieAuthentication.prototype.getAuthenticationData = function (req) { var token = this.getTokenFromRequest(req); if (token === null) { return null; } return { token: token }; }; //////////////////////////////////////////////////////////////////////////////// /// @brief generate authentication data //////////////////////////////////////////////////////////////////////////////// CookieAuthentication.prototype.beginSession = function (req, res, token) { this.setCookie(res, token); }; //////////////////////////////////////////////////////////////////////////////// /// @brief delete authentication data //////////////////////////////////////////////////////////////////////////////// CookieAuthentication.prototype.endSession = function (req, res) { this.setCookie(res, ""); }; //////////////////////////////////////////////////////////////////////////////// /// @brief update authentication data //////////////////////////////////////////////////////////////////////////////// CookieAuthentication.prototype.updateSession = function (req, res, session) { // update the cookie (expire date) this.setCookie(res, session._key); }; //////////////////////////////////////////////////////////////////////////////// /// @brief check whether the authentication handler is responsible //////////////////////////////////////////////////////////////////////////////// CookieAuthentication.prototype.isResponsible = function () { return true; }; //////////////////////////////////////////////////////////////////////////////// /// @brief constructor //////////////////////////////////////////////////////////////////////////////// function Authentication(applicationContext, sessions, authenticators) { this._applicationContext = applicationContext; this._sessions = sessions; if (!Array.isArray(authenticators)) { authenticators = [ authenticators ]; } this._authenticators = authenticators; } //////////////////////////////////////////////////////////////////////////////// /// @brief runs the authentication //////////////////////////////////////////////////////////////////////////////// Authentication.prototype.authenticate = function (req) { var i, n = this._authenticators.length, authenticator, data, session; for (i = 0; i < n; ++i) { authenticator = this._authenticators[i]; data = authenticator.getAuthenticationData(req); if (data !== null) { session = this._sessions.get(data.token); if (session) { return session; } } } return { errorNum: internal.errors.ERROR_SESSION_UNKNOWN.code }; }; //////////////////////////////////////////////////////////////////////////////// /// @brief begin a session //////////////////////////////////////////////////////////////////////////////// Authentication.prototype.beginSession = function (req, res, identifier, data) { var session = this._sessions.generate(identifier, data), i, n = this._authenticators.length, authenticator; for (i = 0; i < n; ++i) { authenticator = this._authenticators[i]; if (authenticator.isResponsible(req) && authenticator.beginSession) { authenticator.beginSession(req, res, session._key, identifier, data); } } return session; }; //////////////////////////////////////////////////////////////////////////////// /// @brief terminate a session //////////////////////////////////////////////////////////////////////////////// Authentication.prototype.endSession = function (req, res, token) { var i, n = this._authenticators.length, authenticator; for (i = 0; i < n; ++i) { authenticator = this._authenticators[i]; if (authenticator.isResponsible(req) && authenticator.endSession) { authenticator.endSession(req, res); } } this._sessions.terminate(token); }; //////////////////////////////////////////////////////////////////////////////// /// @brief update a session //////////////////////////////////////////////////////////////////////////////// Authentication.prototype.updateSession = function (req, res, session) { var i, n = this._authenticators.length, authenticator; for (i = 0; i < n; ++i) { authenticator = this._authenticators[i]; if (authenticator.isResponsible(req) && authenticator.updateSession) { authenticator.updateSession(req, res, session); } } session.update(); }; // http://stackoverflow.com/questions/783818/how-do-i-create-a-custom-error-in-javascript //////////////////////////////////////////////////////////////////////////////// /// @brief constructor //////////////////////////////////////////////////////////////////////////////// function UserAlreadyExistsError(message) { this.message = message || "User already exists"; this.statusCode = 400; } UserAlreadyExistsError.prototype = new Error(); function UnauthorizedError(message) { this.message = message || "Unauthorized"; this.statusCode = 401; } UnauthorizedError.prototype = new Error(); exports.Users = Users; exports.Sessions = Sessions; exports.CookieAuthentication = CookieAuthentication; exports.Authentication = Authentication; exports.UnauthorizedError = UnauthorizedError; exports.UserAlreadyExistsError = UserAlreadyExistsError; exports.createStandardLoginHandler = createStandardLoginHandler; exports.createStandardLogoutHandler = createStandardLogoutHandler; exports.createStandardRegistrationHandler = createStandardRegistrationHandler; exports.createStandardChangePasswordHandler = createStandardChangePasswordHandler; exports.createAuthenticationMiddleware = createAuthenticationMiddleware; exports.createSessionUpdateMiddleware = createSessionUpdateMiddleware; exports.createAuthObject = createAuthObject;