UNPKG

scalra

Version:

node.js framework to prototype and scale rapidly

909 lines (774 loc) 24.2 kB
/* account management (module-based) API: _ACCOUNT_REGISTER // create a new user account _ACCOUNT_LOGIN // login by account _ACCOUNT_LOGOUT // logout by account _ACCOUNT_RESETPASS // reset password by email _ACCOUNT_SETPASS // set new password by token _ACCOUNT_SETDATA // set user data by account name & type:value mapping _ACCOUNT_GETDATA // get user data by account name _ACCOUNT_GETUID // get user uid by account name _ACCOUNT_DELETE // delete account by account name history: 2016-09-27 start 2018-04-30 add _ACCOUNT_DELETE */ // module name // NOTE: use it also as DB & in-memory datastore name var l_name = '_account'; // cache reference of accounts var l_accounts = undefined; // list of logined accounts (account -> user's full data) var l_logins = SR.State.get('user.logins', 'map'); // default encryption type (0 is 'sha512', see SR.Settings.ENCRYPT_TYPES) var l_enc_type = 0; // get a reference to system states var l_states = SR.State.get(SR.Settings.DB_NAME_SYSTEM); // // helper functions // // check if an account is valid var l_validateAccount = function (account) { // check if DB is initialized if (typeof l_accounts === 'undefined') { LOG.error('DB module is not loaded, please enable DB module', l_name); return false; } if (l_accounts.hasOwnProperty(account) === false) { LOG.error('[' + account + '] not found', l_name); return false; } return true; } var l_validateUID = function () { // check if data exists or will init it if (l_states.hasOwnProperty('uid_count') === false) { LOG.warn('no users found, user id (uid) counter init to 0', 'user.js'); l_states['uid_count'] = 0; } else LOG.warn('accounts created so far: ' + l_states['uid_count'], 'user.js'); }; // generate a next unique ID for user // TODO: use SR.DS for l_states instead var l_getUID = function (onDone) { l_validateUID(); var uid = ++l_states['uid_count']; l_states.sync(function (err) { if (err) { return onDone(err); } LOG.warn('uid generated: ' + uid); onDone(null, uid); }); } var l_encryptPass = exports.encryptPass = function (original, salt) { salt = salt || 'scalra'; return UTIL.hash(original + salt, SR.Settings.ENCRYPT_TYPES[l_enc_type]); } // email validator // ref: https://stackoverflow.com/questions/46155/validate-email-address-in-javascript var l_validateEmail = function (email) { var re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return re.test(email); } // // store & remove user data to cache // var l_addLogin = function (account, conn, onDone) { // if (l_accounts.hasOwnProperty(account) === false) { // return onDone('INVALID_ACCOUNT', account); // } // var data = l_accounts[account]; // LOG.warn('[' + account + '] login success, total logins: ' + Object.keys(l_logins).length, l_name); // // record login session // data.login.IP = conn.host; // data.login.time_in = new Date(); // data.login.time_out = null; // data.login.count++; // // store to DB // data.sync(function (err) { // if (err) { // LOG.error(err, l_name); // return onDone(err); // } // // attach login name to connection // // NOTE: we attach connection object to the data stored in memory (not clean?) // // NOTE: we use session because login could come from an HTTP request // // that does not have a persistent connection record in SR.Conn // SR.Conn.setSessionName(conn, account); // // store connID for logout purpose // l_logins[account] = conn.connID; // return onDone(null); // }); // } // returns the token or undefined if token store fail // var l_createToken = function (account, from, onDone) { // // generate a valid token to be returned // var token = UTIL.createToken(); // var data = {tokens: {pass: {}}}; // data.tokens.pass[token] = from; // SR.API._ACCOUNT_SETDATA({account: account, data: data}, // function (err) { // if (err) { // return onDone(err); // } // onDone(null, {account: account, token: token}); // }); // } // var l_revokeToken = function (account, token, onDone) { // var query = {uid: uid}; // var field = 'pass_tokens.' + token; // SR.DB.removeField(SR.Settings.DB_NAME_ACCOUNT, query, field, // function () { // LOG.warn('pass_token [' + token + '] removed'); // UTIL.safeCall(onDone, null, token); // }, // function () { // var err = new Error("accessing DB fail"); // err.name = "revokeToken Error"; // UTIL.safeCall(onDone, err); // }); // } // // initialize session content based on registered or logined user data // var l_initSession = function (login_id, session, data) { // // acknowledge as 'logined' // l_loginID[login_id] = data.account; // // init session // session['_account'] = data.account; // // TODO: needs to fix this, should read "groups" from DB // session['_groups'] = data.groups; // session['_permissions'] = data.permissions; // session['lastStatus'] = data.lastStatus; // // TODO: centralize handling of logined users? // //SR.User.addGroup(user_data.account, ['user', 'admin']); // } // // main API // // create a new user account SR.API.add('_ACCOUNT_REGISTER', { account: 'string', password: 'string', email: 'string', data: '+object', authWP: '+boolean', groups: '+array' }, function (args, onDone, extra) { // check if DB is initialized if (typeof l_accounts === 'undefined') { return onDone('DB_NOT_LOADED'); } // print basic info to confirm LOG.warn('register new account: ' + args.account + ' pass: ' + args.password + ' e-mail: ' + args.email, l_name); // check existing users if (l_accounts.hasOwnProperty(args.account)) { return onDone('ACCOUNT_EXISTS', args.account); } // to login via wordpres if (!!args.authWP) { SR.API._wpGenerateAuthCookie({ username: args.account, password: args.password }, (err, data) => { if (err) { onDone(err); return; } // set email and username as account args.email = data.user.email; args.account = data.user.username; l_getUID(getUIDCallback); }); } else { // check email correctness if (l_validateEmail(args.email) === false) { return onDone('INVALID_EMAIL', args.email); } l_getUID(getUIDCallback); } // generate unique user_id function getUIDCallback (err, uid) { if (err) { return onDone('UID_ERROR'); } var ip = (extra) ? extra.conn.host : "server"; // NOTE: by default a user is a normal user, user 'groups' can later be customized var reg = { uid: uid, account: args.account, password: l_encryptPass(args.password), email: args.email, // verify: {email_verify: false, phone_verify: false}, tokens: {reset: '', pass: {}}, enc_type: l_enc_type, control: {groups: args.groups || [], permissions: []}, data: args.data || {}, login: {IP: ip, count: 1} }; // special handling (by default 'admin' account is special and will be part of the 'admin' group by default if (!args.authWP && reg.account === 'admin') { reg.control.groups.push('admin'); } LOG.warn('creating new account [' + args.account + ']...', l_name); l_accounts.add(reg, function (err) { if (err) { return onDone('DB_ERROR', err); } // register success LOG.warn('account register success', l_name); onDone(null); }); } }); SR.API.add('_ACCOUNT_UPDATE', { account: 'string', fields: 'object', }, (args, onDone, extra) => { l_accounts.update(args.account, args.fields, onDone); }); // login by account // NOTE: account & password can either be a string/string pair or number/string pair, // in the latter case it's actuall checked against uid + pass_tokens // (unused) 'requester' is an optional parameter, indicating which server is asking for this login SR.API.add('_ACCOUNT_LOGIN', { account: 'string', password: 'string', from: '+string', // which server relays this login request authWP: '+boolean', // login via wordpress data: '+object', authMySQL: '+boolean' }, function (args, onDone, extra) { // check if DB is initialized if (typeof l_accounts === 'undefined') { return onDone('DB_NOT_LOADED'); } let account = args.account.toLowerCase(); let password = args.password; LOG.warn('login: [' + account + '] pass: ' + args.password + (args.from ? ' from: ' + args.from : ''), l_name); let userExist = true; // check if account exists if (l_accounts.hasOwnProperty(account) === false) { if (!args.authWP && !args.authMySQL) { LOG.warn(args.authWP); LOG.warn(args.authMySQL); return onDone('INVALID_ACCOUNT', args.authMySQL); } else { userExist = false; } } // check if already logined // NOTE: we allow multiple logins to exist for now if (l_logins.hasOwnProperty(account)) { LOG.warn('account [' + account + '] already logined', l_name); } var user = l_accounts[account]; let username; let duplicatedAccount = false; new Promise((resolve, reject) => { if (args.authWP) { SR.API._wpGenerateAuthCookie({ username: account, password: args.password }, (err, data) => { if (err) { reject(err); return; } username = data.user.username.toLowerCase(); user = l_accounts[username]; resolve(data); }); } else if (args.authMySQL) { SR.API._mysql_user_login({ username: account, password: args.password }, (err, data) => { if (err) { return reject(err); } resolve(data); }); } else { // perform password or token verification if (l_encryptPass(args.password) !== user.password && user.tokens.pass.hasOwnProperty(args.password) === false) { reject('INVALID_PASSWORD_OR_TOKEN'); } else { resolve(); } } // FIXME: should be userInfo }).then((wpInfo) => { if (!wpInfo) { Promise.resolve(); return; } if (args.authWP) { const wpGroups = Object.keys(wpInfo.user.capabilities).filter((key) => wpInfo.user.capabilities[key] === true); var loginViaEmail = false; if (account === wpInfo.user.email.toLowerCase()) { loginViaEmail = true; args.account = wpInfo.user.username.toLowerCase(); l_accounts[account].data.wpUsername = wpInfo.user.username; } /* XXX: the code below are to resolve problem cause by the code last version * the new method wouldn't create two accounts * and should be delete while migration has been done. */ // already got an account if (l_accounts.hasOwnProperty(account) || l_accounts.hasOwnProperty(wpInfo.user.username.toLowerCase())) { userExist = true; } // FIXME: how can this be duplicated if (l_accounts.hasOwnProperty(account) && l_accounts.hasOwnProperty(wpInfo.user.username.toLowerCase())) { duplicatedAccount = true; } const found = Object.values(l_accounts).filter(x => x.email === wpInfo.user.email.toLowerCase()); if (found && found[0]) { userExist = true; if (found.length > 1) { duplicatedAccount = true; } // login with original account username = found[0].account.toLowerCase(); } /* XXX end */ if (!!wpInfo && userExist) { // update user data in local server return new Promise((resolve, reject) => { let fields = { password: l_encryptPass(password), email: wpInfo.user.email, control: { groups: wpGroups, permissions: [] } }; /* XXX */ // user has only one account, change account to username if (loginViaEmail && !duplicatedAccount) { fields.account = username; } /* XXX end */ SR.API._ACCOUNT_UPDATE({ account: username, fields: fields }, (err, record) => { if (err) { reject(err); return; } // account = args.account.toLowerCase(); resolve(SR.State.get('_accountMap')[username]); }); }); } else { // create user in server first return new Promise((resolve, reject) => { SR.API._ACCOUNT_REGISTER({ account: username, password: args.password, email: wpInfo.user.email.toLowerCase(), data: Object.assign(args.data, { wpID: wpInfo.user.id }), groups: wpGroups }, (err, data) => { if (err) { reject(err); return; } resolve(SR.State.get('_accountMap')[username]); }); }); } } else if (args.authMySQL) { if (!!wpInfo && userExist) { // update user data in local server return new Promise((resolve, reject) => { SR.API._ACCOUNT_UPDATE({ account: account, fields: { password: l_encryptPass(args.password), data: { password: args.password, Database: wpInfo.Database } } }, (err, record) => { if (err) { reject(err); return; } resolve(SR.State.get('_accountMap')[account]); }); }); } // create user in server first return new Promise((resolve, reject) => { SR.API._ACCOUNT_REGISTER({ account: account, password: args.password, email: `${account}@mysql.localhost`, groups: [], data: { password: args.password, Database: wpInfo.Database } }, (err, data) => { if (err) { reject(err); return; } resolve(SR.State.get('_accountMap')[account]); }); }); } else { // should never happened return new Promise((resolve, reject) => { SR.API._ACCOUNT_REGISTER({ account: account, password: args.password, email: wpInfo.user.email, data: Object.assign(args.data, { wpID: wpInfo.user.id }), groups: wpGroups }, (err, data) => { if (err) { reject(err); return; } resolve(SR.State.get('_accountMap')[account]); }); }); } }).then((u) => { user = u || user; var ip = (extra) ? extra.conn.host : 'server'; // update login time user.login = { IP: ip, time_in: new Date(), time_out: null, count: user.login.count+1 }; // generate unique token if the request is relayed from a server var token = undefined; if (args.from) { token = UTIL.createToken(); user.tokens.pass[token] = args.from; } // save data user.sync(function (err) { if (err) { LOG.error(err, l_name); return onDone('DB_ERROR', err); } // attach login (account) to connection // NOTE: we use session because login could come from an HTTP request // that does not have a persistent connection record in SR.Conn SR.Conn.setSessionName(extra.conn, account); // init session by recording login-related info // NOTE: 'control' info may change during the session extra.session._user = { account: account, control: user.control, login: user.login }; // record current login (also the conn object for logout purpose) l_logins[account] = extra.conn; // return login success LOG.warn('[' + account + '] login success, total online accounts: ' + Object.keys(l_logins).length, l_name); onDone(null, {account: account, token: token}); }); }).catch((err) => { onDone(err); }); }); // logout by account SR.API.add('_ACCOUNT_LOGOUT', { _login: true, account: '+string' }, function (args, onDone, extra) { var account = (extra && extra.session && extra.session._user ? extra.session._user.account : args.account); if (l_validateAccount(account) === false) { return onDone('INVALID_ACCOUNT', account); } // record logout time var user = l_accounts[account]; user.login.time_out = new Date(); user.sync(function (err) { if (err) { LOG.error(err, l_name); return onDone('DB_ERROR', err); } // remove login name from connection (if any) var conn = l_logins[account]; SR.Conn.unsetSessionName(conn); delete l_logins[account]; // clear session // NOTE: extra might become invalid after sync is done if (extra) { delete extra.session['_user']; } LOG.warn('[' + account + '] logout success, total logins: ' + Object.keys(l_logins).length, l_name); onDone(null); }); }); // auto-logout when disconnect SR.Callback.onDisconnect(function (conn) { // NOTE: if we auto-logout when socket disconnects, when using websockets and page refreshes, // user will auto-logout as well (undesirable). //var account = SR.Conn.getSessionName(conn); //if (!account) { // return; //} //SR.API._ACCOUNT_LOGOUT({account: account}, function (err) { // if (err) { // LOG.error(err); // } // LOG.warn('[' + account + '] auto-logout', l_name); //}); }); // reset password by email SR.API.add('_ACCOUNT_RESETPASS', { email: 'string', account: '+string' // optional e-mail to check }, function (args, onDone) { // send reset mail onDone(null); }); // set new password by token SR.API.add('_ACCOUNT_SETPASS', { _login: true, original_password: 'string', password: 'string', token: '+string', account: '+string' }, function (args, onDone, extra) { var account = (extra && extra.session && extra.session._user ? extra.session._user.account : args.account); SR.API._ACCOUNT_GETDATA({account: account, type: 'password' }, function (err, result) { // _ACCOUNT_SETDATA LOG.warn('密碼修改'); if (result.password !== l_encryptPass(args.original_password)) return onDone(null, {success:0, desc:'密碼不正確'}); var data = l_accounts[account]; data.password = l_encryptPass(args.password); data.sync(function (err) { if (err) { LOG.error(err, l_name); return onDone('DB_ERROR', err); } return onDone(null, {success:1, desc:'修改密碼成功'}); }); }); // l_encryptPass }); SR.API.add('_ADMIN_ACCOUNT_SETPASS', { password: 'string', account: 'string' }, function (args, onDone, extra) { if (extra) return onDone(null); var account = args.account; if (!l_accounts[account]) return onDone('no this account'); var data = l_accounts[account]; data.password = l_encryptPass(args.password); data.sync(function (err) { if (err) { LOG.error(err, l_name); return onDone('DB_ERROR', err); } return onDone(null, {success:1, desc:'修改密碼成功'}); }); }); // which fields are not allowed to be modified directly var l_protected_fields = {'uid': true, 'account': true, 'password': true}; // set user data by account name & type:value mapping SR.API.add('_ACCOUNT_SETDATA', { _login: true, account: '+string', data: 'object' }, function (args, onDone, extra) { var account = (extra && extra.session && extra.session._user ? extra.session._user.account : args.account); if (l_validateAccount(account) === false) { return onDone('INVALID_ACCOUNT', account); } // iterate each item and set value while recording errors var errmsg = ''; var data = l_accounts[account]; for (var key in args.data) { if (data.hasOwnProperty(key) === false) { errmsg += 'field [' + key + '] not found\n'; continue; } if (l_protected_fields[key]) { errmsg += 'field [' + key + '] is protected\n'; continue; } // simple replacement for string / numbers var type = typeof args.data[key]; if (type === 'string' || type === 'number') { data[key] = args.data[key]; continue; } // update/merge value for objects data[key] = UTIL.merge.recursive(true, data[key], args.data[key]); } // store back data.sync(function (err) { if (err) { LOG.error(err, l_name); return onDone('DB_ERROR', err); } onDone(errmsg === '' ? null : errmsg); }); }); // get user data by account name SR.API.add('_ACCOUNT_GETDATA', { _login: true, account: '+string', type: '+string', // type: ['login', 'data', 'control', 'email', 'uid'] types: '+array' // same as type but in array form }, function (args, onDone, extra) { var account = (extra && extra.session && extra.session._user ? extra.session._user.account : args.account); if (l_validateAccount(account) === false) { return onDone('INVALID_ACCOUNT', account); } var data = l_accounts[account]; // convert needed types into array form var types = args.types || []; if (args.type) { types.push(args.type); } // prepare return value, including 'account' var value = {account: account}; var errmsg = ''; for (var i=0; i < types.length; i++) { if (data.hasOwnProperty(types[i]) === false) { errmsg += ('field [' + types[i] + '] invalid\n'); } else { value[types[i]] = data[types[i]]; } } if (errmsg !== '') { onDone('INVALID_DATA', errmsg); } else { onDone(null, value); } }); // get group info (array form) for a given account SR.API.add('_ACCOUNT_GETGROUP', { _login: true, account: 'string', }, function (args, onDone, extra) { var account = args.account; if (l_validateAccount(account) === false) { return onDone('INVALID_ACCOUNT', account); } onDone(null, l_accounts[account].control.groups); }); SR.API.add('_ACCOUNT_GETUID', { _admin: true, account: '+string', }, function (args, onDone) { if (args.account !== undefined) { if (l_accounts[args.account] === undefined) { return onDone('Can not find this account.'); } return onDone(null, l_accounts[args.account].uid); } var uid_list = {}; for (var account in l_accounts) { uid_list[account] = l_accounts[account].uid; } return onDone(null, uid_list); }); // set all groups for an account, by proving a group string SR.API.add('_ACCOUNT_SETGROUP', { _admin: true, account: 'string', groups: 'string' }, function (args, onDone) { var account = args.account; if (l_validateAccount(account) === false) { return onDone('INVALID_ACCOUNT', account); } // split input into array var arr = args.groups.split(/[\s\b\n\t,;]+/); var user = l_accounts[account]; user.control.groups = arr; user.sync(onDone); }); // add an account to a given group membership SR.API.add('_ACCOUNT_ADDGROUP', { //_admin: true, account: 'string', group: 'string' }, function (args, onDone) { var account = args.account; if (l_validateAccount(account) === false) { return onDone('INVALID_ACCOUNT', args.account); } var user = l_accounts[account]; for (var i=0; i < user.control.groups.length; i++) { if (user.control.groups[i] === args.group) { return onDone('GROUP_EXISTS', args.group); } } // add the group user.control.groups.push(args.group); user.sync(onDone); }); // add an account to a given group membership SR.API.add('_ACCOUNT_REMOVEGROUP', { _admin: true, account: 'string', group: 'string' }, function (args, onDone) { var account = args.account; if (l_validateAccount(account) === false) { return onDone('INVALID_ACCOUNT', account); } var user = l_accounts[account]; for (var i=0; i < user.control.groups.length; i++) { if (user.control.groups[i] === args.group) { user.control.groups.splice(i, 1); user.sync(onDone); return; } } onDone('GROUP_ERROR', 'account [' + account + '] does no belong to group [' + args.group + ']'); }); // add an account to a given group membership SR.API.add('_ACCOUNT_DELETE', { _admin: true, account: 'string', }, function (args, onDone) { if (!l_accounts[args.account]) return onDone(args.account + ' account is not defined!'); l_accounts.remove({account:args.account}, function(err, result) { if(err) { return err; } onDone(null); }); }); var l_models = {}; l_models[l_name] = { uid: 'number', // unique system-wide id to identify a user account: '*string', // user account name, also the key to the in-memory map password: 'string', // encrypted login password, method specified by 'enc_type' email: 'string', // email used for both contact and password reset purpose tokens: 'object', // include: 1. pass tokens (can used for login) 2. reset tokens (used to auth password reset) enc_type: 'number', // encryption method used control: 'object', // include: groups & permissions arrays login: 'object', // login record data: 'object' // custom account-specific data }; SR.Callback.onStart(function () { LOG.warn('account module onStart called, init DS...'); SR.DS.init({models: l_models}, function (err, ref) { if (err) { LOG.error(err, l_name); return; } l_accounts = ref[l_name]; LOG.warn('l_accounts initialized with size: ' + l_accounts.size()); }); });