diff --git a/docs/utils.md b/docs/utils.md new file mode 100644 index 0000000..9908aab --- /dev/null +++ b/docs/utils.md @@ -0,0 +1,62 @@ +# Utility Components and Methods API + +A number of useful routines for user management are available via a promise based API. +These are useful for writing management and batch routines, building other HTTP interfaces +and services on top of the system, and testing. + +## `gpii.express.user.utils` + +Component containing a number of useful utility methods. Detailed JSDocs for the +component and invokers are located in [utils.js](../src/js/server/utils.js). + +### Component Options + +This component requires the same CouchDB options as other components in this module. + +| Option | Type | Description | +| ------------------ | ---------- | ----------- | +| `couch.port` | `{String}` | The port on which our CouchDB instance runs. | +| `couch.userDbName` | `{String}` | The CouchDB database name containing our user records. Defaults to `users`. | +| `couch.userDbUrl` | `{String}` | The URL of our CouchDB instance. By default this is expanded from `userDbName` above using the pattern `http://admin:admin@localhost:%port/%userDbName` (see above). | + +### Component Invokers + +### `{that}.createNewUser(userData)` + +* `userData` - Take an object with `username`, `email`, and `password` entries. +* Returns: A promise resolving with the new CouchDB user record on success, or + a promise rejecting with an `error` property and message on failure. + +Creates a new user in the system with a username, email, and password. + +```javascript +utils.createNewUser({username: "alice", email: "alice@gpii.org", password: "#1 Cloudsafe!"}); +``` + +### `{that}.verifyPassword(userRecord, password)` + +* `userRecord` - Takes a full user record object as stored in CouchDB and checks to see if the + supplied password matches. In general this should be a record retrieved from CouchDB and not + something you generate yourself. For reference, the `userRecord` structure can be seen in + this [test fixture](https://github.com/GPII/gpii-express-user/blob/a83beacdffb4096a379fe91a8f6e23979839fdd8/tests/data/users.json). +* `password` - Password to verify against the userRecord. +* Returns: `true` if the password matches, otherwise `false`. + +```javascript +var userRecord = couchDBStore.getUserRecordFromCouchForUsernameOrEmail("alice"); +utils.verifyPassword(userRecord, "#1 CloudSafe!"); // true if this is their password +``` + +Returns a promise that will contain the user recored on a successful resolve. + +### `{that}.unlockUser(username, password)` + +* `username` - Username string to unlock. +* `password` - Password string to use for unlocking `username`. +* Returns a promise resolving with the `userData` record if the password is correct, + otherwise rejecting with an `isError` Object. + +```javascript +var userRecord = utils.unlockUser("alice", "#1 Cloudsafe!"); +// userRecord is their full internal record from CouchDB +``` diff --git a/src/js/server/api.js b/src/js/server/api.js index 11e81e9..d774890 100644 --- a/src/js/server/api.js +++ b/src/js/server/api.js @@ -20,6 +20,7 @@ require("./logout.js"); require("./reset.js"); require("./signup.js"); require("./verify.js"); +require("./utils.js"); fluid.registerNamespace("gpii.express.user.api"); @@ -55,6 +56,10 @@ fluid.defaults("gpii.express.user.api", { "source": "{that}.options.couch", "target": "{that gpii.express.router}.options.couch" }, + { + "source": "{that}.options.couch", + "target": "{that gpii.express.user.utils}.options.couch" + }, { source: "{that}.options.app", target: "{that gpii.express.router}.options.app" @@ -65,6 +70,9 @@ fluid.defaults("gpii.express.user.api", { } ], components: { + utils: { + type: "gpii.express.user.utils" + }, // API Endpoints (routers) current: { type: "gpii.express.user.current", @@ -132,6 +140,7 @@ fluid.defaults("gpii.express.user.api", { } }); + /* A mix-in grade that adds the required session middleware to an instance of `gpii.express` or `gpii.express.router`. diff --git a/src/js/server/login.js b/src/js/server/login.js index a8efe22..0c8627d 100644 --- a/src/js/server/login.js +++ b/src/js/server/login.js @@ -13,25 +13,17 @@ require("./lib/password"); fluid.registerNamespace("gpii.express.user.login"); -gpii.express.user.login.post.handler.verifyPassword = function (that, response) { - // The user exists, so we can check the supplied password against our records. - if (response.username) { - var encodedPassword = gpii.express.user.password.encode(that.options.request.body.password, response.salt, response.iterations, response.keyLength, response.digest); - if (encodedPassword === response.derived_key) { - // Transform the raw response to ensure that nothing sensitive is exposed to the user - var user = fluid.model.transformWithRules(response, that.options.rules.user); +gpii.express.user.login.post.handler.verifyPassword = function (that, utils, request) { + utils.unlockUser(request.body.username, request.body.password).then( + function (data) { + var user = fluid.model.transformWithRules(data, that.options.rules.user); that.options.request.session[that.options.sessionKey] = user; that.sendResponse(200, { message: that.options.messages.success, user: user}); - } - // The password didn't match. - else { + }, + function () { that.sendResponse(401, { isError: true, message: that.options.messages.failure}); } - } - // The user doesn't exist, but we send the same failure message to avoid giving intruders a way to validate usernames. - else { - that.sendResponse(401, { isError: true, message: that.options.messages.failure}); - } + ); }; fluid.defaults("gpii.express.user.login.post.handler", { @@ -50,8 +42,8 @@ fluid.defaults("gpii.express.user.login.post.handler", { method: "post", invokers: { handleRequest: { - func: "{reader}.get", - args: ["{that}.options.request.body"] + funcName: "gpii.express.user.login.post.handler.verifyPassword", + args: ["{that}", "{gpii.express.user.utils}", "{that}.options.request"] } }, components: { diff --git a/src/js/server/signup.js b/src/js/server/signup.js index e2ba370..72afad4 100644 --- a/src/js/server/signup.js +++ b/src/js/server/signup.js @@ -1,10 +1,9 @@ /* eslint-env node */ "use strict"; + var fluid = require("infusion"); var gpii = fluid.registerNamespace("gpii"); -var request = require("request"); // TODO: Replace this with a writable data source. - require("gpii-handlebars"); require("./lib/datasource"); @@ -19,48 +18,22 @@ gpii.express.user.signup.post.handler.lookupExistingUser = function (that) { that.reader.get(that.options.request.body).then(that.checkForExistingUser); }; -gpii.express.user.signup.post.handler.checkForExistingUser = function (that, response) { +gpii.express.user.signup.post.handler.checkForExistingUser = function (that, utils, response) { if (response && response.username) { that.sendResponse(403, { isError: true, message: "A user with this email or username already exists."}); } else { - // Encode the user's password - var salt = gpii.express.user.password.generateSalt(that.options.saltLength); - var derived_key = gpii.express.user.password.encode(that.options.request.body.password, salt); - var code = gpii.express.user.password.generateSalt(that.options.verifyCodeLength); - - // Our rules will set the defaults and pull approved values from the original submission. - var combinedRecord = fluid.model.transformWithRules(that.options.request.body, that.options.rules.write); - - // Set the "name" to the username for backward compatibility with CouchDB - combinedRecord.salt = salt; - combinedRecord.derived_key = derived_key; - combinedRecord[that.options.codeKey] = code; - - // Set the ID to match the CouchDB conventions, for backward compatibility - combinedRecord._id = "org.couch.db.user:" + combinedRecord.username; - - // Save the record for later use in rendering the outgoing email - that.user = combinedRecord; - - // Write the record to couch. TODO: Migrate this to a writable dataSource. - var writeOptions = { - url: that.options.urls.write, - method: "POST", - json: true, - body: combinedRecord - }; - request(writeOptions, function (error, response, body) { - if (error) { - that.sendResponse(500, {isError: true, message: error}); - } - else if ([200, 201].indexOf(response.statusCode) === -1) { - that.sendResponse(response.statusCode, { isError: true, message: body}); - } - else { + var body = that.options.request.body; + utils.createNewUser(body).then( + function (data) { + // Save the record for later use in rendering the outgoing email + that.user = data; that.sendMessage(); + }, + function (error) { + that.sendResponse(500, {isError: true, message: error}); } - }); + ); } }; @@ -77,17 +50,6 @@ fluid.defaults("gpii.express.user.signup.post.handler", { error: "A verification code could not be sent. Contact an administrator." }, rules: { - // Only let the user supply a very particular set of fields. - write: { - "name": "username", // Default rules are designed to cater to CouchDB and express-couchUser conventions, but can be overriden. - "username": "username", - "email": "email", - roles: { literalValue: []}, - type: { literalValue: "user"}, - password_scheme: { literalValue: "pbkdf2"}, - iterations: { literalValue: 10}, - verified: { literalValue: false} - }, mailOptions: { to: "user.email", subject: { literalValue: "Please verify your account..."} @@ -108,7 +70,7 @@ fluid.defaults("gpii.express.user.signup.post.handler", { }, "checkForExistingUser": { funcName: "gpii.express.user.signup.post.handler.checkForExistingUser", - args: ["{that}", "{arguments}.0"] + args: ["{that}", "{gpii.express.user.utils}", "{arguments}.0"] } }, components: { diff --git a/src/js/server/utils.js b/src/js/server/utils.js new file mode 100644 index 0000000..1ca9bfe --- /dev/null +++ b/src/js/server/utils.js @@ -0,0 +1,188 @@ +/* + + Utilities methods for performing user operations. These utilities should + be usable from both http endpoints, and any other locations that can + consume promise based API's. + + Utilities are included for: + - Creating new user accounts + - Unlocking user accounts with a username/email and password. + + */ +/* eslint-env node */ +"use strict"; +var fluid = require("infusion"); +var request = require("request"); // TODO: Replace this with a writable data source. + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.express.user.utils"); + +/** + * gpii.express.user.utils + * + * Component that implements backend utilities as promise based + * invokers that can be used from http handers or other infrastructure. + * + * Expects to be distributed the same couch options as the other components + * in gpii-express-user. + */ +fluid.defaults("gpii.express.user.utils", { + gradeNames: ["fluid.component"], + invokers: { + createNewUser: { + funcName: "gpii.express.user.utils.createNewUser", + args: ["{that}", "{arguments}.0"] // options + }, + unlockUser: { + funcName: "gpii.express.user.utils.unlockUser", + args: ["{that}", "{arguments}.0", "{arguments}.1"] // username, password + } + }, + byUsernameOrEmailUrl: { + expander: { + funcName: "fluid.stringTemplate", + args: [ "%userDbUrl/_design/lookup/_view/byUsernameOrEmail?key=\"%username\"", "{that}.options.couch"] + } + }, + writeUrl: "{that}.options.couch.userDbUrl", + saltLength: 32, + verifyCodeLength: 16, + codeKey: "verification_code", // Must match the value in gpii.express.user.verify + rules: { + createUserWrite: { + "name": "username", // Default rules are designed to cater to CouchDB and express-couchUser conventions, but can be overriden. + "username": "username", + "email": "email", + roles: { literalValue: []}, + type: { literalValue: "user"}, + password_scheme: { literalValue: "pbkdf2"}, + iterations: { literalValue: 10}, + verified: { literalValue: false} + } + }, + components: { + byUsernameOrEmailReader: { + // TODO: Replace with the new "asymmetric" dataSource once that code has been reviewed + type: "gpii.express.user.couchdb.read", + options: { + url: "{gpii.express.user.utils}.options.byUsernameOrEmailUrl", + rules: { + read: { + "": "rows.0.value", + "username": "rows.0.value.name" + } + }, + termMap: { username: "%username"} + } + } + } +}); + +/** + * Creates a new gpii-express-user in the configured user database. + * `userData` is an object of values to create the new user with. + * At the very least this should consist of values for `username`, + * `email`, and `password`. The transformation acting on this data + * is located at `{gpii.express.user.utils}.rules.createUserWrite`. + * + * @param {gpii.express.user.utils} that - Utils Component + * @param {Object} userData - Object with values for the new user. + * @param {String} userData.username - New users username + * @param {String} userData.email - New users email + * @param {String} userData.password - Password for new account + * @return {fluid.promise} - Promise resolving with the new CouchDB record for + * the account or rejecting with an `error` property and message. + */ +gpii.express.user.utils.createNewUser = function (that, userData) { + // Encode the user's password + var salt = gpii.express.user.password.generateSalt(that.options.saltLength); + var derived_key = gpii.express.user.password.encode(userData.password, salt); + var code = gpii.express.user.password.generateSalt(that.options.verifyCodeLength); + + // Our rules will set the defaults and pull approved values from the original submission. + var combinedRecord = fluid.model.transformWithRules(userData, that.options.rules.createUserWrite); + + // Set the "name" to the username for backward compatibility with CouchDB + combinedRecord.salt = salt; + combinedRecord.derived_key = derived_key; + combinedRecord[that.options.codeKey] = code; + + // Set the ID to match the CouchDB conventions, for backward compatibility + combinedRecord._id = "org.couch.db.user:" + combinedRecord.username; + + // Write the record to couch. TODO: Migrate this to a writable dataSource. + var writeOptions = { + url: that.options.writeUrl, + method: "POST", + json: true, + body: combinedRecord + }; + var promiseTogo = fluid.promise(); + request(writeOptions, function (error, response, body) { + if (error) { + promiseTogo.reject({isError: true, message: error}); + } + else if ([200, 201].indexOf(response.statusCode) === -1) { + promiseTogo.reject({ isError: true, message: body}); + } + else { + promiseTogo.resolve(combinedRecord); + } + }); + return promiseTogo; +}; + +/** + * gpii.express.user.utils.verifyPassword function + * + * Given an instance of our standard couch `userRecord`, and the clear text + * `password`, check to see if the password is valid for for the user. + * + * @param {Object} userRecord - Our standard internal user object stored in CouchDB. + * @param {String} password - Password to use to login/unlock user. + * @return {Boolean} - True if this is the correct password, otherwise false. + */ +gpii.express.user.utils.verifyPassword = function (userRecord, password) { + var encodedPassword = gpii.express.user.password.encode(password, + userRecord.salt, userRecord.iterations, userRecord.keyLength, userRecord.digest); + return encodedPassword === userRecord.derived_key; +}; + +/** + * gpii.express.user.utils.unlockUser method + * + * Attempts to look up username using the current CouchDB view. (At the time + * of writing either username or email). And unlock their account using `password`. + * If the username and password match, the CouchDB `userData` record will be returned. + * Otherwise a standard error Object is returned. + * + * @param {gpii.express.user.utils} that - Utils component. + * @param {String} username - Username to use for record lookup. + * @param {String} password - Clear text password to validate record with. + * @return {fluid.promise} - Promise resolving with a `userData` record if the password is correct, otherwise + * rejecting with an `isError` Object. + */ +gpii.express.user.utils.unlockUser = function (that, username, password) { + var promiseTogo = fluid.promise(); + that.byUsernameOrEmailReader.get({username: username}).then( + function (body) { + if (body.username) { + var user = body; + var encodedPassword = gpii.express.user.password.encode(password, user.salt, user.iterations, user.keyLength, user.digest); + if (encodedPassword === user.derived_key) { + promiseTogo.resolve(user); + } + else { + promiseTogo.reject({isError: true, message: "Bad username/password"}); + } + } + else { + promiseTogo.reject({isError: true, message: "Bad username/password"}); + } + }, + function (err) { + promiseTogo.reject({isError: true, message: "Bad username/password" + JSON.stringify(err)}); + } + ); + return promiseTogo; +}; diff --git a/tests/js/server/index.js b/tests/js/server/index.js index 0e78588..00daac8 100644 --- a/tests/js/server/index.js +++ b/tests/js/server/index.js @@ -8,3 +8,4 @@ require("./loginRequired-tests"); require("./mailer-tests.js"); require("./password-function-tests.js"); require("./signup-tests.js"); +require("./utils-tests.js"); diff --git a/tests/js/server/utils-tests.js b/tests/js/server/utils-tests.js new file mode 100644 index 0000000..37af086 --- /dev/null +++ b/tests/js/server/utils-tests.js @@ -0,0 +1,119 @@ +/* + + Tests for the gpii.express.user.utils component. + + */ +/* eslint-env node */ +"use strict"; + +var fluid = require("infusion"); +var gpii = fluid.registerNamespace("gpii"); + +require("../../../"); +require("../lib/"); + +var jqUnit = require("node-jqunit"); + +fluid.registerNamespace("gpii.tests.express.user.utils.caseHolder"); + +gpii.tests.express.user.utils.createUser = function (utils) { + var prom = utils.createNewUser({ + username: "myFirstUser", + password: "this is a password", + email: "myFirstUser@gpii.net" + }); + prom.then(function (data) { + jqUnit.assertEquals("Generated Couch ID", "org.couch.db.user:myFirstUser", data._id); + jqUnit.assertEquals("Email", "myFirstUser@gpii.net", data.email); + jqUnit.assertEquals("Username", "myFirstUser", data.username); + jqUnit.assertTrue("Unlock the password", gpii.express.user.utils.verifyPassword(data, "this is a password")); + }, function (err) { + jqUnit.fail("Unable to create user with error: " + err); + }); + return prom; +}; + +// TODO Can the Infusion IoC tasks resolve references? ie. instead of this function +// use { +// task: ["{gpii.express.user.utils}.unlockUser"], +// args: ["existing", "password"], +gpii.tests.express.user.utils.unlockPromise = function (utils, username, password) { + return utils.unlockUser(username, password); +}; + +// Each test has a request instance of `kettle.test.request.http` or `kettle.test.request.httpCookie`, +// and a test module that wires the request to the listener that handles its results. +fluid.defaults("gpii.tests.express.user.utils.caseHolder", { + gradeNames: ["gpii.test.webdriver.caseHolder"], + rawModules: [ + { + name: "Testing login functions...", + tests: [ + { + name: "Create a new user and verify their data.", + type: "test", + sequence: [ + { + task: "gpii.tests.express.user.utils.createUser", + args: ["{gpii.express.user.utils}"], + resolve: "jqUnit.assert", + resolveArgs: ["Successfully created User"] + } + ] + }, + { + name: "Testing unlocking a user with correct credentials.", + type: "test", + sequence: [ + { + task: "gpii.tests.express.user.utils.unlockPromise", + args: ["{gpii.express.user.utils}", "existing", "password"], + resolve: "jqUnit.assertEquals", + resolveArgs: ["Check verified username", "existing", "{arguments}.0.username"] + } + ] + }, + { + name: "Testing not unlocking a user with incorrect credentials.", + type: "test", + sequence: [ + { + task: "gpii.tests.express.user.utils.unlockPromise", + args: ["{gpii.express.user.utils}", "not-existing", "password"], + reject: "jqUnit.assert", + rejectArgs: ["Succeeded in not unlocking with incorrect credentials."] + } + ] + } + + ] + } + ] +}); + +fluid.defaults("gpii.tests.express.user.utils.environment", { + gradeNames: ["gpii.test.express.user.environment"], + port: 8778, + pouchPort: 8764, + mailPort: 8725, + components: { + caseHolder: { + type: "gpii.tests.express.user.utils.caseHolder" + }, + utils: { + type: "gpii.express.user.utils", + options: { + couch: { + userDbUrl: { + expander: { + funcName: "fluid.stringTemplate", + args: ["http://127.0.0.1:%port/users", { port: "{environment}.options.pouchPort"}] + } + } + } + } + } + } +}); + +fluid.test.runTests("gpii.tests.express.user.utils.environment");