We'll be storing the user information using a Sequelize-based model in an SQL database. As we go through this, ponder a question: should we integrate the database code directly into the REST API implementation? Doing so would reduce the user information microservice to one module, with database queries mingled with REST handlers. By separating the REST service from the data storage model, we have the freedom to adopt other data storage systems besides Sequelize/SQL. Further, the data storage model could conceivably be used in ways other than the REST service.
Create a new file named users-sequelize.mjs in users, containing the following:
import Sequelize from "sequelize";
import jsyaml from 'js-yaml';
import fs from 'fs-extra';
import util from 'util';
import DBG from 'debug';
const log = DBG('users:model-users');
const error = DBG('users:error');
var SQUser;
var sequlz;
async function connectDB() {
if (SQUser) return SQUser.sync();
const yamltext = await fs.readFile(process.env.SEQUELIZE_CONNECT,
'utf8');
const params = await jsyaml.safeLoad(yamltext, 'utf8');
if (!sequlz) sequlz = new Sequelize(params.dbname, params.username,
params.password,
params.params);
// These fields largely come from the Passport / Portable Contacts
schema.
// See http://www.passportjs.org/docs/profile
//
// The emails and photos fields are arrays in Portable Contacts.
// We'd need to set up additional tables for those.
//
// The Portable Contacts "id" field maps to the "username" field
here
if (!SQUser) SQUser = sequlz.define('User', {
username: { type: Sequelize.STRING, unique: true },
password: Sequelize.STRING,
provider: Sequelize.STRING,
familyName: Sequelize.STRING,
givenName: Sequelize.STRING,
middleName: Sequelize.STRING,
emails: Sequelize.STRING(2048),
photos: Sequelize.STRING(2048)
});
return SQUser.sync();
}
As with our Sequelize-based model for Notes, we use a YAML file to store connection configuration. We're even using the same environment variable, SEQUELIZE_CONNECT.
What is the best storage service for user authentication data? By using Sequelize, we have our pick of SQL databases to choose from. While NoSQL databases are all the rage, is there any advantage to using one to store user authentication data? Nope. An SQL server will do the job just fine, and Sequelize allows us the freedom of choice.
It's tempting to simplify the overall system by using the same database instance to store notes and user information, and to use Sequelize for both. But we've chosen to simulate a secured server for user data. That calls for the data to be in separate database instances, preferably on separate servers. A highly secure application deployment might put the user information service on completely separate servers, perhaps in a physically isolated data center, with carefully configured firewalls, and there might even be armed guards at the door.
The user profile schema shown here is derived from the normalized profile provided by Passport; refer to http://www.passportjs.org/docs/profile for more information. Passport will harmonize information given by third-party services into a single object definition. To simplify our code, we're simply using the schema defined by Passport:
export async function create(username, password, provider, familyName, givenName, middleName, emails, photos) {
const SQUser = await connectDB();
return SQUser.create({
username, password, provider,
familyName, givenName, middleName,
emails: JSON.stringify(emails), photos: JSON.stringify(photos)
});
}
export async function update(username, password, provider, familyName, givenName, middleName, emails, photos) {
const user = await find(username);
return user ? user.updateAttributes({
password, provider,
familyName, givenName, middleName,
emails: JSON.stringify(emails),
photos: JSON.stringify(photos)
}) : undefined;
}
Our create and update functions take user information and either add a new record or update an existing record:
export async function find(username) {
const SQUser = await connectDB();
const user = await SQUser.find({ where: { username: username } });
const ret = user ? sanitizedUser(user) : undefined;
return ret;
}
This lets us look up a user information record, and we return a sanitized version of that data.
Because we're segregating the user data from the rest of the Notes application, we want to return a sanitized object rather than the actual SQUser object. What if there was some information leakage because we simply sent the SQUser object back to the caller? The sanitizedUser function, shown later, creates an anonymous object with exactly the fields we want exposed to the other modules:
export async function destroy(username) {
const SQUser = await connectDB();
const user = await SQUser.find({ where: { username: username } });
if (!user) throw new Error('Did not find requested '+ username +' to delete');
user.destroy();
}
This lets us support deleting user information. We do this as we did for the Notes Sequelize model, by first finding the user object and then calling its destroy method:
export async function userPasswordCheck(username, password) {
const SQUser = await connectDB();
const user = await SQUser.find({ where: { username: username } });
if (!user) {
return { check: false, username: username, message: "Could not
find user" };
} else if (user.username === username && user.password ===
password) {
return { check: true, username: user.username };
} else {
return { check: false, username: username, message: "Incorrect
password" };
}
}
This lets us support the checking of user passwords. The three conditions to handle are as follows:
- Whether there's no such user
- Whether the passwords matched
- Whether they did not match
The object we return lets the caller distinguish between those cases. The check field indicates whether to allow this user to be logged in. If check is false, there's some reason to deny their request to log in, and the message is what should be displayed to the user:
export async function findOrCreate(profile) {
const user = await find(profile.id);
if (user) return user;
return await create(profile.id, profile.password, profile.provider,
profile.familyName, profile.givenName, profile.middleName,
profile.emails, profile.photos);
}
This combines two actions in one function: first, to verify whether the named user exists and, if not, to create that user. Primarily, this will be used while authenticating against third-party services:
export async function listUsers() {
const SQUser = await connectDB();
const userlist = await SQUser.findAll({});
return userlist.map(user => sanitizedUser(user));
}
List the existing users. The first step is using findAll to give us the list of the users as an array of SQUser objects. Then we sanitize that list so we don't expose any data that we don't want exposed:
export function sanitizedUser(user) {
var ret = {
id: user.username, username: user.username,
provider: user.provider,
familyName: user.familyName, givenName: user.givenName,
middleName: user.middleName,
emails: JSON.parse(user.emails),
photos: JSON.parse(user.photos)
};
try {
ret.emails = JSON.parse(user.emails);
} catch(e) { ret.emails = []; }
try {
ret.photos = JSON.parse(user.photos);
} catch(e) { ret.photos = []; }
return ret;
}
This is our utility function to ensure we expose a carefully controlled set of information to the caller. With this service, we're emulating a secured user information service that's walled off from other applications. As we said earlier, this function returns an anonymous sanitized object where we know exactly what's in the object.
It's very important to decode the JSON string we put into the database. Remember that we stored the emails and photos data using JSON.stringify in the database. Using JSON.parse, we decode those values, just like adding hot water to instant coffee produces a drinkable beverage.