UNPKG

pixl-server-user

Version:

A basic user login and session management system for the pixl-server framework.

1,475 lines (1,223 loc) 50.4 kB
// Simple User Login Server Component // A component for the pixl-server daemon framework. // Copyright (c) 2015 Joseph Huckaby // Released under the MIT License var assert = require("assert"); var Class = require("pixl-class"); var Component = require("pixl-server/component"); var Tools = require("pixl-tools"); var Mailer = require('pixl-mail'); var Request = require('pixl-request'); var bcrypt = require('bcrypt-node'); module.exports = Class.create({ __name: 'User', __parent: Component, defaultConfig: { "smtp_hostname": "", "session_expire_days": 30, "max_failed_logins_per_hour": 5, "max_forgot_passwords_per_hour": 3, "free_accounts": 0, "sort_global_users": 1, "use_bcrypt": 1, "mail_logger": 0, "self_delete": 1, "valid_username_match": "^[\\w\\-\\.]+$", "block_username_match": "^(abuse|admin|administrator|localhost|127\\.0\\.0\\.1|nobody|noreply|root|support|sysadmin|webmaster|www|god|staff|null|0|constructor|__defineGetter__|__defineSetter__|hasOwnProperty|__lookupGetter__|__lookupSetter__|isPrototypeOf|propertyIsEnumerable|toString|valueOf|__proto__|toLocaleString)$", "email_templates": { "welcome_new_user": "", "changed_password": "", "recover_password": "" }, "default_privileges": {} }, hooks: null, startup: function(callback) { // start user service this.logDebug(3, "User Manager starting up" ); // register our class as an API namespace this.server.API.addNamespace( "user", "api_", this ); // add local references to other components this.storage = this.server.Storage; this.web = this.server.WebServer; // setup SMTP mailer this.mail = new Mailer( this.config.get('smtp_hostname') || this.server.config.get('smtp_hostname') || "127.0.0.1", this.config.get('smtp_port') || this.server.config.get('smtp_port') || 25 ); this.mail.setOptions( this.server.config.get('mail_options') || this.server.config.get('mail_settings') || {} ); if (this.config.get('mail_logger')) this.mail.attachLogAgent( this.logger ); // hook system for integrating with outer webapp this.hooks = {}; // cache this from config this.usernameMatch = new RegExp( this.config.get('valid_username_match') ); this.usernameBlock = new RegExp( this.config.get('block_username_match'), "i" ); // startup complete callback(); }, normalizeUsername: function(username) { // lower-case, strip all non-alpha if (!username) return ''; return username.toString().toLowerCase().replace(/\W+/g, ''); }, api_create: function(args, callback) { // create new user account var self = this; var user = args.params; var path = 'users/' + this.normalizeUsername(user.username); if (!this.config.get('free_accounts')) { return this.doError('user', "Only administrators can create new users.", callback); } if (!this.requireParams(user, { username: this.usernameMatch, email: /^\S+\@\S+$/, full_name: /\S/, password: /.+/ }, callback)) return; if (user.username.toString().match(this.usernameBlock)) { return this.doError('user', "Username is blocked: " + user.username, callback); } // sanitize user.email = user.email.replace(/<.+>/g, ''); user.full_name = user.full_name.replace(/<.+>/g, ''); // first, make sure user doesn't already exist this.storage.get(path, function(err, old_user) { if (old_user) { return self.doError('user', "User already exists: " + user.username, callback); } // now we can create the user user.active = 1; user.created = user.modified = Tools.timeNow(true); user.salt = Tools.generateUniqueID( 64, user.username ); user.password = self.generatePasswordHash( user.password, user.salt ); user.privileges = Tools.copyHash( self.config.get('default_privileges') || {} ); args.user = user; self.fireHook('before_create', args, function(err) { if (err) { return self.doError('user', "Failed to create user: " + err, callback); } self.logDebug(6, "Creating user", user); self.storage.put( path, user, function(err, data) { if (err) { return self.doError('user', "Failed to create user: " + err, callback); } else { self.logDebug(6, "Successfully created user: " + user.username); self.logTransaction('user_create', user.username, self.getClientInfo(args, { user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ) })); // add to master user list in the background if (self.config.get('sort_global_users')) { self.storage.listInsertSorted( 'global/users', { username: user.username }, ['username', 1], function(err) { if (err) self.logError( 1, "Failed to add user to master list: " + err ); callback({ code: 0 }); // fire after hook in background self.fireHook('after_create', args); } ); } else { self.storage.listUnshift( 'global/users', { username: user.username }, function(err) { if (err) self.logError( 1, "Failed to add user to master list: " + err ); callback({ code: 0 }); // fire after hook in background self.fireHook('after_create', args); } ); } // send e-mail in background (no callback) args.user = user; args.self_url = self.server.config.get('base_app_url') + '/'; self.sendEmail( 'welcome_new_user', args ); } // success } ); // save user } ); // hook before } ); // check exists }, api_login: function(args, callback) { // user login, validate password, create new session var self = this; var params = args.params; if (!this.requireParams(params, { username: this.usernameMatch, password: /.+/ }, callback)) return; // load user first this.storage.get('users/' + this.normalizeUsername(params.username), function(err, user) { if (!user) { return self.doError('login', "Username or password incorrect.", callback); // deliberately vague } if (user.force_password_reset) { return self.doError('login', "Account is locked out. Please reset your password to unlock it.", callback); } if (!self.comparePasswords(params.password, user.password, user.salt)) { // incorrect password // (throttle this to prevent abuse) var date_code = Math.floor( Tools.timeNow() / 3600 ); if (date_code != user.fl_date_code) { user.fl_date_code = date_code; user.fl_count = 1; } else { user.fl_count++; if (user.fl_count > self.config.get('max_failed_logins_per_hour')) { // lockout until password reset self.logDebug(3, "Locking account due to too many failed login attempts: " + params.username); user.force_password_reset = 1; } } // save user to update counters self.storage.put( 'users/' + self.normalizeUsername(params.username), user, function(err, data) { return self.doError('login', "Username or password incorrect.", callback); // deliberately vague } ); return; } if (!user.active) { return self.doError('login', "User account is disabled: " + params.username, callback); } args.user = user; self.fireHook('before_login', args, function(err) { if (err) { return self.doError('login', "Failed to login: " + err, callback); } // dates var now = Tools.timeNow(true); var exp_sec = 86400 * self.config.get('session_expire_days'); var expiration_date = Tools.normalizeTime( now + exp_sec, { hour: 0, min: 0, sec: 0 } ); // create session id and object var session_id = Tools.generateUniqueID( 64, params.username ); var session = { id: session_id, username: params.username, ip: args.ip, useragent: args.request.headers['user-agent'], created: now, modified: now, expires: expiration_date }; self.logDebug(6, "Logging user in: " + params.username + ": New Session ID: " + session_id, session); // store session object self.storage.put('sessions/' + session_id, session, function(err, data) { if (err) { return self.doError('user', "Failed to create session: " + err, callback); } else { self.logDebug(6, "Successfully logged in"); self.logTransaction('user_login', params.username, self.getClientInfo(args)); // set session expiration self.storage.expire( 'sessions/' + session_id, expiration_date ); // sanitize user.email = user.email.replace(/<.+>/g, ''); user.full_name = user.full_name.replace(/<.+>/g, ''); var output = Tools.mergeHashes({ code: 0, username: user.username, user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ), session_id: session_id }, args.resp || {}); // use cookies instead if (self.config.get('cookie_settings')) { args.setCookie( 'session_id', session_id, Tools.mergeHashes( self.config.get('cookie_settings'), { maxAge: exp_sec } ) ); delete output.session_id; } callback( output ); args.session = session; self.fireHook('after_login', args); } // success } ); // save session } ); // hook before } ); // load user }, api_logout: function(args, callback) { // user logout, kill session var self = this; this.loadSession(args, function(err, session, user) { if (!session) { self.logDebug(6, "Session not found, but returning success anyway"); callback({ code: 0 }); return; } args.user = user; args.session = session; self.fireHook('before_logout', args, function(err) { if (err) { return self.doError('logout', "Failed to logout: " + err, callback); } self.logDebug(6, "Logging user out: " + session.username + ": Session ID: " + session.id); // delete session object self.storage.delete('sessions/' + session.id, function(err, data) { // deliberately ignoring error here self.logDebug(6, "Successfully logged out"); self.logTransaction('user_logout', session.username, self.getClientInfo(args)); if (self.config.get('cookie_settings')) { args.setCookie( 'session_id', session.id, Tools.mergeHashes( self.config.get('cookie_settings'), { maxAge: 0, expires: new Date(0) } ) ); } callback( Tools.mergeHashes({ code: 0 }, args.resp || {}) ); self.fireHook('after_logout', args); } ); // delete } ); // hook before } ); // load session }, api_resume_session: function(args, callback) { // validate existing session var self = this; this.loadSession(args, function(err, session, user) { if (err && (err == "NO_SESSION")) { // no session ID, just return no user or session info return callback({ code: 0 }); } if (!session) { if (self.config.get('cookie_settings')) { // delete invalid cookie args.setCookie( 'session_id', session.id, Tools.mergeHashes( self.config.get('cookie_settings'), { maxAge: 0, expires: new Date(0) } ) ); } return self.doError('session', "Session has expired or is invalid.", callback); } if (!user) { return self.doError('login', "User not found: " + session.username, callback); } if (!user.active) { return self.doError('login', "User account is disabled: " + session.username, callback); } if (user.force_password_reset) { return self.doError('login', "Account is locked out. Please reset your password to unlock it.", callback); } args.user = user; args.session = session; self.fireHook('before_resume_session', args, function(err) { if (err) { return self.doError('login', "Failed to login: " + err, callback); } // update session, modified, expiration, etc. var now = Tools.timeNow(true); var exp_sec = 86400 * self.config.get('session_expire_days'); var expiration_date = Tools.normalizeTime( now + exp_sec, { hour: 0, min: 0, sec: 0 } ); session.modified = now; var new_exp_day = false; if (expiration_date != session.expires) { session.expires = expiration_date; new_exp_day = true; } self.logDebug(6, "Recovering session for: " + session.username, session); // store session object self.storage.put('sessions/' + session.id, session, function(err, data) { if (err) { return self.doError('user', "Failed to update session: " + err, callback); } else { self.logDebug(6, "Successfully logged in"); self.logTransaction('user_login', session.username, self.getClientInfo(args)); // set session expiration if (new_exp_day && self.storage.config.get('expiration_updates')) { self.storage.expire( 'sessions/' + session.id, expiration_date ); } // sanitize user.email = user.email.replace(/<.+>/g, ''); user.full_name = user.full_name.replace(/<.+>/g, ''); var output = Tools.mergeHashes({ code: 0, username: session.username, user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ), session_id: session.id }, args.resp || {}); // use cookies instead if (self.config.get('cookie_settings')) { args.setCookie( 'session_id', session.id, Tools.mergeHashes( self.config.get('cookie_settings'), { maxAge: exp_sec } ) ); delete output.session_id; } callback( output ); self.fireHook('after_resume_session', args); } // success } ); // save session } ); // hook before } ); // loaded session }, api_update: function(args, callback) { // update existing user var self = this; var updates = args.params; var changed_password = false; this.loadSession(args, function(err, session, user) { if (!session) { return self.doError('session', "Session has expired or is invalid.", callback); } if (!user) { return self.doError('user', "User not found: " + session.username, callback); } if (!user.active) { return self.doError('user', "User account is disabled: " + session.username, callback); } if (updates.username != user.username) { // sanity check return self.doError('user', "Username mismatch.", callback); } if (!self.comparePasswords(updates.old_password, user.password, user.salt)) { return self.doError('user', "Your password is incorrect.", callback); } // NOTE: Since we are now allowing the parent app to augment the user inside of loadSession, // the user's privileges may be merged with roles -- we need to load a FRESH copy of the user var path = 'users/' + self.normalizeUsername(user.username); self.storage.get(path, function(err, user) { if (err) { return self.doError('user', "User not found: " + user.username, callback); } args.user = user; args.session = session; self.fireHook('before_update', args, function(err) { if (err) { return self.doError('user', "Failed to update user: " + err, callback); } // check for password change if (updates.new_password) { updates.salt = Tools.generateUniqueID( 64, user.username ); updates.password = self.generatePasswordHash( updates.new_password, updates.salt ); changed_password = true; } // change password else delete updates.password; delete updates.new_password; delete updates.old_password; // don't allow user to update his own privs or roles delete updates.privileges; delete updates.roles; // apply updates for (var key in updates) { user[key] = updates[key]; } // sanitize user.email = user.email.replace(/<.+>/g, ''); user.full_name = user.full_name.replace(/<.+>/g, ''); // update user record user.modified = Tools.timeNow(true); self.logDebug(6, "Updating user", user); self.storage.put( path, user, function(err, data) { if (err) { return self.doError('user', "Failed to update user: " + err, callback); } self.logDebug(6, "Successfully updated user"); self.logTransaction('user_update', user.username, self.getClientInfo(args, { user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ) })); callback({ code: 0, user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ) }); if (changed_password) { // send e-mail in background (no callback) args.user = user; args.date_time = (new Date()).toLocaleString(); self.sendEmail( 'changed_password', args ); } // changed_password self.fireHook('after_update', args); } ); // updated user } ); // hook before } ); // load fresh user } ); // loaded session }, api_delete: function(args, callback) { // delete user account AND logout var self = this; var params = args.params; if (!this.config.get('self_delete')) { return this.doError('user', "User account deletion has been disabled by your administrator.", callback); } this.loadSession(args, function(err, session, user) { if (!session) { return self.doError('session', "Session has expired or is invalid.", callback); } // make sure user exists and is active if (!user) { return self.doError('user', "User not found: " + session.username, callback); } if (!user.active) { return self.doError('user', "User account is disabled: " + session.username, callback); } if (params.username != user.username) { // sanity check return self.doError('user', "Username mismatch.", callback); } if (!self.comparePasswords(params.password, user.password, user.salt)) { return self.doError('login', "Your password is incorrect.", callback); } args.user = user; args.session = session; self.fireHook('before_delete', args, function(err) { if (err) { return self.doError('login', "Failed to delete user: " + err, callback); } self.logDebug(6, "Deleting session: " + session.id); self.storage.delete('sessions/' + session.id, function(err, data) { // ignore session delete error, proceed self.logDebug(6, "Deleting user", user); self.storage.delete( "users/" + self.normalizeUsername(user.username), function(err, data) { if (err) { return self.doError('user', "Failed to delete user: " + err, callback); } else { self.logDebug(6, "Successfully deleted user"); self.logTransaction('user_delete', user.username, self.getClientInfo(args)); // remove from master user list self.storage.listFindCut( 'global/users', { username: user.username }, function(err) { if (err) self.logError( 1, "Failed to remove user from master list: " + err ); if (self.config.get('cookie_settings')) { args.setCookie( 'session_id', session.id, Tools.mergeHashes( self.config.get('cookie_settings'), { maxAge: 0, expires: new Date(0) } ) ); } callback({ code: 0 }); self.fireHook('after_delete', args); } ); } // success } ); // delete user } ); // delete session } ); // hook before } ); // loaded session }, api_forgot_password: function(args, callback) { // send forgot password e-mail to user var self = this; var params = args.params; if (!this.requireParams(params, { username: this.usernameMatch, email: /^\S+\@\S+$/ }, callback)) return; // load user first this.storage.get('users/' + this.normalizeUsername(params.username), function(err, user) { if (!user) { return self.doError('login', "User account not found.", callback); // deliberately vague } if (user.email.toLowerCase() != params.email.toLowerCase()) { return self.doError('login', "User account not found.", callback); // deliberately vague } if (!user.active) { return self.doError('login', "User account is disabled: " + session.username, callback); } // check API throttle var date_code = Math.floor( Tools.timeNow() / 3600 ); if (user.fp_date_code && (date_code == user.fp_date_code) && (user.fp_count > self.config.get('max_forgot_passwords_per_hour'))) { // lockout until next hour return self.doError('login', "This feature is locked due to too many requests. Please try again later.", callback); } args.user = user; self.fireHook('before_forgot_password', args, function(err) { if (err) { return self.doError('login', "Forgot password failed: " + err, callback); } // create special recovery hash and expiration date for it var recovery_key = Tools.generateUniqueID( 64, user.username ); // dates var now = Tools.timeNow(true); var expiration_date = Tools.normalizeTime( now + 86400, { hour:0, min:0, sec:0 } ); // create object var recovery = { key: recovery_key, username: params.username, ip: args.ip, useragent: args.request.headers['user-agent'], created: now, modified: now, expires: expiration_date }; self.logDebug(6, "Creating recovery key for: " + params.username + ": Key: " + recovery_key, recovery); // store recovery object self.storage.put('password_recovery/' + recovery_key, recovery, function(err, data) { if (err) { return self.doError('user', "Failed to create recovery key: " + err, callback); } self.logDebug(6, "Successfully created recovery key"); // set session expiration self.storage.expire( 'password_recovery/' + recovery_key, expiration_date ); // add some things to args for email body placeholder substitution args.user = user; args.self_url = self.server.config.get('base_app_url') + '/'; args.date_time = (new Date()).toLocaleString(); args.recovery_key = recovery_key; // send e-mail to user self.sendEmail( 'recover_password', args, function(err) { if (err) { return self.doError('email', err.message, callback); } self.logTransaction('user_forgot_password', params.username, self.getClientInfo(args, { key: recovery_key })); callback({ code: 0 }); // throttle this API to prevent abuse if (date_code != user.fp_date_code) { user.fp_date_code = date_code; user.fp_count = 1; } else { user.fp_count++; } // save user to update counters self.storage.put( 'users/' + self.normalizeUsername(params.username), user, function(err) { // fire async hook self.fireHook('after_forgot_password', args); } ); // save user } ); // email sent } ); // stored recovery object } ); // hook before } ); // loaded user }, api_reset_password: function(args, callback) { // reset user password using recovery key var self = this; var params = args.params; if (!this.requireParams(params, { username: this.usernameMatch, new_password: /.+/, key: /^[A-F0-9]{64}$/i }, callback)) return; // load user first this.storage.get('users/' + this.normalizeUsername(params.username), function(err, user) { if (!user) { return self.doError('login', "User account not found.", callback); } if (!user.active) { return self.doError('login', "User account is disabled: " + session.username, callback); } // load recovery key, make sure it matches this user self.storage.get('password_recovery/' + params.key, function(err, recovery) { if (!recovery) { return self.doError('login', "Password reset failed.", callback); // deliberately vague } if (recovery.username != params.username) { return self.doError('login', "Password reset failed.", callback); // deliberately vague } args.user = user; self.fireHook('before_reset_password', args, function(err) { if (err) { return self.doError('login', "Failed to reset password: " + err, callback); } // update user record user.salt = Tools.generateUniqueID( 64, user.username ); user.password = self.generatePasswordHash( params.new_password, user.salt ); user.modified = Tools.timeNow(true); // remove throttle lock delete user.force_password_reset; self.logDebug(6, "Updating user for password reset", user); self.storage.put( "users/" + self.normalizeUsername(user.username), user, function(err, data) { if (err) { return self.doError('user', "Failed to update user: " + err, callback); } self.logDebug(6, "Successfully updated user"); self.logTransaction('user_update', user.username, self.getClientInfo(args, { user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ) })); // delete recovery key (one time use only!) self.logDebug(6, "Deleting recovery key: " + params.key); self.storage.delete('password_recovery/' + params.key, function(err, data) { // ignore error, call it done self.logTransaction('user_password_reset', params.username, self.getClientInfo(args, { key: params.key })); callback({ code: 0 }); // send e-mail in background (no callback) args.user = user; args.date_time = (new Date()).toLocaleString(); self.sendEmail( 'changed_password', args ); // fire after hook self.fireHook('after_reset_password', args); } ); // deleted recovery key } ); // updated user } ); // hook before } ); // recovery key loaded } ); // user loaded }, // // Administrator Level Calls: // api_admin_create: function(args, callback) { // admin only: create new user account var self = this; var new_user = args.params; var path = 'users/' + this.normalizeUsername(new_user.username); if (!this.requireParams(new_user, { username: this.usernameMatch, email: /^\S+\@\S+$/, full_name: /\S/, password: /.+/ }, callback)) return; // sanitize new_user.email = new_user.email.replace(/<.+>/g, ''); new_user.full_name = new_user.full_name.replace(/<.+>/g, ''); this.loadSession(args, function(err, session, admin_user) { if (!session) { return self.doError('session', "Session has expired or is invalid.", callback); } if (!admin_user) { return self.doError('user', "User not found: " + session.username, callback); } if (!admin_user.active) { return self.doError('user', "User account is disabled: " + session.username, callback); } if (!admin_user.privileges.admin) { return self.doError('user', "User is not an administrator: " + session.username, callback); } // first, make sure new user doesn't already exist self.storage.get(path, function(err, old_user) { if (old_user) { return self.doError('user_exists', "User already exists: " + new_user.username, callback); } // optionally send e-mail var send_welcome_email = new_user.send_email || false; delete new_user.send_email; // now we can create the user new_user.active = 1; new_user.created = new_user.modified = Tools.timeNow(true); new_user.salt = Tools.generateUniqueID( 64, new_user.username ); new_user.password = self.generatePasswordHash( new_user.password, new_user.salt ); new_user.privileges = new_user.privileges || Tools.copyHash( self.config.get('default_privileges') || {} ); args.admin_user = admin_user; args.session = session; args.user = new_user; self.fireHook('before_create', args, function(err) { if (err) { return self.doError('user', "Failed to create user: " + err, callback); } self.logDebug(6, "Creating user", new_user); self.storage.put( path, new_user, function(err, data) { if (err) { return self.doError('user', "Failed to create user: " + err, callback); } else { self.logDebug(6, "Successfully created user: " + new_user.username); self.logTransaction('user_create', new_user.username, self.getClientInfo(args, { user: Tools.copyHashRemoveKeys( new_user, { password: 1, salt: 1 } ) })); // add to master user list in the background if (self.config.get('sort_global_users')) { self.storage.listInsertSorted( 'global/users', { username: new_user.username }, ['username', 1], function(err) { if (err) self.logError( 1, "Failed to add user to master list: " + err ); callback({ code: 0 }); // fire after hook in background self.fireHook('after_create', args); } ); } else { self.storage.listUnshift( 'global/users', { username: new_user.username }, function(err) { if (err) self.logError( 1, "Failed to add user to master list: " + err ); callback({ code: 0 }); // fire after hook in background self.fireHook('after_create', args); } ); } // send e-mail in background (no callback) if (send_welcome_email) { args.user = new_user; args.self_url = self.server.config.get('base_app_url') + '/'; self.sendEmail( 'welcome_new_user', args ); } } // success } ); // save user } ); // hook before } ); // check exists } ); // load session }, api_admin_update: function(args, callback) { // admin only: update any user var self = this; var updates = args.params; var path = 'users/' + this.normalizeUsername(updates.username); if (!this.requireParams(args.params, { username: this.usernameMatch }, callback)) return; this.loadSession(args, function(err, session, admin_user) { if (!session) { return self.doError('session', "Session has expired or is invalid.", callback); } if (!admin_user) { return self.doError('user', "User not found: " + session.username, callback); } if (!admin_user.active) { return self.doError('user', "User account is disabled: " + session.username, callback); } if (!admin_user.privileges.admin) { return self.doError('user', "User is not an administrator: " + session.username, callback); } self.storage.get(path, function(err, user) { if (err) { return self.doError('user', "User not found: " + updates.username, callback); } args.admin_user = admin_user; args.session = session; args.user = user; self.fireHook('before_update', args, function(err) { if (err) { return self.doError('user', "Failed to update user: " + err, callback); } // check for password change if (updates.new_password) { updates.salt = Tools.generateUniqueID( 64, user.username ); updates.password = self.generatePasswordHash( updates.new_password, updates.salt ); // reset lockouts if password changed by admin updates.unlock = true; } // change password else delete updates.password; delete updates.new_password; if (updates.unlock) { // optionally "reset" lockouts on account // (changing password triggers this as well) delete user.force_password_reset; delete user.fp_date_code; delete user.fp_count; delete user.fl_date_code; delete user.fl_count; delete updates.unlock; } // apply updates for (var key in updates) { user[key] = updates[key]; } // sanitize user.email = user.email.replace(/<.+>/g, ''); user.full_name = user.full_name.replace(/<.+>/g, ''); // update user record user.modified = Tools.timeNow(true); self.logDebug(6, "Admin updating user", user); self.storage.put( path, user, function(err, data) { if (err) { return self.doError('user', "Failed to update user: " + err, callback); } self.logDebug(6, "Successfully updated user"); self.logTransaction('user_update', user.username, self.getClientInfo(args, { user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ) })); callback({ code: 0, user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ) }); self.fireHook('after_update', args); } ); // updated user } ); // hook before } ); // loaded user } ); // loaded session }, api_admin_delete: function(args, callback) { // admin only: delete any user account var self = this; var params = args.params; var path = 'users/' + this.normalizeUsername(params.username); if (!this.requireParams(params, { username: this.usernameMatch }, callback)) return; this.loadSession(args, function(err, session, admin_user) { if (!session) { return self.doError('session', "Session has expired or is invalid.", callback); } if (!admin_user) { return self.doError('user', "User not found: " + session.username, callback); } if (!admin_user.active) { return self.doError('user', "User account is disabled: " + session.username, callback); } if (!admin_user.privileges.admin) { return self.doError('user', "User is not an administrator: " + session.username, callback); } self.storage.get(path, function(err, user) { if (err) { return self.doError('user', "User not found: " + params.username, callback); } args.admin_user = admin_user; args.session = session; args.user = user; self.fireHook('before_delete', args, function(err) { if (err) { return self.doError('login', "Failed to delete user: " + err, callback); } self.logDebug(6, "Deleting user", user); self.storage.delete( "users/" + self.normalizeUsername(user.username), function(err, data) { if (err) { return self.doError('user', "Failed to delete user: " + err, callback); } else { self.logDebug(6, "Successfully deleted user"); self.logTransaction('user_delete', user.username, self.getClientInfo(args)); // remove from master user list self.storage.listFindCut( 'global/users', { username: user.username }, function(err) { if (err) self.logError( 1, "Failed to remove user from master list: " + err ); callback({ code: 0 }); self.fireHook('after_delete', args); } ); } // success } ); // delete user } ); // hook before } ); // loaded user } ); // loaded session }, api_admin_get_user: function(args, callback) { // admin only: get single user record, for editing var self = this; var params = Tools.mergeHashes( args.params, args.query ); if (!this.requireParams(params, { username: this.usernameMatch }, callback)) return; this.loadSession(args, function(err, session, admin_user) { if (!session) { return self.doError('session', "Session has expired or is invalid.", callback); } if (!admin_user) { return self.doError('user', "User not found: " + session.username, callback); } if (!admin_user.active) { return self.doError('user', "User account is disabled: " + session.username, callback); } if (!admin_user.privileges.admin) { return self.doError('user', "User is not an administrator: " + session.username, callback); } // load user var path = 'users/' + self.normalizeUsername(params.username); self.storage.get( path, function(err, user) { if (err) { return self.doError('user', "Failed to load user: " + err, callback); } // sanitize user.email = user.email.replace(/<.+>/g, ''); user.full_name = user.full_name.replace(/<.+>/g, ''); // success, return user record callback({ code: 0, user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ) }); } ); // loaded user } ); // loaded session }, api_admin_get_users: function(args, callback) { // admin only: get chunk of users from global list, with pagination var self = this; var params = Tools.mergeHashes( args.params, args.query ); this.loadSession(args, function(err, session, admin_user) { if (!session) { return self.doError('session', "Session has expired or is invalid.", callback); } if (!admin_user) { return self.doError('user', "User not found: " + session.username, callback); } if (!admin_user.active) { return self.doError('user', "User account is disabled: " + session.username, callback); } if (!admin_user.privileges.admin) { return self.doError('user', "User is not an administrator: " + session.username, callback); } if (!params.offset) params.offset = 0; if (!params.limit) params.limit = 50; self.storage.listGet( 'global/users', params.offset, params.limit, function(err, stubs, list) { if (err) { // no users found, not an error for this API return callback({ code: 0, rows: [], list: { length: 0 } }); } // create array of paths to user records var paths = []; for (var idx = 0, len = stubs.length; idx < len; idx++) { paths.push( 'users/' + self.normalizeUsername(stubs[idx].username) ); } // load all users self.storage.getMulti( paths, function(err, users) { if (err) { return self.doError('user', "Failed to load users: " + err, callback); } // remove passwords and salts for (var idx = 0, len = users.length; idx < len; idx++) { users[idx] = Tools.copyHashRemoveKeys( users[idx], { password: 1, salt: 1 } ); // sanitize users[idx].email = users[idx].email.replace(/<.+>/g, ''); users[idx].full_name = users[idx].full_name.replace(/<.+>/g, ''); } // success, return users and list header callback({ code: 0, rows: users, list: list }); } ); // loaded users } ); // got username list } ); // loaded session }, api_external_login: function(args, callback) { // query external user management system for login var self = this; var url = this.config.get('external_user_api'); if (!url) return this.doError('user', "No external_user_api config param set.", callback); this.logDebug(6, "Externally logging in via: " + url, args.request.headers); // must pass along cookie and user-agent var request = new Request( args.request.headers['user-agent'] || 'PixlUser API' ); request.get( url, { headers: { 'Cookie': args.request.headers['cookie'] || args.params.cookie || args.query.cookie || '' } }, function(err, resp, data) { // check for error if (err) return self.doError('user', err, callback); if (resp.statusCode != 200) { return self.doError('user', "Bad HTTP Response: " + resp.statusMessage, callback); } var json = null; try { json = JSON.parse( data.toString() ); } catch (err) { return self.doError('user', "Failed to parse JSON response: " + err, callback); } var code = json.code || json.Code; if (code) { return self.doError('user', "External API Error: " + (json.description || json.Description), callback); } self.logDebug(6, "Got response from external user system:", json); var username = json.username || json.Username || ''; var remote_user = json.user || json.User || null; if (username && remote_user) { // user found in response! update our records and create a local session var path = 'users/' + self.normalizeUsername(username); if (!username.match(self.usernameMatch)) { return self.doError('user', "Username contains illegal characters: " + username, callback); } self.logDebug(7, "Testing if user exists: " + path); self.storage.get(path, function(err, user) { var new_user = false; if (!user) { // first time, create new user self.logDebug(6, "Creating new user: " + username); new_user = true; user = { username: username, active: 1, created: Tools.timeNow(true), modified: Tools.timeNow(true), salt: Tools.generateUniqueID( 64, username ), password: Tools.generateUniqueID(64), // unused privileges: Tools.copyHash( self.config.get('default_privileges') || {} ) }; } // new user else { self.logDebug(7, "User already exists: " + username); if (user.force_password_reset) { return self.doError('login', "Account is locked out. Please reset your password to unlock it.", callback); } if (!user.active) { return self.doError('login', "User account is disabled: " + username, callback); } } // copy to args for logging args.user = user; var finish = function() { // sync user info user.full_name = remote_user.full_name || remote_user.FullName || username; user.email = remote_user.email || remote_user.Email || (username + '@' + self.server.hostname); // sanitize user.email = user.email.replace(/<.+>/g, ''); user.full_name = user.full_name.replace(/<.+>/g, ''); // must reset all privileges here, as remote system may delete keys when privs are revoked for (var key in user.privileges) { user.privileges[key] = 0; } // copy over privileges var privs = remote_user.privileges || remote_user.Privileges || {}; for (var key in privs) { var ckey = key.replace(/\W+/g, '_').toLowerCase(); user.privileges[ckey] = privs[key] ? 1 : 0; } // copy over avatar url user.avatar = json.avatar || json.Avatar || ''; // save user locally self.storage.put( path, user, function(err) { if (err) return self.doError('user', "Failed to create user: " + err, callback); if (new_user) { self.logDebug(6, "Successfully created user: " + username); self.logTransaction('user_create', username, self.getClientInfo(args, { user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ) })); } // now perform a local login self.fireHook('before_login', args, function(err) { if (err) { return self.doError('login', "Failed to login: " + err, callback); } // now create session var now = Tools.timeNow(true); var expiration_date = Tools.normalizeTime( now + (86400 * self.config.get('session_expire_days')), { hour: 0, min: 0, sec: 0 } ); // create session id and object var session_id = Tools.generateUniqueID( 64, username ); var session = { id: session_id, username: username, ip: args.ip, useragent: args.request.headers['user-agent'], created: now, modified: now, expires: expiration_date }; self.logDebug(6, "Logging user in: " + username + ": New Session ID: " + session_id, session); // store session object self.storage.put('sessions/' + session_id, session, function(err, data) { if (err) { return self.doError('user', "Failed to create session: " + err, callback); } // copy to args to logging args.session = session; self.logDebug(6, "Successfully logged in", username); self.logTransaction('user_login', username, self.getClientInfo(args)); // set session expiration self.storage.expire( 'sessions/' + session_id, expiration_date ); callback( Tools.mergeHashes({ code: 0, username: username, user: Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } ), session_id: session_id }, args.resp || {}) ); self.fireHook('after_login', args); // add to master user list in the background if (new_user) { if (self.config.get('sort_global_users')) { self.storage.listInsertSorted( 'global/users', { username: username }, ['username', 1], function(err) { if (err) self.logError( 1, "Failed to add user to master list: " + err ); self.fireHook('after_create', args); } ); } else { self.storage.listUnshift( 'global/users', { username: username }, function(err) { if (err) self.logError( 1, "Failed to add user to master list: " + err ); self.fireHook('after_create', args); } ); } } // new user else { self.fireHook('after_update', args); } } ); // save session } ); // before_login } ); // save user }; // finish // fire correct hook for action if (new_user) { self.fireHook('before_create', args, function(err) { if (err) { return self.doError('user', "Failed to create user: " + err, callback); } finish(); }); } else { self.fireHook('before_update', args, function(err) { if (err) { return self.doError('user', "Failed to update user: " + err, callback); } finish(); }); } } ); // user get } // user is logged in else { // API must require a browser redirect, so pass back to client // add our encoded self URL onto end of redirect URL var url = json.location || json.Location; url += encodeURIComponent( self.web.getSelfURL(args.request, '/') ); self.logDebug(6, "Browser redirect required: " + url); callback({ code: 0, location: url }); } } ); }, sendEmail: function(name, args, callback) { // send e-mail using template system and arg placeholders, if enabled var self = this; var emails = this.config.get('email_templates') || {}; if (emails[name]) { // email is enabled args.config = this.server.config.get(); this.mail.send( emails[name], args, function(err, data) { if (err) self.logError('email', "Failed to send e-mail: " + err, { name: name, data: data }); else self.logDebug(6, "Email sent successfully", { name: name, data: data }); if (callback) callback(err); } ); } }, registerHook: function(name, callback) { // register a function as a hook handler name = name.toLowerCase(); this.hooks[name] = callback; }, fireHook: function(name, data, callback) { // fire custom hook, allowing webapp to intercept and alter data or throw an error name = name.toLowerCase(); if (!callback) callback = function() {}; if (this.hooks[name]) { this.hooks[name](data, callback); } else callback(null); }, getClientInfo: function(args, params) { // return client info object suitable for logging in the data column if (!params) params = {}; params.ip = args.ip; params.headers = args.request.headers; return params; }, loadSession: function(args, callback) { // make sure session is valid var self = this; var session_id = args.cookies['session_id'] || args.request.headers['x-session-id'] || args.params.session_id || args.query.session_id; if (!session_id) return callback( "NO_SESSION" ); this.storage.get('sessions/' + session_id, function(err, session) { if (err) { self.logError('user', "Failed to load session: " + err); return callback(err, null); } // also load user self.storage.get('users/' + self.normalizeUsername(session.username), function(err, user) { if (err) return callback(err, null); // get session_id out of common places, so it doesn't interfere with API calls and isn't logged delete args.params.session_id; delete args.cookies['session_id']; delete args.request.headers['x-session-id']; delete args.request.headers['cookie']; // sanitize user.email = user.email.replace(/<.+>/g, ''); user.full_name = user.full_name.replace(/<.+>/g, ''); // allow parent app to manipulate session and/or user self.fireHook('after_load_session', { session, user }); // pass both session and user to callback callback(null, session, user); } ); // load user } ); // load session }, requireParams: function(params, rules, callback) { // require params to exist and have length assert( arguments.length == 3, "Wrong number of arguments to requireParams" ); for (var key in rules) { var regexp = rules[key]; if (typeof(params[key]) == 'undefined') { this.doError('api', "Missing parameter: " + key, callback); return false; } if (params[key] === null) { this.doError('api', "Null parameter: " + key, callback); return false; } if (!params[key].toString().match(regexp)) { this.doError('api', "Malformed parameter: " + key, callback); return false; } } return true; }, doError: function(code, msg, callback) { // log error and send api response assert( arguments.length == 3, "Wrong number of arguments to doError" ); this.logError( code, msg ); callback({ code: code, description: msg }); return false; }, generatePasswordHash: function(password, salt) { // generate crypto hash of password given plain password and salt string if (this.config.get('use_bcrypt')) { // use extremely secure but CPU expensive bcrypt algorithm return bcrypt.hashSync( password + salt ); } else { // use weaker but fast salted SHA-256 algorithm return Tools.digestHex( password + salt, 'sha256' ); } }, comparePasswords: function(password, hash, salt) { // compare passwords for login, given plain