diff --git a/.gitignore b/.gitignore index 71187dd5..90872276 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules *.crt *.cert *.key +torlist diff --git a/lib/acp.js b/lib/acp.js index 7456d628..c5de8452 100644 --- a/lib/acp.js +++ b/lib/acp.js @@ -57,7 +57,7 @@ module.exports = { }); user.socket.on("acp-lookup-user", function(name) { - db.searchUser(name, function (err, res) { + db.users.search(name, ["name", "email", "global_rank"], function (err, res) { res = res || []; user.socket.emit("acp-userdata", res); }); @@ -71,7 +71,7 @@ module.exports = { }); user.socket.on("acp-reset-password", function(data) { - db.getGlobalRank(data.name, function (err, rank) { + db.users.getGlobalRank(data.name, function (err, rank) { if(err || rank >= user.global_rank) return; @@ -98,11 +98,11 @@ module.exports = { if(data.rank < 1 || data.rank >= user.global_rank) return; - db.getGlobalRank(data.name, function (err, rank) { + db.users.getGlobalRank(data.name, function (err, rank) { if(err || rank >= user.global_rank) return; - db.setGlobalRank(data.name, data.rank, + db.users.setGlobalRank(data.name, data.rank, function (err, res) { ActionLog.record(user.ip, user.name, "acp-set-rank", data); diff --git a/lib/api.js b/lib/api.js index 3a20b7b2..42ba1c27 100644 --- a/lib/api.js +++ b/lib/api.js @@ -87,7 +87,7 @@ module.exports = function (Server) { if (pw !== needPassword) { var uname = req.cookies.cytube_uname; var session = req.cookies.cytube_session; - Server.db.userLoginSession(uname, session, function (err, row) { + Server.db.users.verifyAuth(uname + ":" + session, function (err, row) { if (err) { res.status(403); res.type("application/json"); @@ -135,7 +135,7 @@ module.exports = function (Server) { if(filter !== "public") { var name = query.name || ""; var session = query.session || ""; - db.userLoginSession(name, session, function (err, row) { + db.users.verifyAuth(name + ":" + session, function (err, row) { if(err) { if(err !== "Invalid session" && err !== "Session expired") { @@ -197,7 +197,7 @@ module.exports = function (Server) { return; } - db.userLogin(name, pw, session, function (err, row) { + var callback = function (err, row) { if(err) { if(err !== "Session expired") ActionLog.record(getIP(req), name, "login-failure", err); @@ -215,9 +215,15 @@ module.exports = function (Server) { res.jsonp({ success: true, name: name, - session: row.session_hash + session: row.hash }); - }); + }; + + if (session) { + db.users.verifyAuth(name + ":" + session, callback); + } else { + db.users.verifyLogin(name, pw, callback); + } }); /* register an account */ @@ -268,21 +274,8 @@ module.exports = function (Server) { return; } - - if(!$util.isValidUserName(name)) { - ActionLog.record(ip, name, "register-failure", - "Invalid name"); - res.jsonp({ - success: false, - error: "Invalid username. Valid usernames must be " + - "1-20 characters long and consist only of " + - "alphanumeric characters and underscores (_)" - }); - return; - } - // db.registerUser checks if the name is taken already - db.registerUser(name, pw, function (err, session) { + db.users.register(name, pw, "", req.ip, function (err, session) { if(err) { res.jsonp({ success: false, @@ -328,7 +321,7 @@ module.exports = function (Server) { return; } - db.userLoginPassword(name, oldpw, function (err, row) { + db.users.verifyLogin(name, oldpw, function (err, row) { if(err) { res.jsonp({ success: false, @@ -337,7 +330,7 @@ module.exports = function (Server) { return; } - db.setUserPassword(name, newpw, function (err, row) { + db.users.setPassword(name, newpw, function (err, row) { if(err) { res.jsonp({ success: false, @@ -514,7 +507,7 @@ module.exports = function (Server) { text = text.substring(0, 255); } - db.userLoginSession(name, session, function (err, row) { + db.verifyAuth(name + ":" + session, function (err, row) { if(err) { res.jsonp({ success: false, @@ -590,7 +583,7 @@ module.exports = function (Server) { return; } - db.userLoginPassword(name, pw, function (err, row) { + db.users.verifyLogin(name, pw, function (err, row) { if(err) { res.jsonp({ success: false, @@ -599,7 +592,7 @@ module.exports = function (Server) { return; } - db.setUserEmail(name, email, function (err, dbres) { + db.users.setEmail(name, email, function (err, dbres) { if(err) { res.jsonp({ success: false, @@ -611,7 +604,7 @@ module.exports = function (Server) { ActionLog.record(getIP(req), name, "email-update", email); res.jsonp({ success: true, - session: row.session_hash + session: row.hash }); }); }); @@ -633,7 +626,7 @@ module.exports = function (Server) { return; } - db.userLoginSession(name, session, function (err, row) { + db.users.verifyAuth(name + ":" + session, function (err, row) { if(err) { res.jsonp({ success: false, @@ -682,7 +675,7 @@ module.exports = function (Server) { return; } - db.userLoginSession(name, session, function (err, row) { + db.verifyAuth(name + ":" + session, function (err, row) { if(err) { if(err !== "Invalid session" && err !== "Session expired") { @@ -743,7 +736,7 @@ module.exports = function (Server) { return; } - db.userLoginSession(name, session, function (err, row) { + db.users.verifyAuth(name + ":" + session, function (err, row) { if(err) { if(err !== "Invalid session" && err !== "Session expired") { @@ -780,7 +773,7 @@ module.exports = function (Server) { return; } - db.userLoginSession(name, session, function (err, row) { + db.users.verifyAuth(name + ":" + session, function (err, row) { if(err) { if(err !== "Invalid session" && err !== "Session expired") { @@ -817,7 +810,7 @@ module.exports = function (Server) { return; } - db.userLoginSession(name, session, function (err, row) { + db.users.verifyAuth(name + ":" + session, function (err, row) { if(err) { if(err !== "Invalid session" && err !== "Session expired") { diff --git a/lib/database.js b/lib/database.js index 1c0cad87..39b4f3c7 100644 --- a/lib/database.js +++ b/lib/database.js @@ -26,6 +26,8 @@ var Database = function (cfg) { }); self.global_ipbans = {}; + self.users = require("./database/accounts")(self); + self.users.init(); }; Database.prototype.query = function (query, sub, callback) { diff --git a/lib/database/accounts.js b/lib/database/accounts.js new file mode 100644 index 00000000..fd9d68e8 --- /dev/null +++ b/lib/database/accounts.js @@ -0,0 +1,439 @@ +//var db = require("../database"); +var $util = require("../utilities"); +var bcrypt = require("bcrypt"); + +var registrationLock = {}; +var blackHole = function () { }; + +module.exports = function (db) { + return { + /** + * Initialize the accounts table + */ + init: function () { + db.query("CREATE TABLE IF NOT EXISTS `users` (" + + "`id` INT NOT NULL AUTO_INCREMENT," + + "`name` VARCHAR(20) NOT NULL," + + "`password` VARCHAR(64) NOT NULL," + + "`global_rank` INT NOT NULL," + + "`email` VARCHAR(255) NOT NULL," + + "`ip` VARCHAR(39) NOT NULL," + + "`time` BIGINT NOT NULL, " + + "PRIMARY KEY(`id`), INDEX(`name`)) " + + "CHARACTER SET utf8"); + }, + + /** + * Check if a username is taken + */ + isUsernameTaken: function (name, callback) { + db.query("SELECT name FROM `users` WHERE name=?", [name], + function (err, rows) { + if (err) { + callback(err, true); + return; + } + callback(null, rows.length > 0); + }); + }, + + /** + * Search for a user by name + */ + search: function (name, fields, callback) { + /* This bit allows it to accept varargs + Function can be called as (name, callback) or + (name, fields, callback) + */ + if (typeof callback !== "function") { + if (typeof fields === "function") { + callback = fields; + fields = ["name"]; + } else { + return; + } + } + + // Don't allow search to return password hashes + if (fields.indexOf("password") !== -1) { + fields.splice(fields.indexOf("password")); + } + + db.query("SELECT " + fields.join(",") + " FROM `users` WHERE name LIKE ?", + ["%"+name+"%"], + function (err, rows) { + if (err) { + callback(err, true); + return; + } + callback(null, rows); + }); + }, + + /** + * Registers a new user account + */ + register: function (name, pw, email, ip, callback) { + // Start off with a boatload of error checking + if (typeof callback !== "function") { + callback = blackHole; + } + + if (typeof name !== "string" || typeof pw !== "string") { + callback(new Error("You must provide a nonempty username and password"), null); + return; + } + var lname = name.toLowerCase(); + + if (registrationLock[lname]) { + callback(new Error("There is already a registration in progress for "+name), + null); + return; + } + + if (!$util.isValidUserName(name)) { + callback(new Error("Invalid username. Usernames may consist of 1-20 " + + "characters a-z, A-Z, 0-9, -, _, and accented letters."), + null); + return; + } + + if (typeof email !== "string") { + email = ""; + } + + if (typeof ip !== "string") { + ip = ""; + } + + // From this point forward, actual registration happens + // registrationLock prevents concurrent database activity + // on the same user account + registrationLock[lname] = true; + + this.isUsernameTaken(name, function (err, taken) { + if (err) { + delete registrationLock[lname]; + callback(err, null); + return; + } + + if (taken) { + delete registrationLock[lname]; + callback(new Error("Username is already registered"), null); + return; + } + + bcrypt.hash(pw, 10, function (err, hash) { + if (err) { + delete registrationLock[lname]; + callback(err, null); + return; + } + + db.query("INSERT INTO `users` " + + "(`name`, `password`, `global_rank`, `email`, `ip`, `time`)" + + " VALUES " + + "(?, ?, ?, ?, ?, ?)", + [name, hash, 1, email, ip, Date.now()], + function (err, res) { + delete registrationLock[lname]; + if (err) { + callback(err, null); + } else { + callback(null, { + name: name, + hash: hash + }); + } + }); + }); + }); + }, + + /** + * Verify a username/password pair + */ + verifyLogin: function (name, pw, callback) { + if (typeof callback !== "function") { + return; + } + + if (typeof name !== "string" || typeof pw !== "string") { + callback(new Error("Invalid username/password combination"), null); + return; + } + + /* Passwords are capped at 100 characters to prevent a potential + denial of service vector through causing the server to hash + ridiculously long strings. + */ + pw = pw.substring(0, 100); + + /* Note: rather than hash the password and then query based on name and + password, I query by name, then use bcrypt.compare() to check that + the hashes match. + */ + + db.query("SELECT name,password,global_rank FROM `users` WHERE name=?", + [name], + function (err, rows) { + if (err) { + callback(err, null); + return; + } + + if (rows.length === 0) { + callback(new Error("User does not exist"), null); + return; + } + + bcrypt.compare(pw, rows[0].password, function (err, match) { + if (err) { + callback(err, null); + } else if (!match) { + callback(new Error("Invalid username/password combination"), null); + } else { + callback(null, { + name: rows[0].name, + hash: rows[0].password, + global_rank: rows[0].global_rank + }); + } + }); + }); + }, + + /** + * Verify an auth string of the form name:hash + */ + verifyAuth: function (auth, callback) { + if (typeof callback !== "function") { + return; + } + + if (typeof auth !== "string") { + callback(new Error("Invalid auth string"), null); + return; + } + + var split = auth.split(":"); + if (split.length !== 2) { + callback(new Error("Invalid auth string"), null); + return; + } + + var name = split[0]; + var hash = split[1]; + db.query("SELECT name,password,global_rank FROM `users` WHERE " + + "name=? and password=?", [name, hash], + function (err, rows) { + if (err) { + callback(err, null); + return; + } + + if (rows.length === 0) { + callback(new Error("Auth string does not match an existing user"), null); + return; + } + + callback(null, { + name: rows[0].name, + hash: rows[0].password, + global_rank: rows[0].global_rank + }); + }); + }, + + /** + * Change a user's password + */ + setPassword: function (name, pw, callback) { + if (typeof callback !== "function") { + callback = blackHole; + } + + if (typeof name !== "string" || typeof pw !== "string") { + callback(new Error("Invalid username/password combination"), null); + return; + } + + /* Passwords are capped at 100 characters to prevent a potential + denial of service vector through causing the server to hash + ridiculously long strings. + */ + pw = pw.substring(0, 100); + + bcrypt.hash(pw, 10, function (err, hash) { + if (err) { + callback(err, null); + return; + } + + db.query("UPDATE `users` SET password=? WHERE name=?", + [hash, name], + function (err, result) { + callback(err, err ? null : true); + }); + }); + }, + + /** + * Lookup a user's global rank + */ + getGlobalRank: function (name, callback) { + if (typeof callback !== "function") { + return; + } + + if (typeof name !== "string") { + callback(new Error("Invalid username"), null); + return; + } + + db.query("SELECT global_rank FROM `users` WHERE name=?", [name], + function (err, rows) { + if (err) { + callback(err, null); + } else if (rows.length === 0) { + callback(new Error("User does not exist"), null); + } else { + callback(null, rows[0].global_rank); + } + }); + }, + + /** + * Updates a user's global rank + */ + setGlobalRank: function (name, rank, callback) { + if (typeof callback !== "function") { + callback = blackHole; + } + + if (typeof name !== "string") { + callback(new Error("Invalid username"), null); + return; + } + + if (typeof rank !== "number") { + callback(new Error("Invalid rank"), null); + return; + } + + db.query("UPDATE `users` SET global_rank=? WHERE name=?", [rank, name], + function (err, result) { + callback(err, err ? null : true); + }); + }, + + /** + * Lookup multiple users' global rank in one query + */ + getGlobalRanks: function (names, callback) { + if (typeof callback !== "function") { + return; + } + + if (!(names instanceof Array)) { + callback(new Error("Expected array of names, got " + typeof names), null); + return; + } + + var list = "(" + names.map(function () { return "?";}).join(",") + ")"; + + db.query("SELECT global_rank FROM `users` WHERE name IN " + list, names, + function (err, rows) { + if (err) { + callback(err, null); + } else if (rows.length === 0) { + callback(null, []); + } else { + callback(null, rows.map(function (x) { return x.global_rank; })); + } + }); + }, + + /** + * Lookup a user's email + */ + getEmail: function (name, callback) { + if (typeof callback !== "function") { + return; + } + + if (typeof name !== "string") { + callback(new Error("Invalid username"), null); + return; + } + + db.query("SELECT email FROM `users` WHERE name=?", [name], + function (err, rows) { + if (err) { + callback(err, null); + } else if (rows.length === 0) { + callback(new Error("User does not exist"), null); + } else { + callback(null, rows[0].email); + } + }); + }, + + /** + * Updates a user's email + */ + setEmail: function (name, email, callback) { + if (typeof callback !== "function") { + callback = blackHole; + } + + if (typeof name !== "string") { + callback(new Error("Invalid username"), null); + return; + } + + if (typeof email !== "string") { + callback(new Error("Invalid email"), null); + return; + } + + db.query("UPDATE `users` SET email=? WHERE name=?", [email, name], + function (err, result) { + callback(err, err ? null : true); + }); + }, + + getProfile: function (name, callback) { + if (typeof callback !== "function") { + return; + } + + callback(new Error("getProfile is not implemented"), null); + }, + + setProfile: function (name, callback) { + if (typeof callback !== "function") { + return; + } + + callback(new Error("setProfile is not implemented"), null); + }, + + generatePasswordReset: function (ip, name, email, callback) { + if (typeof callback !== "function") { + return; + } + + callback(new Error("generatePasswordReset is not implemented"), null); + }, + + recoverPassword: function (hash, callback) { + if (typeof callback !== "function") { + return; + } + + callback(new Error("recoverPassword is not implemented"), null); + }, + }; +}; diff --git a/lib/utilities.js b/lib/utilities.js index 253aad61..8d23224b 100644 --- a/lib/utilities.js +++ b/lib/utilities.js @@ -38,7 +38,7 @@ module.exports = { }, isValidUserName: function (name) { - return name.match(/^[\w-_]{1,20}$/); + return name.match(/^[-\w\u00c0-\u00ff]{1,20}$/); }, randomSalt: function (length) { diff --git a/www/assets/js/account.js b/www/assets/js/account.js index 6e25f647..385fceb9 100644 --- a/www/assets/js/account.js +++ b/www/assets/js/account.js @@ -134,9 +134,9 @@ $("#registerbtn").click(function() { var pwc = $("#regpwconfirm").val(); var err = false; - if(!name.match(/^[a-z0-9_]{1,20}$/i)) { + if(!name.match(/^[-\w\u00c0-\u00ff]{1,20}$/i)) { $("
").addClass("alert alert-error") - .text("Usernames must be 1-20 characters long and contain only a-z, 0-9, and underscores") + .text("Usernames must be 1-20 characters long and contain only a-z, A-Z, 0-9, -, _, and accented letters.") .insertAfter($("#regusername").parent().parent()); err = true; }