@solid/oidc-auth-manager
Version:
An OpenID Connect (OIDC) authentication manager (OP, RP and RS) for decentralized peer-to-peer authentication
346 lines (311 loc) • 8.84 kB
JavaScript
'use strict'
const KVPFileStore = require('kvplus-files')
const bcrypt = require('bcryptjs')
const DEFAULT_SALT_ROUNDS = 10
class UserStore {
/**
* @constructor
*
* @param [options={}] {Object} Options hashmap
* @param [options.path] {string} Directory path where the various collections
* (users etc) will be stored.
*
* @param [options.saltRounds] {number} Number of `bcrypt` password hash
* salt rounds.
* @see https://www.npmjs.com/package/bcrypt
*
* @param [options.backend] {KVPFileStore} Optional Key/Value file store
* (will be initialized if not passed in).
* @see https://github.com/solid/kvplus-files
*/
constructor (options) {
this.backend = options.backend
this.saltRounds = options.saltRounds
}
/**
* Factory method, constructs a UserStore instance from passed in options.
* Usage:
*
* ```
* let options = {
* path: './db/users',
* saltRounds: 10
* }
* let store = UserStore.from(options)
* ```
*
* @param options {Object} Options hashmap
*
* @param options.path {string} Directory path where the various collections
* (users etc) will be stored. Used to initialize a backend.
*
* @param [options.saltRounds] {number} Number of `bcrypt` password hash
* salt rounds.
*
* @return {UserStore}
*/
static from (options) {
options.saltRounds = options.saltRounds || DEFAULT_SALT_ROUNDS
const storeOptions = UserStore.backendOptionsFor(options.path)
options.backend = new KVPFileStore(storeOptions)
return new UserStore(options)
}
/**
* Constructs and returns options for initializing a default KVPFileStore
* instance.
*
* @param path {string} Directory path where the various collections
* (users etc) will be stored. Used to initialize a backend if it's not
* explicitly passed in.
*
* @return {Object}
*/
static backendOptionsFor (path) {
return {
path,
collections: ['users', 'users-by-email']
}
}
/**
* Creates and returns an fs-safe filename key from an email.
*
* @param email {string|null}
*
* @return {string|null}
*/
static normalizeEmailKey (email) {
if (!email) { return null }
return encodeURIComponent(email)
}
/**
* Creates and returns an fs-safe filename key from an id string (which can
* be an http URI).
*
* @param id {string|null}
*
* @return {string|null}
*/
static normalizeIdKey (id) {
if (!id) { return null }
return encodeURIComponent(id)
}
/**
* Initializes the backend store's collections (if using a file-system based
* backend, this creates directories for each collection).
*/
initCollections () {
this.backend.initCollections()
}
/**
* Generates a salted password hash, saves user credentials to the 'users'
* collection, and makes an index entry into the 'users-by-email' collection
* if applicable.
*
* @param user {UserAccount} User account currently being created
* @param password {string} User's login password
*
* @throws {TypeError} HTTP 400 errors if required parameters are missing.
*
* @return {Promise<Object>} Resolves to stored user object hashmap
*/
createUser (user, password) {
return Promise.resolve()
.then(() => {
this.validateUser(user)
this.validatePassword(password)
})
.then(() => this.hashPassword(password))
.then(hashedPassword => {
user.hashedPassword = hashedPassword
return this.saveUser(user)
})
.then(() => {
return this.saveUserByEmail(user)
})
}
/**
* Updates (overwrites) a user record with the new password.
*
* @param user {UserAccount}
* @param password {string}
*
* @return {Promise}
*/
updatePassword (user, password) {
return Promise.resolve()
.then(() => {
this.validateUser(user)
this.validatePassword(password)
})
.then(() => this.hashPassword(password))
.then(hashedPassword => {
user.hashedPassword = hashedPassword
return this.saveUser(user)
})
}
validateUser (user) {
if (!user || !user.id) {
const error = new TypeError('No user id provided to user store')
error.status = 400
throw error
}
}
validatePassword (password) {
if (!password) {
const error = new TypeError('No password provided')
error.status = 400
throw error
}
}
/**
* Saves a serialized user object to the 'users' collection.
*
* @param user {UserAccount}
*
* @return {Promise}
*/
saveUser (user) {
const userKey = UserStore.normalizeIdKey(user.id)
return Promise.resolve()
.then(() => {
if (user.localAccountId) {
return this.saveAliasUserRecord(user.localAccountId, user.id)
}
})
.then(() => this.backend.put('users', userKey, user))
}
/**
* Permanently deletes the files of a user
*
* @param user {UserAccount}
*
* @return {Promise}
*/
deleteUser (user) {
const userKey = UserStore.normalizeIdKey(user.id)
let deletedEmail
if (user.email) {
const emailKey = UserStore.normalizeEmailKey(user.email)
deletedEmail = this.backend.del('users-by-email', emailKey)
} else {
deletedEmail = Promise.reject(new Error('No email given'))
}
const deletedUser = this.backend.del('users', userKey)
return Promise.all([deletedEmail, deletedUser])
}
/**
* Saves an "alias" user object, used for linking local account IDs to
* external Web IDs.
*
* @param fromId {string}
* @param toId {string}
*
* @returns {Promise}
*/
saveAliasUserRecord (fromId, toId) {
const aliasRecord = {
link: toId
}
const aliasKey = UserStore.normalizeIdKey(fromId)
return this.backend.put('users', aliasKey, aliasRecord)
}
/**
* Creates an entry for the user id in the 'users-by-email' index, if
* applicable.
*
* @param user {UserAccount}
*
* @return {Promise}
*/
saveUserByEmail (user) {
if (user.email) {
const userByEmail = { id: user.id }
const key = UserStore.normalizeEmailKey(user.email)
return this.backend.put('users-by-email', key, userByEmail)
} else {
return Promise.resolve()
}
}
/**
* Loads and returns a user object for a given id.
*
* @param userId {string}
*
* @return {Promise<Object>} User info, parsed from a JSON string
*/
findUser (userId) {
const userKey = UserStore.normalizeIdKey(userId)
return this.backend.get('users', userKey)
.then(user => {
if (user && user.link) {
// this is an alias record, fetch the user it points to
return this.findUser(user.link)
}
return user
})
}
/**
* Loads and returns a user object for a given email.
*
* @param email {string}
*
* @return {Promise<Object>} User info, parsed from a JSON string
*/
findUserByEmail (email) {
const emailKey = UserStore.normalizeEmailKey(email)
return this.backend.get('users-by-email', emailKey)
.then(user => {
if (user && user.link) {
// this is an alias record, fetch the user it points to
return this.findUser(user.link)
}
return user
})
}
/**
* Creates and returns a salted password hash, for storage with the user
* record.
*
* @see https://www.npmjs.com/package/bcrypt
*
* @param plaintextPassword {string}
*
* @throws {Error}
*
* @return {Promise<string>} Combined salt and password hash, bcrypt style
*/
hashPassword (plaintextPassword) {
return new Promise((resolve, reject) => {
bcrypt.hash(plaintextPassword, this.saltRounds, (err, hashedPassword) => {
if (err) { return reject(err) }
resolve(hashedPassword)
})
})
}
/**
* Returns the user object if the plaintext password matches the stored hash,
* and returns a `null` if there is no match.
*
* @param user {UserAccount}
* @param user.hashedPassword {string} Created by a previous call to
* `hashPassword()` and stored in the user object.
*
* @param plaintextPassword {string} For example, submitted by a user from a
* login form.
*
* @return {Promise<UserAccount|null>}
*/
matchPassword (user, plaintextPassword) {
return new Promise((resolve, reject) => {
bcrypt.compare(plaintextPassword, user.hashedPassword, (err, res) => {
if (err) { return reject(err) }
if (res) { // password matches
return resolve(user)
}
return resolve(null)
})
})
}
}
module.exports = UserStore
module.exports.DEFAULT_SALT_ROUNDS = DEFAULT_SALT_ROUNDS