// 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('bcryptjs');

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,
		"use_csrf": 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.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) {
			// always compare passwords first, to prevent timing attacks
			args.perf.begin('compare');
			var result = self.comparePasswords(params.password, user ? user.password : Tools.generateUniqueID(), user ? user.salt : Tools.generateUniqueID());
			args.perf.end('compare');
			
			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 (!result) {
				// 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);
				var session = {
					id: session_id,
					username: params.username,
					ip: args.ip,
					useragent: args.request.headers['user-agent'],
					created: now,
					modified: now,
					expires: expiration_date
				};
				
				if (self.config.get('use_csrf')) {
					session.csrf_token = Tools.generateUniqueID(64);
				}
				
				self.logDebug(6, "Logging user in: " + params.username, 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;
						}
						
						if (self.config.get('use_csrf')) {
							output.csrf_token = session.csrf_token;
						}
						
						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);
				
				// 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', '', 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;
		
		// skip csrf for this api (user doesn't have the csrf token yet)
		args.skip_csrf = true;
		
		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', '', 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;
				
				// only bump expiration forward if expiration_updates config prop is set
				var new_exp_day = false;
				if ((expiration_date != session.expires) && self.storage.config.get('expiration_updates')) {
					session.expires = expiration_date;
					new_exp_day = true;
				}
				
				// session may need to be upgraded to csrf
				if (self.config.get('use_csrf') && !session.csrf_token) {
					session.csrf_token = Tools.generateUniqueID(64);
				}
				
				self.logDebug(6, "Recovering session for: " + session.username);
				
				// 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.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;
						}
						
						if (self.config.get('use_csrf')) {
							output.csrf_token = session.csrf_token;
						}
						
						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);
						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);
					
					var user_stub = Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } );
					self.logDebug(6, "Updating user", user_stub);
					
					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: user_stub }));
						
						callback({ code: 0, user: user_stub });
						
						if (changed_password) {
							// send e-mail in background (no callback)
							args.user = user;
							args.date_time = (new Date()).toString();
							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.username);
					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', '', 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;
		
		// delay responses randomly to prevent timing attacks
		var delayedResponse = function(data) {
			setTimeout( function() { callback(data); }, Math.floor( Math.random() * 250 ) );
		};
		
		// load user first
		this.storage.get('users/' + this.normalizeUsername(params.username), function(err, user) {
			// send faux-success for errors to prevent "user exists" knowledge capture
			if (!user) {
				self.logError('forgot', "User account not found: " + params.username);
				return delayedResponse({ code: 0 });
			}
			if (user.email.toLowerCase() != params.email.toLowerCase()) {
				self.logError('forgot', "Emails do not match.", [ user.email.toLowerCase(), params.email.toLowerCase() ]);
				return delayedResponse({ code: 0 });
			}
			if (!user.active) {
				self.logError('forgot', "User account is disabled: " + params.username);
				return delayedResponse({ code: 0 });
			}
			
			// 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
				self.logError('forgot', "This feature is locked due to too many requests", params);
				return delayedResponse({ code: 0 });
			}
			
			args.user = user;
			
			self.fireHook('before_forgot_password', args, function(err) {
				if (err) {
					self.logError('reset', "Failed to reset password: " + err);
					return delayedResponse({ code: 'login', description: "Forgot password failed: " + err });
				}
				
				// create special recovery hash and expiration date for it
				var recovery_key = Tools.generateUniqueID(64);
				
				// 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(9, "Creating recovery key for: " + params.username);
				
				// store recovery object
				self.storage.put('password_recovery/' + recovery_key, recovery, function(err, data) {
					if (err) {
						return delayedResponse({ code: 'user', description: "Failed to create recovery key: " + err });
					}
					
					self.logDebug(6, "Successfully created recovery key");
					delayedResponse({ code: 0 });
					
					// 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()).toString();
					args.recovery_key = recovery_key;
					
					// send e-mail to user
					self.sendEmail( 'recover_password', args, function(err) {
						if (err) {
							self.logError('email', err.message);
							return;
						}
						
						self.logTransaction('user_forgot_password', params.username, self.getClientInfo(args, { key: recovery_key }));
					 	
					 	// 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;
		
		// delay responses randomly to prevent timing attacks
		var delayedResponse = function(data) {
			setTimeout( function() { callback(data); }, Math.floor( Math.random() * 250 ) );
		};
		var delayedError = function(code, description) {
			self.logError(code, description);
			delayedResponse({ code, description });
		};
		
		// load user first
		this.storage.get('users/' + this.normalizeUsername(params.username), function(err, user) {
			if (!user) {
				self.logError('reset', "User account not found: " + params.username);
				return delayedError('login', "Password reset failed."); // // deliberately vague
			}
			if (!user.active) {
				self.logError('reset', "User account is disabled: " + params.username);
				return delayedError('login', "Password reset failed."); // // deliberately vague
			}
			
			// load recovery key, make sure it matches this user
			self.storage.get('password_recovery/' + params.key, function(err, recovery) {
				if (!recovery) {
					self.logError('reset', "Failed to locate password recovery: " + params.key);
					return delayedError('login', "Password reset failed."); // // deliberately vague
				}
				if (recovery.username != params.username) {
					self.logError('reset', "Usernames to not match.", [ recovery.username, params.username ]);
					return delayedError('login', "Password reset failed."); // // deliberately vague
				}
				
				args.user = user;
				
				self.fireHook('before_reset_password', args, function(err) {
					if (err) {
						self.logError('reset', "Failed to reset password: " + err);
						return delayedError('login', "Password reset failed."); // // deliberately vague
					}
					
					// update user record
					user.salt = Tools.generateUniqueID(64);
					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.username);
					
					self.storage.put( "users/" + self.normalizeUsername(user.username), user, function(err, data) {
						if (err) {
							self.logError('user', "Failed to update user: " + err);
							return delayedError('login', "Password reset failed."); // // deliberately vague
						}
						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 for user: " + user.username);
						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 }));
							delayedResponse({ code: 0 });
							
							// send e-mail in background (no callback)
							args.user = user;
							args.date_time = (new Date()).toString();
							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.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.username);
					
					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);
						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);
					
					var user_stub = Tools.copyHashRemoveKeys( user, { password: 1, salt: 1 } );
					self.logDebug(6, "Admin updating user: " + user.username, user_stub);
					
					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: user_stub }));
						
						callback({ code: 0, user: user_stub });
						
						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.username);
					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),
							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);
								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 = {
				email_from: this.server.config.get('email_from'),
				client: this.server.config.get('client')
			};
			
			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;
		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);
			}
			
			// csrf check
			var csrf_token = args.request.headers['x-csrf-token'] || args.params.csrf_token || null;
			if (self.config.get('use_csrf') && !args.request.method.match(/^(GET|HEAD)$/) && session.csrf_token && (csrf_token != session.csrf_token) && !args.skip_csrf) {
				self.logError('user', "Failed to validate session: Invalid CSRF Token", { username: session.username, ips: args.ips });
				return callback(new Error("Invalid session"), 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'];
				
				// cleanup csrf token mess as well
				delete args.params.csrf_token;
				delete args.request.headers['x-csrf-token'];
				
				// 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 plaintext, pw hash and user salt
		if (this.config.get('use_bcrypt')) {
			// use extremely secure but CPU expensive bcrypt algorithm
			return bcrypt.compareSync(password + salt, hash);
		}
		else {
			// use weaker but fast salted SHA-256 algorithm
			return (hash == this.generatePasswordHash(password, salt));
		}
	},
	
	shutdown: function(callback) {
		// shutdown user service
		callback();
	}
	
});
