@quarks/quarks-iam
Version:
A modern authorization server built to authenticate your users and protect your APIs
457 lines (382 loc) • 10.8 kB
JavaScript
/**
* Module dependencies
*/
var client = require('../boot/redis').getClient()
var settings = require('../boot/settings')
var providers = require('../providers')
var bcrypt = require('bcryptjs')
var CheckPassword = require('mellt').CheckPassword
var Modinha = require('modinha')
var Document = require('modinha-redis')
var PasswordRequiredError = require('../errors/PasswordRequiredError')
var InsecurePasswordError = require('../errors/InsecurePasswordError')
/**
* User model
*/
var User = Modinha.define('users', {
// OpenID Connect Standard Claims
//
// NOTE: The "sub" claim is stored as `_id`.
// Expose it as `sub` via mappings.
name: { type: 'string' },
givenName: { type: 'string' },
familyName: { type: 'string' },
middleName: { type: 'string' },
nickname: { type: 'string' },
preferredUsername: { type: 'string' },
profile: { type: 'string' },
picture: { type: 'string' },
website: { type: 'string' },
email: {
type: 'string',
// required: true,
unique: true,
format: 'email'
},
emailVerified: { type: 'boolean', default: false },
dateEmailVerified: { type: 'number' },
emailVerifyToken: {
type: 'string',
unique: true
},
gender: { type: 'string' },
birthdate: { type: 'string' },
zoneinfo: { type: 'string' },
locale: { type: 'string' },
phoneNumber: { type: 'string' },
phoneNumberVerified: { type: 'boolean', default: false },
address: { type: 'object' },
// Hashed password
hash: {
type: 'string',
private: true,
set: hashPassword
},
/**
* Each provider object in user.providers should include
* - Provider user/account id
* - Name of provider
* - Protocol of provider
* - Complete authorization response from provider
* - Complete userInfo response from the provider
* - Last login time
* - Last login provider
*/
providers: {
type: 'object',
default: {},
set: function (data) {
var providers = this.providers = this.providers || {}
Object.keys(data.providers || {}).forEach(function (key) {
providers[key] = data.providers[key]
})
}
},
// supports indexing user.providers.PROVIDER.info.id
lastProvider: {
type: 'string'
}
})
/**
* Hash Password Setter
*/
function hashPassword (data) {
var password = data.password
var hash = data.hash
if (password) {
var salt = bcrypt.genSaltSync(10)
hash = bcrypt.hashSync(password, salt)
}
this.hash = hash
}
/**
* UserInfo Mapping
*/
User.mappings.userinfo = {
'_id': 'sub',
'name': 'name',
'givenName': 'given_name',
'familyName': 'family_name',
'middleName': 'middle_name',
'nickname': 'nickname',
'preferredUsername': 'preferred_username',
'profile': 'profile',
'picture': 'picture',
'website': 'website',
'email': 'email',
'emailVerified': 'email_verified',
'gender': 'gender',
'birthdate': 'birthdate',
'zoneinfo': 'zoneinfo',
'locale': 'locale',
'phoneNumber': 'phone_number',
'phoneNumberVerified': 'phone_number_verified',
'address': 'address',
'created': 'joined_at',
'modified': 'updated_at'
}
/**
* Document persistence
*/
User.extend(Document)
User.__client = client
/**
* User intersections
*/
User.intersects('roles')
/**
* Authorized scope
*/
User.prototype.authorizedScope = function (callback) {
var client = User.__client
var self = this
// get a list of unrestricted scope names
client.zrange('scopes:restricted:false', 0, -1, function (err, unrestricted) {
if (err) { return callback(err) }
// get a list of roles for this user
client.zrange('users:' + self._id + ':roles', 0, -1, function (err, roles) {
if (err) { return callback(err) }
if (!roles || roles.length === 0) {
return callback(null, unrestricted)
}
var multi = client.multi()
roles.forEach(function (role) {
multi.zrange('roles:' + role + ':scopes', 0, -1)
})
multi.exec(function (err, results) {
if (err) { return callback(err) }
callback(null, [].concat.apply(unrestricted, results.map(function (result) {
return result[1]
})))
})
})
})
}
/**
* Verify password
*/
User.prototype.verifyPassword = function (password, callback) {
if (!this.hash) { return callback(null, false) }
bcrypt.compare(password, this.hash, callback)
}
/**
* Verify password strength
*/
User.verifyPasswordStrength = function (password) {
var daysToCrack = providers.password.daysToCrack
if (CheckPassword(password) <= daysToCrack) {
return false
}
return true
}
/**
* Change password
*/
User.changePassword = function (id, password, callback) {
// require a password
if (!password) {
return callback(new PasswordRequiredError())
}
// require password to be strong
if (!User.verifyPasswordStrength(password)) {
return callback(new InsecurePasswordError())
}
User.patch(id,
{ password: password },
{ private: true },
function (err, user) {
if (err) { return callback(err) }
if (!user) { return callback(null, null) }
callback(null, user)
}
)
}
/**
* Create
*/
User.insert = function (data, options, callback) {
var collection = User.collection
if (!callback) {
callback = options
options = {}
}
if (options.password !== false) {
// require a password
if (!data.password) {
return callback(new PasswordRequiredError())
}
// check the password strength
if (!User.verifyPasswordStrength(data.password)) {
return callback(new InsecurePasswordError())
}
}
// create an instance
var user = User.initialize(data, { private: true })
var validation = user.validate()
// pick up mapped values
if (options.mapping) {
user.merge(data, { mapping: options.mapping })
}
// require a valid user
if (!validation.valid) {
return callback(validation)
}
// catch duplicate values
User.enforceUnique(user, function (err) {
if (err) { return callback(err) }
// batch operations
var multi = User.__client.multi()
// store the user
multi.hset(collection, user._id, User.serialize(user))
// index the user
User.index(multi, user)
// execute ops
multi.exec(function (err) {
if (err) { return callback(err) }
callback(null, User.initialize(user))
})
})
}
/**
* Authenticate
*/
User.authenticate = function (email, password, callback) {
User.getByEmail(email, { private: true }, function (err, user) {
if (err) { return callback(err) }
if (!user) {
return callback(null, false, {
message: 'Unknown user.'
})
}
user.verifyPassword(password, function (err, match) {
if (err) { return callback(err) }
if (match) {
callback(null, User.initialize(user), {
message: 'Authenticated successfully!'
})
} else {
callback(null, false, {
message: 'Invalid password.'
})
}
})
})
}
/**
* Lookup
*
* Takes a request object and third party userinfo
* object and provides either an authenticated user
* or attempts to lookup the user based on a provider
* param in the request and a provider id in the
* userinfo.
*/
User.lookup = function (req, info, callback) {
if (req.user) { return callback(null, req.user) }
var provider = req.params.provider || req.body.provider
var index = User.collection + ':provider:' + provider
User.__client.hget(index, info.id, function (err, id) {
if (err) { return callback(err) }
User.get(id, function (err, user) {
if (err) { return callback(err) }
callback(null, user)
})
})
}
/**
* Index provider user id
*
* This index matches a provider's user id to an
* Anvil Connect `user._id` for later lookup by
* that provider identifier.
*
* This is a tricky index to implement, because we
* don't know the full path ahead of time, so we'll
* need to first resolve it.
*
* Support for this requirement was added to modinha-redis
* for this specific index. The nested array that
* defines `field` gets evaluated first. That value at
* that path is then used as a property name to access
* the id.
*/
User.defineIndex({
type: 'hash',
key: [User.collection + ':provider:$', 'lastProvider'],
field: ['$', ['providers.$.info.id', 'lastProvider']],
value: '_id'
})
/**
* Connect
*/
User.connect = function (req, auth, info, callback) {
var provider = providers[req.params.provider || req.body.provider]
// what if there's no provider param?
// Try to find an existing user.
User.lookup(req, info, function (err, user) {
if (err) { return callback(err) }
// Initialize the user data.
var data = {
providers: {},
lastProvider: provider.id
}
// Set the provider object
data.providers[provider.id] = {
provider: provider.id,
protocol: provider.protocol,
auth: auth,
info: info
}
// Update an existing user with the authorization response
// and userinfo from this provider. By default this will not
// update existing OIDC standard claims values. If
// `refresh_userinfo` is set to `true`, in the configuration
// file, claims will be updated.
if (user) {
if (settings.refresh_userinfo || provider.refresh_userinfo) {
Modinha.map(provider.mapping, info, data)
}
User.patch(user._id, data, function (err, user) {
if (err) { return callback(err) }
callback(null, user)
})
// Create a new user based on this provider's response. This
// WILL map from the provider's raw userInfo properties into the
// user's OIDC standard claims.
} else {
Modinha.map(provider.mapping, info, data)
User.insert(data, {
password: false
}, function (error, user) {
// Handle unique email error
if (error && error.message === 'email must be unique') {
// Lookup the existing user
User.getByEmail(data.email, function (err, user) {
if (err || !user) { return callback(err) }
return callback(null, false, {
message: error.message,
providers: user.providers
})
})
// Handle other error
} else if (error) {
return callback(error)
// User registered successfully
} else {
req.sendVerificationEmail = req.provider.emailVerification.enable
req.flash('isNewUser', true)
callback(null, user, { message: 'Registered successfully' })
}
})
}
})
}
/**
* Errors
*/
User.PasswordRequiredError = PasswordRequiredError
User.InsecurePasswordError = InsecurePasswordError
/**
* Exports
*/
module.exports = User