/* TShock, a server mod for Terraria Copyright (C) 2011-2015 Nyx Studios (fka. The TShock Team) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ using System; using System.Data; using System.Collections.Generic; using System.Linq; using System.Text; using MySql.Data.MySqlClient; using System.Text.RegularExpressions; using BCrypt.Net; using System.Security.Cryptography; namespace TShockAPI.DB { /// UserManager - Methods for dealing with database users and other user functionality within TShock. public class UserManager { /// database - The database object to use for connections. private IDbConnection _database; /// Creates a UserManager object. During instantiation, this method will verify the table structure against the format below. /// The database to connect to. /// A UserManager object. public UserManager(IDbConnection db) { _database = db; var table = new SqlTable("Users", new SqlColumn("ID", MySqlDbType.Int32) {Primary = true, AutoIncrement = true}, new SqlColumn("Username", MySqlDbType.VarChar, 32) {Unique = true}, new SqlColumn("Password", MySqlDbType.VarChar, 128), new SqlColumn("UUID", MySqlDbType.VarChar, 128), new SqlColumn("Usergroup", MySqlDbType.Text), new SqlColumn("Registered", MySqlDbType.Text), new SqlColumn("LastAccessed", MySqlDbType.Text), new SqlColumn("KnownIPs", MySqlDbType.Text) ); var creator = new SqlTableCreator(db, db.GetSqlType() == SqlType.Sqlite ? (IQueryBuilder) new SqliteQueryCreator() : new MysqlQueryCreator()); creator.EnsureTableStructure(table); } /// /// Adds a given username to the database /// /// User user public void AddUser(User user) { if (!TShock.Groups.GroupExists(user.Group)) throw new GroupNotExistsException(user.Group); int ret; try { ret = _database.Query("INSERT INTO Users (Username, Password, UUID, UserGroup, Registered) VALUES (@0, @1, @2, @3, @4);", user.Name, user.Password, user.UUID, user.Group, DateTime.UtcNow.ToString("s")); } catch (Exception ex) { // Detect duplicate user using a regexp as Sqlite doesn't have well structured exceptions if (Regex.IsMatch(ex.Message, "Username.*not unique")) throw new UserExistsException(user.Name); throw new UserManagerException("AddUser SQL returned an error (" + ex.Message + ")", ex); } if (1 > ret) throw new UserExistsException(user.Name); Hooks.AccountHooks.OnAccountCreate(user); } /// /// Removes a given username from the database /// /// User user public void RemoveUser(User user) { try { var tempuser = GetUser(user); int affected = _database.Query("DELETE FROM Users WHERE Username=@0", user.Name); if (affected < 1) throw new UserNotExistException(user.Name); Hooks.AccountHooks.OnAccountDelete(tempuser); } catch (Exception ex) { throw new UserManagerException("RemoveUser SQL returned an error", ex); } } /// /// Sets the Hashed Password for a given username /// /// User user /// string password public void SetUserPassword(User user, string password) { try { user.CreateBCryptHash(password); if ( _database.Query("UPDATE Users SET Password = @0 WHERE Username = @1;", user.Password, user.Name) == 0) throw new UserNotExistException(user.Name); } catch (Exception ex) { throw new UserManagerException("SetUserPassword SQL returned an error", ex); } } /// /// Sets the UUID for a given username /// /// User user /// string uuid public void SetUserUUID(User user, string uuid) { try { if ( _database.Query("UPDATE Users SET UUID = @0 WHERE Username = @1;", uuid, user.Name) == 0) throw new UserNotExistException(user.Name); } catch (Exception ex) { throw new UserManagerException("SetUserUUID SQL returned an error", ex); } } /// /// Sets the group for a given username /// /// User user /// string group public void SetUserGroup(User user, string group) { try { Group grp = TShock.Groups.GetGroupByName(group); if (null == grp) throw new GroupNotExistsException(group); if (_database.Query("UPDATE Users SET UserGroup = @0 WHERE Username = @1;", group, user.Name) == 0) throw new UserNotExistException(user.Name); // Update player group reference for any logged in player foreach (var player in TShock.Players.Where(p => null != p && p.User.Name == user.Name)) { player.Group = grp; } } catch (Exception ex) { throw new UserManagerException("SetUserGroup SQL returned an error", ex); } } /// Updates the last accessed time for a database user to the current time. /// The user object to modify. public void UpdateLogin(User user) { try { if (_database.Query("UPDATE Users SET LastAccessed = @0, KnownIps = @1 WHERE Username = @2;", DateTime.UtcNow.ToString("s"), user.KnownIps, user.Name) == 0) throw new UserNotExistException(user.Name); } catch (Exception ex) { throw new UserManagerException("UpdateLogin SQL returned an error", ex); } } /// Gets the database ID of a given user object from the database. /// The username of the user to query for. /// The user's ID public int GetUserID(string username) { try { using (var reader = _database.QueryReader("SELECT * FROM Users WHERE Username=@0", username)) { if (reader.Read()) { return reader.Get("ID"); } } } catch (Exception ex) { TShock.Log.ConsoleError("FetchHashedPasswordAndGroup SQL returned an error: " + ex); } return -1; } /// Gets a user object by name. /// The user's name. /// The user object returned from the search. public User GetUserByName(string name) { try { return GetUser(new User {Name = name}); } catch (UserManagerException) { return null; } } /// Gets a user object by their user ID. /// The user's ID. /// The user object returned from the search. public User GetUserByID(int id) { try { return GetUser(new User {ID = id}); } catch (UserManagerException) { return null; } } /// Gets a user object by a user object. /// The user object to search by. /// The user object that is returned from the search. public User GetUser(User user) { bool multiple = false; string query; string type; object arg; if (0 != user.ID) { query = "SELECT * FROM Users WHERE ID=@0"; arg = user.ID; type = "id"; } else { query = "SELECT * FROM Users WHERE Username=@0"; arg = user.Name; type = "name"; } try { using (var result = _database.QueryReader(query, arg)) { if (result.Read()) { user = LoadUserFromResult(user, result); // Check for multiple matches if (!result.Read()) return user; multiple = true; } } } catch (Exception ex) { throw new UserManagerException("GetUser SQL returned an error (" + ex.Message + ")", ex); } if (multiple) throw new UserManagerException(String.Format("Multiple users found for {0} '{1}'", type, arg)); throw new UserNotExistException(user.Name); } /// Gets all users from the database. /// The users from the database. public List GetUsers() { try { List users = new List(); using (var reader = _database.QueryReader("SELECT * FROM Users")) { while (reader.Read()) { users.Add(LoadUserFromResult(new User(), reader)); } return users; } } catch (Exception ex) { TShock.Log.Error(ex.ToString()); } return null; } /// /// Gets all users from the database with a username that starts with or contains /// /// Rough username search. "n" will match "n", "na", "nam", "name", etc /// If is not the first part of the username. If true then "name" would match "name", "username", "wordsnamewords", etc /// Matching users or null if exception is thrown public List GetUsersByName(string username, bool notAtStart = false) { try { List users = new List(); string search = notAtStart ? string.Format("%{0}%", username) : string.Format("{0}%", username); using (var reader = _database.QueryReader("SELECT * FROM Users WHERE Username LIKE @0", search)) { while (reader.Read()) { users.Add(LoadUserFromResult(new User(), reader)); } } return users; } catch (Exception ex) { TShock.Log.Error(ex.ToString()); } return null; } /// Fills out the fields of a User object with the results from a QueryResult object. /// The user to add data to. /// The QueryResult object to add data from. /// The 'filled out' user object. private User LoadUserFromResult(User user, QueryResult result) { user.ID = result.Get("ID"); user.Group = result.Get("Usergroup"); user.Password = result.Get("Password"); user.UUID = result.Get("UUID"); user.Name = result.Get("Username"); user.Registered = result.Get("Registered"); user.LastAccessed = result.Get("LastAccessed"); user.KnownIps = result.Get("KnownIps"); return user; } } /// A database user. public class User { /// The database ID of the user. public int ID { get; set; } /// The user's name. public string Name { get; set; } /// The hashed password for the user. public string Password { get; internal set; } /// The user's saved Univerally Unique Identifier token. public string UUID { get; set; } /// The group object that the user is a part of. public string Group { get; set; } /// The unix epoch corresponding to the registration date of the user. public string Registered { get; set; } /// The unix epoch corresponding to the last access date of the user. public string LastAccessed { get; set; } /// A JSON serialized list of known IP addresses for a user. public string KnownIps { get; set; } /// Constructor for the user object, assuming you define everything yourself. /// The user's name. /// The user's password hash. /// The user's UUID. /// The user's group name. /// The unix epoch for the registration date. /// The unix epoch for the last access date. /// The known IPs for the user, serialized as a JSON object /// A completed user object. public User(string name, string pass, string uuid, string group, string registered, string last, string known) { Name = name; Password = pass; UUID = uuid; Group = group; Registered = registered; LastAccessed = last; KnownIps = known; } /// Default constructor for a user object; holds no data. /// A user object. public User() { Name = ""; Password = ""; UUID = ""; Group = ""; Registered = ""; LastAccessed = ""; KnownIps = ""; } /// /// Verifies if a password matches the one stored in the database. /// If the password is stored in an unsafe hashing algorithm, it will be converted to BCrypt. /// If the password is stored using BCrypt, it will be re-saved if the work factor in the config /// is greater than the existing work factor with the new work factor. /// /// The password to check against the user object. /// bool true, if the password matched, or false, if it didn't. public bool VerifyPassword(string password) { try { if (BCrypt.Net.BCrypt.Verify(password, Password)) { // If necessary, perform an upgrade to the highest work factor. UpgradePasswordWorkFactor(password); return true; } } catch (SaltParseException) { if (String.Equals(HashPassword(password), Password, StringComparison.InvariantCultureIgnoreCase)) { // Return true to keep blank passwords working but don't convert them to bcrypt. if (Password == "non-existant password") { return true; } // The password is not stored using BCrypt; upgrade it to BCrypt immediately UpgradePasswordToBCrypt(password); return true; } return false; } return false; } /// Upgrades a password to BCrypt, from an insecure hashing algorithm. /// The raw user password (unhashed) to upgrade protected void UpgradePasswordToBCrypt(string password) { // Save the old password, in the event that we have to revert changes. string oldpassword = Password; try { TShock.Users.SetUserPassword(this, Password); } catch (UserManagerException e) { TShock.Log.ConsoleError(e.ToString()); Password = oldpassword; // Revert changes } } /// Upgrades a password to the highest work factor available in the config. /// The raw user password (unhashed) to upgrade protected void UpgradePasswordWorkFactor(string password) { // If the destination work factor is not greater, we won't upgrade it or re-hash it int currentWorkFactor; try { currentWorkFactor = Int32.Parse((Password.Split('$')[2])); } catch (FormatException) { TShock.Log.ConsoleError("Warning: Not upgrading work factor because bcrypt hash in an invalid format."); return; } if (currentWorkFactor < TShock.Config.BCryptWorkFactor) { try { TShock.Users.SetUserPassword(this, password); } catch (UserManagerException e) { TShock.Log.ConsoleError(e.ToString()); } } } /// Creates a BCrypt hash for a user and stores it in this object. /// The plain text password to hash public void CreateBCryptHash(string password) { if (password.Trim().Length < Math.Max(4, TShock.Config.MinimumPasswordLength)) { throw new ArgumentOutOfRangeException("password", "Password must be > " + TShock.Config.MinimumPasswordLength + " characters."); } try { Password = BCrypt.Net.BCrypt.HashPassword(password.Trim(), TShock.Config.BCryptWorkFactor); } catch (ArgumentOutOfRangeException) { TShock.Log.ConsoleError("Invalid BCrypt work factor in config file! Creating new hash using default work factor."); Password = BCrypt.Net.BCrypt.HashPassword(password.Trim()); } } /// Creates a BCrypt hash for a user and stores it in this object. /// The plain text password to hash /// The work factor to use in generating the password hash public void CreateBCryptHash(string password, int workFactor) { if (password.Trim().Length < Math.Max(4, TShock.Config.MinimumPasswordLength)) { throw new ArgumentOutOfRangeException("password", "Password must be > " + TShock.Config.MinimumPasswordLength + " characters."); } Password = BCrypt.Net.BCrypt.HashPassword(password.Trim(), workFactor); } /// /// A dictionary of hashing algorithms and an implementation object. /// protected readonly Dictionary> HashTypes = new Dictionary> { {"sha512", () => new SHA512Managed()}, {"sha256", () => new SHA256Managed()}, {"md5", () => new MD5Cng()}, {"sha512-xp", () => SHA512.Create()}, {"sha256-xp", () => SHA256.Create()}, {"md5-xp", () => MD5.Create()}, }; /// /// Returns a hashed string for a given string based on the config file's hash algo /// /// bytes to hash /// string hash protected string HashPassword(byte[] bytes) { if (bytes == null) throw new NullReferenceException("bytes"); Func func; if (!HashTypes.TryGetValue(TShock.Config.HashAlgorithm.ToLower(), out func)) throw new NotSupportedException("Hashing algorithm {0} is not supported".SFormat(TShock.Config.HashAlgorithm.ToLower())); using (var hash = func()) { var ret = hash.ComputeHash(bytes); return ret.Aggregate("", (s, b) => s + b.ToString("X2")); } } /// /// Returns a hashed string for a given string based on the config file's hash algo /// /// string to hash /// string hash protected string HashPassword(string password) { if (string.IsNullOrEmpty(password) && Password == "non-existant password") return "non-existant password"; return HashPassword(Encoding.UTF8.GetBytes(password)); } } /// UserManagerException - An exception generated by the user manager. [Serializable] public class UserManagerException : Exception { /// Creates a new UserManagerException object. /// The message for the object. /// A new UserManagerException object. public UserManagerException(string message) : base(message) { } /// Creates a new UserManagerObject with an internal exception. /// The message for the object. /// The inner exception for the object. /// A new UserManagerException with a defined inner exception. public UserManagerException(string message, Exception inner) : base(message, inner) { } } /// A UserExistsException object, used when a user already exists when attempting to create a new one. [Serializable] public class UserExistsException : UserManagerException { /// Creates a new UserExistsException object. /// The name of the user that already exists. /// A UserExistsException object with the user's name passed in the message. public UserExistsException(string name) : base("User '" + name + "' already exists") { } } /// A UserNotExistException, used when a user does not exist and a query failed as a result of it. [Serializable] public class UserNotExistException : UserManagerException { /// Creates a new UserNotExistException object, with the user's name in the message. /// The user's name to be pasesd in the message. /// A new UserNotExistException object with a message containing the user's name that does not exist. public UserNotExistException(string name) : base("User '" + name + "' does not exist") { } } /// A GroupNotExistsException, used when a group does not exist. [Serializable] public class GroupNotExistsException : UserManagerException { /// Creates a new GroupNotExistsException object with the group's name in the message. /// The group name. /// A new GroupNotExistsException with the group that does not exist's name in the message. public GroupNotExistsException(string group) : base("Group '" + group + "' does not exist") { } } }