UNPKG

@quarks/quarks-iam

Version:

A modern authorization server built to authenticate your users and protect your APIs

457 lines (382 loc) 10.8 kB
/** * 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