diff --git a/README.md b/README.md index bd6a363..c6a012c 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,12 @@ var db = new PouchDB('_users') `pouchdb-auth` adds 3 methods to the PouchDB API 1. `db.hashAdminPasswords(admins)` -2. `db.useAsAuthenticationDB()` -3. `db.stopUsingAsAuthenticationDB` +2. `db.generateSecret()` +3. `db.useAsAuthenticationDB()` +4. `db.stopUsingAsAuthenticationDB` -### db.hashAdminPasswords(admins[, callback]) +### db.hashAdminPasswords(admins[, options[, callback]]) `admins` is an object in the form of `'username': 'password'`. @@ -40,8 +41,15 @@ db.hashAdminPasswords({ 'admin': 'secret' } }) ``` +- `options.iterations`: The number of pbkdf2 iterations to use when hashing the + passwords. Defaults to CouchDB's 10. + See below ("How it works") for more background information +### db.generateSecret() + +Generates a secret that you can use for useAsAuthenticationDB(). This is a +synchronous method. ### db.useAsAuthenticationDB([options[, callback]]) @@ -53,14 +61,27 @@ to the db (documented below): - `db.signUp(username, password[, options[, callback]])` - `db.logIn(username, password[, options[, callback]])` -- `db.logOut(options[, callback])` -- `db.session(options[, callback])` - -`options.isOnlineAuthDB`: If `true`, password hashing, keeping -track of the session and doc validation is all handled by the -CouchDB on the other end. Defaults to `true` if called on an http -database, otherwise `false`. Having this enabled makes it impossible to -use the `options.sessionID` option in methods that support it. +- `db.logOut([options[, callback]])` +- `db.session([options[, callback]])` +- `db.multiUserLogIn([callback])` +- `db.multiUserSession([sessionID[, callback]])` + +- `options.isOnlineAuthDB`: If `true`, password hashing, keeping + track of the session and doc validation is all handled by the + CouchDB on the other end. Defaults to `true` if called on an http + database, otherwise `false`. An online db currently doesn't provide the + `db.multiUser*` methods. +- `options.timeout`: By default, a session is valid for 600 seconds. If you want + to renew the session, call ``db.session()`` within this time window, or set + the expiration time higher (or to 0, which sets it to infinite), by changing + this value. +- `options.secret`: To calculate the session keys, a secret is necessary. You + can pass in your own using this parameter. Otherwise, a random one is + generated for the authentication db. +- `options.admins` (optional): Allows to pass in an admins object that looks + like the one defined in CouchDB's `_config`. +- `options.iterations`: The number of pbkdf2 iterations to use when hashing the + passwords. Defaults to CouchDB's 10. Returns a promise, unless `callback` is passed. Resolves with nothing. @@ -73,15 +94,14 @@ db.useAsAuthenticationDB() }) ``` -### db.stopUsingAsAuthenticationDB([callback]) +### db.stopUsingAsAuthenticationDB() Removes custom behavior and methods applied by `db.useAsAuthenticationDB()`. -Returns a promise, unless `callback` is passed. Resolves with nothing. +Returns nothing. This is a synchronous method. ```js -db.useAsAuthenticationDB() -.then(function () {}) +db.stopUsingAsAuthenticationDB(); ``` @@ -111,24 +131,13 @@ db.signUp('john', 'secret') }) ``` -### db.logIn(username, password[, options[, callback]]) +### db.logIn(username, password[, callback]) Tries to get the user specified by `username` from the database, if its `password` (after hashing) matches, the user is considered -to be logged in. This fact is then saved to a db, allowing the +to be logged in. This fact is then stored in memory, allowing the other methods (`db.logOut` & `db.session`) to use it later on. -- `options.sessionID` (optional, default `"default"`) - - Under this key the session is saved to a db. - This allows you to have multiple sessions - running alongside each other. - -` `options.admins` (optional) - - Allows to pass in an admins object that looks - like the one defined in CouchDB's `_config`. - Returns a promise, unless `callback` is passed. Resolves with `name` and `roles`. If username and/or password is incorrect, rejects with `unauthorized` error. @@ -138,54 +147,43 @@ db.logIn('john', 'secret') .then(function (response) { // { // ok: true, - // name: 'username', + // name: 'john', // roles: ['roles', 'here'] // } -}) +}); db.logIn('john', 'wrongsecret') .catch(function (error) { // error.name === `unauthorized` // error.status === 401 // error.message === 'Name or password is incorrect.' -}) +}); ``` -### db.logOut([options[, callback]]) +### db.logOut(callback) Removes the current session. -Returns a promise, unless `callback` is passed. +Returns a promise that resolves to `{ok: true}`, to match a CouchDB logout. This +method never fails, it works even if there is no session. ```js db.logOut() -.then(function (response) { +.then(function (resp) { // { ok: true } -}) - +}); +``` -### db.session([options[, callback]]) +### db.session([callback]) Reads the current session from the db. -- `options.sessionID` (optional, default `"default"`) - - Under this key the session is saved to a db. - This allows you to have multiple sessions - running alongside each other. - -` `options.admins` (optional) - - Allows to pass in an admins object that looks - like the one defined in CouchDB's `_config`. - Returns a promise, unless `callback` is passed. Note that `db.session()` does not return an error if the current -user has no valid session, just like CouchDB`s `GET /_session` -returns a `200` status. To determine whether the current user -has a valid session or not, check if `response.userCtx.name` -is set. +user has no valid session, just like CouchDB returns a `200` status to a +`GET /_session` request. To determine whether the current user has a valid +session or not, check if `response.userCtx.name` is set. ```js db.session() @@ -203,6 +201,59 @@ db.session() }) ``` +### db.multiUserLogIn(username, password[, callback]) + +This works the same as ``db.logIn()``, but returns an extra property +(``sessionID``), so multiple sessions can be managed at the same time. You pass +in this property to the ``db.multiUserSession`` function as a reminder which +session you are talking about. + +As a matter of fact, the normal functions are just a small wrapper over the +``db.multiUser*`` functions. They just store and re-use the last sessionID +internally. + +```js +db.multiUserLogIn('john', 'secret') +.then(response) { + // { + // ok: true, + // name: 'username', + // roles: ['roles', 'here'], + // sessionID: 'amFuOjU2Njg4MkI5OkEK3-1SRseo6yNRHfk-mmk6zOxm' + // } +}); +``` + +### db.multiUserSession(sessionID[, callback]) + +The same as ``db.session()``, but supporting multiple sessions at the same time. +Pass in a ``sessionID`` obtained from a ``db.multiUserLogIn()`` call. If +``sessionID`` is not given, a normal non-logged in session will be returned. +A new updated ``sessionID`` is generated and included to prevent the session +from expiring. + +```js +db.multiUserSession('amFuOjU2Njg4MkI5OkEK3-1SRseo6yNRHfk-mmk6zOxm') +.then(response) { + // { + // "ok": true, + // "userCtx": { + // "name": 'john', + // "roles": [], + // }, + // "info": { + // "authentication_handlers": ["api"] + // }, + // sessionID: 'some-new-session-id' + // } +} +``` + +### db.multiUserLogOut() + +Contrary to what you might expect, this method **does not exist**. Multi user +logouts are as simple as just forgetting the ``sessionID``. That is the only +thing the ``db.logOut()`` method does internally. No other state is kept. ## How it works @@ -214,10 +265,11 @@ Admin users are not stored in the `_users` database, but in the `[admins]` secti of couch.ini, see http://docs.couchdb.org/en/latest/config/auth.html When setting passwords clear text, CouchDB will automatically overwrite -them with hashed passwords on restart. +them with hashed passwords on restart. the ``hashAdminPasswords`` function +can be used to emulate that behaviour with PouchDB-Auth. The `roles` property of `_users` documents is used by CouchDB to determine access to databases, -which can be set in each database's `_security` setting. There are now default roles by CouchDB, +which can be set in the `_security` setting of each database. There are now default roles by CouchDB, so you are free to set your own (With the excepion of system roles starting with a `_`). The `roles` property can only be changed by CouchDB admin users. More on authorization in CouchDB: http://docs.couchdb.org/en/latest/intro/security.html#authorization diff --git a/index.js b/index.js deleted file mode 100644 index c22a3e6..0000000 --- a/index.js +++ /dev/null @@ -1,441 +0,0 @@ -/* - Copyright 2014-2015, Marten de Vries - - 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. -*/ - -"use strict"; - -var Promise = require("pouchdb-promise"); -var crypto = require("crypto-lite").crypto; -var secureRandom = require("secure-random"); -var extend = require("extend"); - -var wrappers = require("pouchdb-wrappers"); -var createBulkDocsWrapper = require("pouchdb-bulkdocs-wrapper"); -var nodify = require("promise-nodify"); -var Validation = require("pouchdb-validation"); -var PouchPluginError = require("pouchdb-plugin-error"); -var httpQuery = require("pouchdb-req-http-query"); -var systemDB = require("pouchdb-system-db"); - -var DESIGN_DOC = require("./designdoc.js"); -var ADMIN_RE = /^-pbkdf2-([\da-f]+),([\da-f]+),([0-9]+)$/; -var IS_HASH_RE = /^-(?:pbkdf2|hashed)-/; -var SESSION_DB_NAME = 'pouch__auth_sessions__'; - -var sessionDB; -function getSessionDB(PouchDB) { - if (!sessionDB) { - sessionDB = new PouchDB(SESSION_DB_NAME, {auto_compaction: true}); - } - return sessionDB; -} - -var dbData = { - dbs: [], - isOnlineAuthDBsByDBIdx: [] -}; -function dbDataFor(db) { - var i = dbData.dbs.indexOf(db); - return { - isOnlineAuthDB: dbData.isOnlineAuthDBsByDBIdx[i] - }; -} - -exports.useAsAuthenticationDB = function (opts, callback) { - var args = processArgs(this, opts, callback); - - try { - Validation.installValidationMethods.call(args.db); - } catch (err) { - throw new Error("Already in use as an authentication database."); - } - - var i = dbData.dbs.push(args.db) -1; - if (typeof args.opts.isOnlineAuthDB === "undefined") { - args.opts.isOnlineAuthDB = ["http", "https"].indexOf(args.db.type()) !== -1; - } - dbData.isOnlineAuthDBsByDBIdx[i] = args.opts.isOnlineAuthDB; - - if (!args.opts.isOnlineAuthDB) { - wrappers.installWrapperMethods(args.db, writeWrappers); - systemDB.installSystemDBProtection(args.db); - } - for (var name in api) { - args.db[name] = api[name].bind(args.db); - } - - var promise; - if (args.opts.isOnlineAuthDB) { - promise = Promise.resolve(); - } else { - promise = args.db.put(DESIGN_DOC) - .catch(function (err) { - /* istanbul ignore if */ - if (err.status !== 409) { - throw err; - } - }) - .then(function () {/* empty success value */}); - } - - nodify(promise, args.callback); - return promise; -}; - -function processArgs(db, opts, callback) { - if (typeof opts === "function") { - callback = opts; - opts = {}; - } - opts = opts || {}; - //clone (deep) - opts = extend(true, {}, opts); - - opts.sessionID = opts.sessionID || "default"; - if (typeof opts.admins === "undefined") { - opts.admins = {}; - } else { - opts.admins = parseAdmins(opts.admins); - } - return { - db: db, - //|| {} for hashAdminPasswords e.g. - PouchDB: (db || {}).constructor, - opts: opts, - callback: callback - }; -} - -function parseAdmins(admins) { - var result = {}; - for (var name in admins) { - /* istanbul ignore else */ - if (admins.hasOwnProperty(name)) { - var info = admins[name].match(ADMIN_RE); - if (info) { - result[name] = { - password_scheme: "pbkdf2", - derived_key: info[1], - salt: info[2], - iterations: parseInt(info[3], 10), - roles: ["_admin"], - name: name - }; - } - } - } - return result; -} - -var api = {}; -var writeWrappers = {}; - -writeWrappers.put = function (original, args) { - return modifyDoc(args.doc).then(original); -}; -writeWrappers.post = writeWrappers.put; - -function modifyDoc(doc) { - if (!(typeof doc.password == "undefined" || doc.password === null)) { - doc.iterations = 10; - doc.password_scheme = "pbkdf2"; - - return generateSalt().then(function (salt) { - doc.salt = salt; - - return hashPassword(doc.password, doc.salt, doc.iterations); - }).then(function (hash) { - delete doc.password; - doc.derived_key = hash; - }); - } - return Promise.resolve(); -} - -function generateSalt() { - var arr = secureRandom(16); - var result = arrayToString(arr); - return Promise.resolve(result); -} - -function arrayToString(array) { - var result = ""; - for (var i = 0; i < array.length; i += 1) { - result += ((array[i] & 0xFF) + 0x100).toString(16); - } - return result; -} - -function hashPassword(password, salt, iterations) { - return new Promise(function (resolve, reject) { - crypto.pbkdf2(password, salt, iterations, 20, function (err, derived_key) { - /* istanbul ignore if */ - if (err) { - reject(err); - } else { - resolve(derived_key.toString("hex")); - } - }); - }); -} - -writeWrappers.bulkDocs = createBulkDocsWrapper(modifyDoc); - -api.signUp = function (username, password, opts, callback) { - //opts: roles - var args = processArgs(this, opts, callback); - - var doc = { - _id: docId(username), - type: 'user', - name: username, - password: password, - roles: args.opts.roles || [] - }; - - var promise = args.db.put(doc); - nodify(promise, args.callback); - return promise; -}; - -function docId(username) { - return "org.couchdb.user:" + username; -} - -api.logIn = function (username, password, opts, callback) { - var args = processArgs(this, opts, callback); - var data = dbDataFor(args.db); - var promise; - - if (data.isOnlineAuthDB) { - promise = httpQuery(args.db, { - method: "POST", - raw_path: "/_session", - body: JSON.stringify({ - name: username, - password: password - }), - headers: { - "Content-Type": "application/json" - } - }).then(function (resp) { - return JSON.parse(resp.body); - }); - } else { - var userDoc, userDocPromise; - - userDoc = args.opts.admins[username]; - if (typeof userDoc === "undefined") { - userDocPromise = args.db.get(docId(username), {conflicts: true}); - } else { - userDocPromise = Promise.resolve(userDoc); - } - - promise = userDocPromise - .then(function (doc) { - if ((doc._conflicts || {}).length) { - throw new PouchPluginError({ - status: 401, - name: "unauthorized", - message: "User document conflicts must be resolved before" + - "the document is used for authentication purposes." - }); - } - userDoc = doc; - return hashPassword(password, userDoc.salt, userDoc.iterations); - }) - .then(function (derived_key) { - if (derived_key !== userDoc.derived_key) { - throw "invalid_password"; - } - return getSessionDB(args.PouchDB).get(args.opts.sessionID).catch(function () { - //non-existing doc is fine - return {_id: args.opts.sessionID}; - }); - }) - .then(function (sessionDoc) { - sessionDoc.username = userDoc.name; - - return getSessionDB(args.PouchDB).put(sessionDoc); - }) - .then(function () { - return { - ok: true, - name: userDoc.name, - roles: userDoc.roles - }; - }) - .catch(function (err) { - if (!(err instanceof PouchPluginError)) { - err = new PouchPluginError({ - status: 401, - name: "unauthorized", - message: "Name or password is incorrect." - }); - } - throw err; - }); - } - - nodify(promise, args.callback); - return promise; -}; - -api.logOut = function (opts, callback) { - var args = processArgs(this, opts, callback); - var data = dbDataFor(args.db); - var promise; - - if (data.isOnlineAuthDB) { - promise = httpQuery(args.db, { - method: "DELETE", - raw_path: "/_session" - }).then(function (resp) { - return JSON.parse(resp.body); - }); - } else { - promise = getSessionDB(args.PouchDB).get(args.opts.sessionID) - .then(function (doc) { - return getSessionDB(args.PouchDB).remove(doc); - }) - .catch(function () {/* fine, no session -> already logged out */}) - .then(function () { - return {ok: true}; - }); - } - nodify(promise, args.callback); - return promise; -}; - -api.session = function (opts, callback) { - var args = processArgs(this, opts, callback); - var data = dbDataFor(args.db); - - var promise; - if (data.isOnlineAuthDB) { - promise = httpQuery(args.db, { - raw_path: "/_session", - method: "GET" - }).then(function (resp) { - return JSON.parse(resp.body); - }); - } else { - var resp = { - ok: true, - userCtx: { - name: null, - roles: [] - }, - info: { - authentication_handlers: ["api"] - } - }; - if (Object.keys(args.opts.admins).length === 0) { - //admin party - resp.userCtx.roles = ["_admin"]; - } - - promise = args.db.info() - .then(function (info) { - resp.info.authentication_db = info.db_name; - - return getSessionDB(args.PouchDB).get(args.opts.sessionID); - }) - .then(function (sessionDoc) { - var adminDoc = args.opts.admins[sessionDoc.username]; - if (typeof adminDoc !== "undefined") { - return adminDoc; - } - return args.db.get(docId(sessionDoc.username)); - }) - .then(function (userDoc) { - resp.info.authenticated = "api"; - resp.userCtx.name = userDoc.name; - resp.userCtx.roles = userDoc.roles; - }).catch(function () { - //resp is valid in its current state for an error, so do nothing - }).then(function () { - return resp; - }); - } - nodify(promise, args.callback); - return promise; -}; - -exports.stopUsingAsAuthenticationDB = function (opts, callback) { - var args = processArgs(this, opts, callback); - var db = this; - - var i = dbData.dbs.indexOf(db); - if (i === -1) { - throw new Error("Not an authentication database."); - } - dbData.dbs.splice(i, 1); - - for (var name in api) { - /* istanbul ignore else */ - if (api.hasOwnProperty(name)) { - delete db[name]; - } - } - - var isOnlineAuthDB = dbData.isOnlineAuthDBsByDBIdx.splice(i, 1)[0]; - if (!isOnlineAuthDB) { - systemDB.uninstallSystemDBProtection(db); - wrappers.uninstallWrapperMethods(db, writeWrappers); - } - - Validation.uninstallValidationMethods.call(db); - - //backwards compatibility: this once contained async logic, even - //though today it's all synchronous. - var promise = Promise.resolve(); - nodify(promise, args.callback); - return promise; -}; - -exports.hashAdminPasswords = function (admins, opts, callback) { - //opts: opts.iterations (default 10) - //'static' method (doesn't use the database) - var args = processArgs(null, opts, callback); - var iterations = args.opts.iterations || 10; - - var result = {}; - var promise = Promise.all(Object.keys(admins).map(function (key) { - return hashAdminPassword(admins[key], iterations) - .then(function (hashed) { - result[key] = hashed; - }); - })).then(function () { - return result; - }); - nodify(promise, args.callback); - return promise; -}; - -function hashAdminPassword(password, iterations) { - if (IS_HASH_RE.test(password)) { - return Promise.resolve(password); - } - var salt; - return generateSalt() - .then(function (theSalt) { - salt = theSalt; - return hashPassword(password, salt, iterations); - }) - .then(function (hash) { - return "-pbkdf2-" + hash + "," + salt + "," + iterations; - }); -} diff --git a/lib/admins.js b/lib/admins.js new file mode 100644 index 0000000..37f1abf --- /dev/null +++ b/lib/admins.js @@ -0,0 +1,72 @@ +/* + Copyright 2014-2015, Marten de Vries + + 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. +*/ + +'use strict'; + +var utils = require('./utils'); + +var Promise = require('pouchdb/extras/promise'); +var IS_HASH_RE = /^-(?:pbkdf2|hashed)-/; + +exports.hashPasswords = function (admins, opts, callback) { + // opts: opts.iterations (default 10) + // 'static' method (doesn't use the database) + var args = utils.processArgs(null, opts, callback); + + var result = {}; + return utils.nodify(Promise.all(Object.keys(admins).map(function (key) { + return hashAdminPassword(admins[key], utils.iterations(args)) + .then(function (hashed) { + result[key] = hashed; + }); + })).then(function () { + return result; + }), args.callback); +}; + +function hashAdminPassword(password, iterations) { + if (IS_HASH_RE.test(password)) { + return Promise.resolve(password); + } + var salt = utils.generateSecret(); + return utils.hashPassword(password, salt, iterations) + .then(function (hash) { + return '-pbkdf2-' + hash + ',' + salt + ',' + iterations; + }); +} + +var ADMIN_RE = /^-pbkdf2-([\da-f]+),([\da-f]+),([0-9]+)$/; + +exports.parse = function (admins) { + var result = {}; + for (var name in admins) { + /* istanbul ignore else */ + if (admins.hasOwnProperty(name)) { + var info = admins[name].match(ADMIN_RE); + if (info) { + result[name] = { + password_scheme: 'pbkdf2', + derived_key: info[1], + salt: info[2], + iterations: parseInt(info[3], 10), + roles: ['_admin'], + name: name + }; + } + } + } + return result; +}; diff --git a/designdoc.js b/lib/designdoc.js similarity index 100% rename from designdoc.js rename to lib/designdoc.js diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..9817d0c --- /dev/null +++ b/lib/index.js @@ -0,0 +1,112 @@ +/* + Copyright 2014-2015, Marten de Vries + + 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. +*/ + +'use strict'; + +var Promise = require('pouchdb/extras/promise'); +var systemDB = require('pouchdb-system-db'); +var Validation = require('pouchdb-validation'); +var wrappers = require('pouchdb-wrappers'); + +var admins = require('./admins') +var api = require('./sessionapi'); +var designDoc = require('./designdoc'); +var utils = require('./utils'); +var writeWrappers = require('./writewrappers'); + +exports.hashAdminPasswords = admins.hashPasswords; +exports.generateSecret = utils.generateSecret; + +exports.useAsAuthenticationDB = function (opts, callback) { + var args = utils.processArgs(this, opts, callback); + + // install validation + try { + Validation.installValidationMethods.call(args.db); + } catch (err) { + throw new Error("Already in use as an authentication database."); + } + + // generate defaults for db config when not given & store them + var info = { + isOnlineAuthDB: isOnline(args), + timeout: typeof args.opts.timeout === 'undefined' ? 600 : args.opts.timeout, + iterations: utils.iterations(args), + secret: args.opts.secret || utils.generateSecret(), + admins: admins.parse(args.opts.admins || {}) + }; + + var i = utils.dbData.dbs.push(args.db) - 1; + utils.dbData.dataByDBIdx[i] = info; + + // add API to the db object + for (var name in api) { + if (!(info.isOnlineAuthDB && name.indexOf('multiUser') === 0)) { + args.db[name] = api[name].bind(args.db); + } + } + + + return utils.nodify(Promise.resolve().then(function () { + // add wrappers and make system db + if (!info.isOnlineAuthDB) { + wrappers.installWrapperMethods(args.db, writeWrappers); + systemDB.installSystemDBProtection(args.db); + + // store validation doc + return args.db.put(designDoc); + } + }).catch(function (err) { + /* istanbul ignore if */ + if (err.status !== 409) { + throw err; + } + }).then(function () { + /* empty success value */ + }), args.callback); +}; + +function isOnline(args) { + if (typeof args.opts.isOnlineAuthDB === 'undefined') { + return ['http', 'https'].indexOf(args.db.type()) !== -1; + } + return args.opts.isOnlineAuthDB; +} + +exports.stopUsingAsAuthenticationDB = function () { + var db = this; + + var i = utils.dbData.dbs.indexOf(db); + if (i === -1) { + throw new Error("Not an authentication database."); + } + utils.dbData.dbs.splice(i, 1); + var info = utils.dbData.dataByDBIdx.splice(i, 1)[0]; + + for (var name in api) { + /* istanbul ignore else */ + if (api.hasOwnProperty(name)) { + delete db[name]; + } + } + + if (!info.isOnlineAuthDB) { + systemDB.uninstallSystemDBProtection(db); + wrappers.uninstallWrapperMethods(db, writeWrappers); + } + + Validation.uninstallValidationMethods.call(db); +}; diff --git a/lib/sessionapi.js b/lib/sessionapi.js new file mode 100644 index 0000000..abce840 --- /dev/null +++ b/lib/sessionapi.js @@ -0,0 +1,233 @@ +/* + Copyright 2014-2015, Marten de Vries + + 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. +*/ + +'use strict'; + +var Promise = require('pouchdb/extras/promise'); +var base64url = require('base64url'); +var calculateSessionId = require('couchdb-calculate-session-id'); +var httpQuery = require("pouchdb-req-http-query"); +var PouchPluginError = require("pouchdb-plugin-error"); + +var utils = require('./utils'); + +exports.signUp = function (username, password, opts, callback) { + //opts: roles + var args = utils.processArgs(this, opts, callback); + + var doc = { + _id: docId(username), + type: 'user', + name: username, + password: password, + roles: args.opts.roles || [] + }; + + return utils.nodify(args.db.put(doc), args.callback); +}; + +function docId(username) { + return "org.couchdb.user:" + username; +} + +exports.logIn = function (username, password, callback) { + var promise; + var info = utils.dbDataFor(this); + + if (info.isOnlineAuthDB) { + promise = httpQuery(this, { + method: 'POST', + raw_path: '/_session', + body: JSON.stringify({ + name: username, + password: password + }), + headers: { + 'Content-Type': 'application/json' + } + }).then(function (resp) { + return JSON.parse(resp.body); + }); + } else { + promise = exports.multiUserLogIn.call(this, username, password) + .then(saveSessionID.bind(null, info)); + } + + return utils.nodify(promise, callback); +}; + +function saveSessionID(info, resp) { + info.sessionID = resp.sessionID; + delete resp.sessionID; + + return resp; +} + +exports.logOut = function (callback) { + var info = utils.dbDataFor(this); + var promise; + if (info.isOnlineAuthDB) { + promise = httpQuery(this, { + method: 'DELETE', + raw_path: '/_session' + }).then(function (resp) { + return JSON.parse(resp.body); + }); + } else { + delete info.sessionID; + promise = Promise.resolve({ok: true}); + } + + return utils.nodify(promise, callback); +}; + +exports.session = function (callback) { + var info = utils.dbDataFor(this); + + var promise; + if (info.isOnlineAuthDB) { + promise = httpQuery(this, { + raw_path: '/_session', + method: 'GET' + }).then(function (resp) { + return JSON.parse(resp.body); + }); + } else { + promise = exports.multiUserSession.call(this, info.sessionID) + .then(saveSessionID.bind(null, info)); + } + + return utils.nodify(promise, callback); +}; + +exports.multiUserLogIn = function (username, password, callback) { + var db = this; + var info = utils.dbDataFor(db); + + var userDoc; + return utils.nodify(getUserDoc(db, username).then(function (doc) { + userDoc = doc; + return utils.hashPassword(password, userDoc.salt, userDoc.iterations); + }).then(function (derived_key) { + if (derived_key !== userDoc.derived_key) { + throw 'invalid_password'; + } + return { + ok: true, + name: userDoc.name, + roles: userDoc.roles, + sessionID: newSessionId(userDoc, info) + }; + }).catch(function (err) { + if (!(err instanceof PouchPluginError)) { + err = new PouchPluginError({ + status: 401, + name: 'unauthorized', + message: "Name or password is incorrect." + }); + } + throw err; + }), callback); +}; + +function getUserDoc(db, username) { + var info = utils.dbDataFor(db); + + var userDoc = info.admins[username]; + return Promise.resolve().then(function () { + if (typeof userDoc === "undefined") { + return db.get(docId(username), {conflicts: true}); + } + return userDoc; + }).then(function (doc) { + if ((doc._conflicts || {}).length) { + throw new PouchPluginError({ + status: 401, + name: 'unauthorized', + message: "User document conflicts must be resolved before" + + "the document is used for authentication purposes." + }); + } + return doc + }); +} + +function newSessionId(userDoc, info) { + return calculateSessionId(userDoc.name, userDoc.salt, info.secret, timestamp()); +} + +function timestamp() { + return Math.round(Date.now() / 1000); +} + +exports.multiUserSession = function (sessionID, callback) { + var db = this; + + var info = utils.dbDataFor(db); + var resp = { + ok: true, + userCtx: { + name: null, + roles: [] + }, + info: { + authentication_handlers: ['api'] + } + }; + if (Object.keys(info.admins).length === 0) { + //admin party + resp.userCtx.roles = ['_admin']; + } + var givenTimestamp; + return utils.nodify(db.info().then(function (dbInfo) { + resp.info.authentication_db = dbInfo.db_name; + if (sessionID) { + try { + var decoded = base64url.decode(sessionID); + var givenUsername = decoded.split(':')[0]; + givenTimestamp = parseInt(decoded.split(':')[1], 16); + if (typeof givenUsername === 'undefined' || isNaN(givenTimestamp)) { + throw 'invalid'; + } + } catch (err) { + throw new PouchPluginError({ + status: 400, + name: 'bad_request', + message: "Malformed session ID. If you're using a browser, try clearing your cookies." + }); + } + + return getUserDoc(db, givenUsername); + } else { + throw 'no session id'; + } + }).then(function (userDoc) { + var expectedHash = calculateSessionId(userDoc.name, userDoc.salt, info.secret, givenTimestamp); + if (timestamp() < givenTimestamp + info.timeout && expectedHash === sessionID) { + resp.info.authenticated = 'api'; + resp.userCtx.name = userDoc.name; + resp.userCtx.roles = userDoc.roles; + resp.sessionID = newSessionId(userDoc, info); + } + }).catch(function (err) { + if (err instanceof PouchPluginError) { + throw err; + } + // otherwise, resp is ready to be returned. + }).then(function () { + return resp; + }), callback); +}; diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..4c73e48 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,81 @@ +/* + Copyright 2014-2015, Marten de Vries + + 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. +*/ + +'use strict'; + +var crypto = require('crypto-lite').crypto; +var secureRandom = require('secure-random'); + +exports.dbData = { + dbs: [], + dataByDBIdx: [] +}; + +exports.dbDataFor = function (db) { + var i = exports.dbData.dbs.indexOf(db); + return exports.dbData.dataByDBIdx[i]; +} + +exports.nodify = function (promise, callback) { + require('promise-nodify')(promise, callback); + return promise; +}; + +exports.processArgs = function (db, opts, callback) { + if (typeof opts === "function") { + callback = opts; + opts = {}; + } + opts = opts || {}; + + return { + db: db, + //|| {} for hashAdminPasswords e.g. + PouchDB: (db || {}).constructor, + opts: opts, + callback: callback + }; +}; + +exports.iterations = function (args) { + return args.opts.iterations || 10; +}; + +exports.generateSecret = function () { + var arr = secureRandom(16); + return arrayToString(arr); +}; + +function arrayToString(array) { + var result = ''; + for (var i = 0; i < array.length; i += 1) { + result += ((array[i] & 0xFF) + 0x100).toString(16); + } + return result; +} + +exports.hashPassword = function (password, salt, iterations) { + return new Promise(function (resolve, reject) { + crypto.pbkdf2(password, salt, iterations, 20, function (err, derived_key) { + /* istanbul ignore if */ + if (err) { + reject(err); + } else { + resolve(derived_key.toString('hex')); + } + }); + }); +}; diff --git a/lib/writewrappers.js b/lib/writewrappers.js new file mode 100644 index 0000000..35fdb78 --- /dev/null +++ b/lib/writewrappers.js @@ -0,0 +1,43 @@ +/* + Copyright 2014-2015, Marten de Vries + + 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. +*/ + +'use strict'; + +var Promise = require('pouchdb/extras/promise'); +var createBulkDocsWrapper = require("pouchdb-bulkdocs-wrapper"); + +var utils = require('./utils'); + +exports.put = function (original, args) { + return modifyDoc(args.db, args.doc).then(original); +}; + +function modifyDoc(db, doc) { + if (!(typeof doc.password == 'undefined' || doc.password === null)) { + doc.iterations = utils.dbDataFor(db).iterations; + doc.password_scheme = 'pbkdf2'; + doc.salt = utils.generateSecret(); + + return utils.hashPassword(doc.password, doc.salt, doc.iterations).then(function (hash) { + delete doc.password; + doc.derived_key = hash; + }); + } + return Promise.resolve(); +} + +exports.post = exports.put; +exports.bulkDocs = createBulkDocsWrapper(modifyDoc); diff --git a/package.json b/package.json index 38c554b..e1fe2d1 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "pouchdb-auth", - "main": "index.js", + "main": "lib/index.js", + "version": "2.0.0", "description": "A PouchDB plug-in that simulates CouchDB's authentication daemon. Includes a users db that functions like CouchDB's.", "repository": { "type": "git", "url": "https://github.com/pouchdb/pouchdb-auth.git" }, - "homepage": "http://python-pouchdb.marten-de-vries.nl/plugins.html", "keywords": [ "pouch", "pouchdb", @@ -19,20 +19,23 @@ "license": "Apache-2.0", "author": "Marten de Vries", "dependencies": { + "base64url": "^1.0.5", + "couchdb-calculate-session-id": "^1.0.1", "crypto-lite": "^0.1.0", - "extend": "^3.0.0", + "pouchdb": "^5.1.0", + "pouchdb-bulkdocs-wrapper": "^1.0.0", "pouchdb-plugin-error": "^1.0.0", "pouchdb-promise": "^0.0.0", "pouchdb-req-http-query": "^1.0.2", - "pouchdb-validation": "^1.1.0", "pouchdb-system-db": "^1.0.0", - "promise-nodify": "^1.0.0", - "secure-random": "^1.1.1", + "pouchdb-validation": "^1.1.0", "pouchdb-wrappers": "^1.0.0", - "pouchdb-bulkdocs-wrapper": "^1.0.0" + "promise-nodify": "^1.0.0", + "secure-random": "^1.1.1" }, "devDependencies": { - "pouchdb-plugin-helper": "^2.0.0" + "extend": "^3.0.0", + "pouchdb-plugin-helper": "^2.0.1" }, "scripts": { "helper": "./node_modules/.bin/pouchdb-plugin-helper", diff --git a/test/functionality.js b/test/functionality.js index 56ef2b6..f206950 100644 --- a/test/functionality.js +++ b/test/functionality.js @@ -3,6 +3,49 @@ import extend from 'extend'; let db; +function shouldBeAdminParty(session) { + session.info.should.eql({ + "authentication_handlers": ["api"], + "authentication_db": "test" + }); + session.userCtx.should.eql({ + "name": null, + "roles": ["_admin"] + }); + session.ok.should.be.ok; +} + +function shouldNotBeLoggedIn(session) { + session.info.should.eql({ + authentication_handlers: ["api"], + authentication_db: "test" + }); + session.userCtx.should.eql({ + name: null, + roles: [] + }); + session.ok.should.be.ok; +} + +function shouldBeSuccesfulLogIn(data, roles) { + var copy = extend({}, data); + // irrelevant + delete copy.sessionID; + copy.should.eql({ + "ok": true, + "name": "username", + "roles": roles + }); +} + +function shouldBeLoggedIn(session, roles) { + session.userCtx.should.eql({ + "name": "username", + "roles": roles + }); + session.info.authenticated.should.equal("api"); +} + describe('SyncAuthTests', () => { beforeEach(async () => { db = setup() @@ -58,7 +101,7 @@ describe('SyncAuthTests', () => { const session2 = await db.session(); shouldBeLoggedIn(session2, ["test"]); - const session3 = await db.session({sessionID: "not-the-default-one"}); + const session3 = await db.multiUserSession(); shouldBeAdminParty(session3); const logOutData = await db.logOut(); @@ -78,34 +121,6 @@ describe('SyncAuthTests', () => { error.message.should.equal("Name or password is incorrect."); }); - function shouldBeAdminParty(session) { - session.info.should.eql({ - "authentication_handlers": ["api"], - "authentication_db": "test" - }); - session.userCtx.should.eql({ - "name": null, - "roles": ["_admin"] - }); - session.ok.should.be.ok; - } - - function shouldBeSuccesfulLogIn(data, roles) { - data.should.eql({ - "ok": true, - "name": "username", - "roles": roles - }); - } - - function shouldBeLoggedIn(session, roles) { - session.userCtx.should.eql({ - "name": "username", - "roles": roles - }); - session.info.authenticated.should.equal("api"); - } - it('should support sign up without roles', async () => { const result = await db.signUp("username", "password"); result.ok.should.be.ok; @@ -124,38 +139,6 @@ describe('SyncAuthTests', () => { resp[0].status.should.equal(403); }); - it('should support admin logins', async () => { - const admins = { - username: "-pbkdf2-37508a1f1c5c19f38779fbe029ae99ee32988293,885e6e9e9031e391d5ef12abbb6c6aef,10" - }; - shouldNotBeLoggedIn(await db.session({admins: admins})); - const logInData = await db.logIn("username", "test", {admins: admins}); - shouldBeSuccesfulLogIn(logInData, ["_admin"]); - - //if admins not supplied, there's no session (admin party!) - shouldBeAdminParty(await db.session()); - //otherwise there is - const sessionData = await db.session({admins: admins}); - shouldBeLoggedIn(sessionData, ["_admin"]); - - //check if logout works (shouldn't need to know about admins - - //just cancel the session.) - await db.logOut(); - shouldNotBeLoggedIn(await db.session({admins: admins})); - }); - - function shouldNotBeLoggedIn(session) { - session.info.should.eql({ - authentication_handlers: ["api"], - authentication_db: "test" - }); - session.userCtx.should.eql({ - name: null, - roles: [] - }); - session.ok.should.be.ok; - } - it('should handle conflicting logins', async () => { const doc1 = { _id: "org.couchdb.user:test", @@ -178,17 +161,13 @@ describe('SyncAuthTests', () => { error.message.should.contain("conflict"); }); - it('should handle invalid admins field on login', async () => { - const admins = { - username: "-pbkdf2-37508a1f1c5c19f38779fbe029ae99ee32988293,885e6e9e9031e391d5ef12abbb6c6aef,10", - username2: 'this-is-no-hash' - }; - shouldNotBeLoggedIn(await db.session({admins: admins})); - const error = await shouldThrowError(async () => - await db.logIn("username2", "test", {admins: admins}) - ); - error.status.should.equal(401); - shouldNotBeLoggedIn(await db.session({admins: admins})); + it('should not accept invalid session ids', async () => { + const err = await shouldThrowError(async () => { + await db.multiUserSession('invalid-session-id'); + }); + err.status.should.equal(400); + err.name.should.equal('bad_request'); + err.message.should.contain('Malformed'); }); afterEach(async () => { @@ -198,15 +177,13 @@ describe('SyncAuthTests', () => { describe('AsyncAuthTests', () => { beforeEach(async () => { - db = setup() + db = setup(); }); afterEach(teardown); it('should suport the basics', done => { function cb(err) { - if (err) { - return done(err); - } - db.stopUsingAsAuthenticationDB(done); + db.stopUsingAsAuthenticationDB(); + done(err); } db.useAsAuthenticationDB(cb); }); @@ -245,3 +222,72 @@ describe('AsyncAuthTestsWithoutDaemon', () => { resp.abc.lastIndexOf(",11").should.equal(resp.abc.length - 3); }); }); + +describe('No automated test setup', () => { + beforeEach(() => { + db = setup(); + }); + afterEach(teardown); + + it('should support admin logins', async () => { + const opts = { + admins: { + username: '-pbkdf2-37508a1f1c5c19f38779fbe029ae99ee32988293,885e6e9e9031e391d5ef12abbb6c6aef,10' + }, + secret: db.generateSecret() + }; + await db.useAsAuthenticationDB(opts); + + shouldNotBeLoggedIn(await db.multiUserSession()); + const logInData = await db.multiUserLogIn('username', 'test'); + shouldBeSuccesfulLogIn(logInData, ['_admin']); + + db.stopUsingAsAuthenticationDB(); + await db.useAsAuthenticationDB({/* no admins */}); + + //if admins not supplied, there's no session (admin party!) + shouldBeAdminParty(await db.multiUserSession(logInData.sessionID)); + + db.stopUsingAsAuthenticationDB(); + await db.useAsAuthenticationDB(opts); + + //otherwise there is + const sessionData = await db.multiUserSession(logInData.sessionID); + shouldBeLoggedIn(sessionData, ["_admin"]); + + //check if logout works (i.e. forgetting the session id.) + shouldNotBeLoggedIn(await db.multiUserSession()); + }); + + it('should handle invalid admins field on login', async () => { + const admins = { + username: "-pbkdf2-37508a1f1c5c19f38779fbe029ae99ee32988293,885e6e9e9031e391d5ef12abbb6c6aef,10", + username2: 'this-is-no-hash' + }; + await db.useAsAuthenticationDB({admins: admins}); + + shouldNotBeLoggedIn(await db.session()); + const error = await shouldThrowError(async () => + await db.logIn("username2", "test") + ); + error.status.should.equal(401); + shouldNotBeLoggedIn(await db.session()); + }); + + it('should not accept timed out sessions', async () => { + // example stolen from calculate-couchdb-session-id's test suite. That + // session timed out quite a bit ago. + + await db.useAsAuthenticationDB({ + secret: '4ed13457964f05535fbb54c0e9f77a83', + timeout: 3600, + admins: { + // password 'test' + 'jan': '-pbkdf2-2be978bc2be874f755d8899cfddad18ed78e3c09,d5513283df4f649c72757a91aa30bdde,10' + } + }) + + var sessionID = 'amFuOjU2Njg4MkI5OkEK3-1SRseo6yNRHfk-mmk6zOxm'; + shouldNotBeLoggedIn(await db.multiUserSession(sessionID)); + }); +}); diff --git a/test/signatures.js b/test/signatures.js index b2fbc13..f81281f 100644 --- a/test/signatures.js +++ b/test/signatures.js @@ -1,4 +1,4 @@ -import {PouchDB, Auth} from './utils'; +import {setup, teardown, Auth} from './utils'; describe('hashAdminPasswords', () => { it('should return a promise', async () => { @@ -11,11 +11,14 @@ describe('hashAdminPasswords', () => { }); describe('workflow', () => { + let db; + beforeEach(() => { + db = setup(); + }); + afterEach(teardown); it('should not throw and methods should return promises', async () => { - const db = new PouchDB('test'); await db.useAsAuthenticationDB(); await db.session(() => {}); - await db.stopUsingAsAuthenticationDB(); - await db.destroy(); + db.stopUsingAsAuthenticationDB(); }); });