/* TShock, a server mod for Terraria Copyright (C) 2011-2019 Pryaxis & TShock Contributors 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; using TShockAPI.DB.Queries; using TShockAPI.Hooks; namespace TShockAPI.DB { /// UserAccountManager - Methods for dealing with database user accounts and other related functionality within TShock. public class UserAccountManager { /// database - The database object to use for connections. private IDbConnection _database; /// Creates a UserAccountManager object. During instantiation, this method will verify the table structure against the format below. /// The database to connect to. /// A UserAccountManager object. public UserAccountManager(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) ); SqlTableCreator creator = new(db, db.GetSqlQueryBuilder()); creator.EnsureTableStructure(table); } /// /// Adds the given user account to the database /// /// The user account to be added public void AddUserAccount(UserAccount account) { if (!TShock.Groups.GroupExists(account.Group)) throw new GroupNotExistsException(account.Group); int ret; try { ret = _database.Query("INSERT INTO Users (Username, Password, UUID, UserGroup, Registered) VALUES (@0, @1, @2, @3, @4);", account.Name, account.Password, account.UUID, account.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|UNIQUE constraint failed: Users\\.Username")) throw new UserAccountExistsException(account.Name); throw new UserAccountManagerException(GetString($"AddUser SQL returned an error ({ex.Message})"), ex); } if (1 > ret) throw new UserAccountExistsException(account.Name); Hooks.AccountHooks.OnAccountCreate(account); } /// /// Removes all user accounts from the database whose usernames match the given user account /// /// The user account public void RemoveUserAccount(UserAccount account) { try { // Logout any player logged in as the account to be removed TShock.Players.Where(p => p?.IsLoggedIn == true && p.Account.Name == account.Name).ForEach(p => p.Logout()); UserAccount tempuser = GetUserAccount(account); int affected = _database.Query("DELETE FROM Users WHERE Username=@0", account.Name); if (affected < 1) throw new UserAccountNotExistException(account.Name); Hooks.AccountHooks.OnAccountDelete(tempuser); } catch (Exception ex) { throw new UserAccountManagerException(GetString("RemoveUser SQL returned an error"), ex); } } /// /// Sets the Hashed Password for a given username /// /// The user account /// The user account password to be set public void SetUserAccountPassword(UserAccount account, string password) { try { account.CreateBCryptHash(password); if ( _database.Query("UPDATE Users SET Password = @0 WHERE Username = @1;", account.Password, account.Name) == 0) throw new UserAccountNotExistException(account.Name); } catch (Exception ex) { throw new UserAccountManagerException(GetString("SetUserPassword SQL returned an error"), ex); } } /// /// Sets the UUID for a given username /// /// The user account /// The user account uuid to be set public void SetUserAccountUUID(UserAccount account, string uuid) { try { if ( _database.Query("UPDATE Users SET UUID = @0 WHERE Username = @1;", uuid, account.Name) == 0) throw new UserAccountNotExistException(account.Name); } catch (Exception ex) { throw new UserAccountManagerException(GetString("SetUserUUID SQL returned an error"), ex); } } /// /// Sets the group for a given username /// /// The user account /// The user account group to be set public void SetUserGroup(UserAccount account, string group) { Group grp = TShock.Groups.GetGroupByName(group); if (null == grp) throw new GroupNotExistsException(group); if (AccountHooks.OnAccountGroupUpdate(account, ref grp)) throw new UserGroupUpdateLockedException(account.Name); if (_database.Query("UPDATE Users SET UserGroup = @0 WHERE Username = @1;", grp.Name, account.Name) == 0) throw new UserAccountNotExistException(account.Name); try { // Update player group reference for any logged in player foreach (var player in TShock.Players.Where(p => p != null && p.Account != null && p.Account.Name == account.Name)) { player.Group = grp; } } catch (Exception ex) { throw new UserAccountManagerException(GetString("SetUserGroup SQL returned an error"), ex); } } /// /// Sets the group for a given username /// /// Who changes the group /// The user account /// The user account group to be set public void SetUserGroup(TSPlayer author, UserAccount account, string group) { Group grp = TShock.Groups.GetGroupByName(group); if (null == grp) throw new GroupNotExistsException(group); if (AccountHooks.OnAccountGroupUpdate(account, author, ref grp)) throw new UserGroupUpdateLockedException(account.Name); if (_database.Query("UPDATE Users SET UserGroup = @0 WHERE Username = @1;", grp.Name, account.Name) == 0) throw new UserAccountNotExistException(account.Name); try { // Update player group reference for any logged in player foreach (var player in TShock.Players.Where(p => p != null && p.Account != null && p.Account.Name == account.Name)) { player.Group = grp; } } catch (Exception ex) { throw new UserAccountManagerException(GetString("SetUserGroup SQL returned an error"), ex); } } /// Updates the last accessed time for a database user account to the current time. /// The user account object to modify. public void UpdateLogin(UserAccount account) { try { if (_database.Query("UPDATE Users SET LastAccessed = @0, KnownIps = @1 WHERE Username = @2;", DateTime.UtcNow.ToString("s"), account.KnownIps, account.Name) == 0) throw new UserAccountNotExistException(account.Name); } catch (Exception ex) { throw new UserAccountManagerException(GetString("UpdateLogin SQL returned an error"), ex); } } /// Gets the database ID of a given user account object from the database. /// The username of the user account to query for. /// The user account ID public int GetUserAccountID(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(GetString($"FetchHashedPasswordAndGroup SQL returned an error: {ex}")); } return -1; } /// Gets a user account object by name. /// The user's name. /// The user account object returned from the search. public UserAccount GetUserAccountByName(string name) { try { return GetUserAccount(new UserAccount {Name = name}); } catch (UserAccountManagerException) { return null; } } /// Gets a user account object by their user account ID. /// The user's ID. /// The user account object returned from the search. public UserAccount GetUserAccountByID(int id) { try { return GetUserAccount(new UserAccount {ID = id}); } catch (UserAccountManagerException) { return null; } } /// Gets a user account object by a user account object. /// The user account object to search by. /// The user object that is returned from the search. public UserAccount GetUserAccount(UserAccount account) { bool multiple = false; string query; string type; object arg; if (account.ID != 0) { query = "SELECT * FROM Users WHERE ID=@0"; arg = account.ID; type = "id"; } else { query = "SELECT * FROM Users WHERE Username=@0"; arg = account.Name; type = "name"; } try { using var result = _database.QueryReader(query, arg); if (result.Read()) { account = LoadUserAccountFromResult(account, result); // Check for multiple matches if (!result.Read()) return account; multiple = true; } } catch (Exception ex) { throw new UserAccountManagerException(GetString($"GetUser SQL returned an error {ex.Message}"), ex); } if (multiple) throw new UserAccountManagerException(GetString($"Multiple user accounts found for {type} '{arg}'")); throw new UserAccountNotExistException(account.Name); } /// Gets all the user accounts from the database. /// The user accounts from the database. public List GetUserAccounts() { try { List accounts = new List(); using var reader = _database.QueryReader("SELECT * FROM Users"); while (reader.Read()) { accounts.Add(LoadUserAccountFromResult(new UserAccount(), reader)); } return accounts; } catch (Exception ex) { TShock.Log.Error(ex.ToString()); } return null; } /// /// Gets all user accounts 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 GetUserAccountsByName(string username, bool notAtStart = false) { try { List accounts = 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()) { accounts.Add(LoadUserAccountFromResult(new UserAccount(), reader)); } return accounts; } catch (Exception ex) { TShock.Log.Error(ex.ToString()); } return null; } /// Fills out the fields of a User account object with the results from a QueryResult object. /// The user account to add data to. /// The QueryResult object to add data from. /// The 'filled out' user object. private UserAccount LoadUserAccountFromResult(UserAccount account, QueryResult result) { account.ID = result.Get("ID"); account.Group = result.Get("Usergroup"); account.Password = result.Get("Password"); account.UUID = result.Get("UUID"); account.Name = result.Get("Username"); account.Registered = result.Get("Registered"); account.LastAccessed = result.Get("LastAccessed"); account.KnownIps = result.Get("KnownIps"); return account; } } /// A database user account. public class UserAccount : IEquatable { /// The database ID of the user account. public int ID { get; set; } /// The user's name. public string Name { get; set; } /// The hashed password for the user account. public string Password { get; internal set; } /// The user's saved Universally Unique Identifier token. public string UUID { get; set; } /// The group object that the user account is a part of. public string Group { get; set; } /// The unix epoch corresponding to the registration date of the user account. public string Registered { get; set; } /// The unix epoch corresponding to the last access date of the user account. public string LastAccessed { get; set; } /// A JSON serialized list of known IP addresses for a user account. public string KnownIps { get; set; } /// Constructor for the user account 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 account, serialized as a JSON object /// A completed user account object. public UserAccount(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 account object; holds no data. /// A user account object. public UserAccount() { 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 account 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) { TShock.Log.ConsoleError(GetString($"Unable to verify the password hash for user {Name} ({ID})")); return false; } return false; } /// Upgrades a password to the highest work factor available in the config. /// The raw user account 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 = int.Parse((Password.Split('$')[2])); } catch (FormatException) { TShock.Log.ConsoleWarn(GetString("Not upgrading work factor because bcrypt hash in an invalid format.")); return; } if (currentWorkFactor < TShock.Config.Settings.BCryptWorkFactor) { try { TShock.UserAccounts.SetUserAccountPassword(this, password); } catch (UserAccountManagerException e) { TShock.Log.ConsoleError(e.ToString()); } } } /// Creates a BCrypt hash for a user account 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.Settings.MinimumPasswordLength)) { int minLength = TShock.Config.Settings.MinimumPasswordLength; throw new ArgumentOutOfRangeException("password", GetString($"Password must be at least {minLength} characters.")); } try { Password = BCrypt.Net.BCrypt.HashPassword(password.Trim(), TShock.Config.Settings.BCryptWorkFactor); } catch (ArgumentOutOfRangeException) { TShock.Log.ConsoleError(GetString("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 account 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.Settings.MinimumPasswordLength)) { int minLength = TShock.Config.Settings.MinimumPasswordLength; throw new ArgumentOutOfRangeException("password", GetString($"Password must be at least {minLength} characters.")); } Password = BCrypt.Net.BCrypt.HashPassword(password.Trim(), workFactor); } #region IEquatable /// Indicates whether the current is equal to another . /// true if the is equal to the parameter; otherwise, false. /// An to compare with this . public bool Equals(UserAccount other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; return ID == other.ID && string.Equals(Name, other.Name); } /// Indicates whether the current is equal to another object. /// true if the is equal to the parameter; otherwise, false. /// An to compare with this . public override bool Equals(object obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != this.GetType()) return false; return Equals((UserAccount)obj); } /// Serves as the hash function. /// A hash code for the current . public override int GetHashCode() { unchecked { return (ID * 397) ^ (Name != null ? Name.GetHashCode() : 0); } } /// /// Compares equality of two objects. /// /// Left hand of the comparison. /// Right hand of the comparison. /// true if the objects are equal; otherwise, false. public static bool operator ==(UserAccount left, UserAccount right) { return Equals(left, right); } /// /// Compares equality of two objects. /// /// Left hand of the comparison. /// Right hand of the comparison. /// true if the objects aren't equal; otherwise, false. public static bool operator !=(UserAccount left, UserAccount right) { return !Equals(left, right); } #endregion /// /// Converts the UserAccount to it's string representation /// /// Returns the UserAccount string representation public override string ToString() => Name; } /// UserAccountManagerException - An exception generated by the user account manager. [Serializable] public class UserAccountManagerException : Exception { /// Creates a new UserAccountManagerException object. /// The message for the object. /// A new UserAccountManagerException object. public UserAccountManagerException(string message) : base(message) { } /// Creates a new UserAccountManager Object with an internal exception. /// The message for the object. /// The inner exception for the object. /// A new UserAccountManagerException with a defined inner exception. public UserAccountManagerException(string message, Exception inner) : base(message, inner) { } } /// A UserExistsException object, used when a user account already exists when attempting to create a new one. [Serializable] public class UserAccountExistsException : UserAccountManagerException { /// Creates a new UserAccountExistsException object. /// The name of the user account that already exists. /// A UserAccountExistsException object with the user's name passed in the message. public UserAccountExistsException(string name) : base(GetString($"User account {name} already exists")) { } } /// A UserNotExistException, used when a user does not exist and a query failed as a result of it. [Serializable] public class UserAccountNotExistException : UserAccountManagerException { /// Creates a new UserAccountNotExistException object, with the user account name in the message. /// The user account name to be passed in the message. /// A new UserAccountNotExistException object with a message containing the user account name that does not exist. public UserAccountNotExistException(string name) : base(GetString($"User account {name} does not exist")) { } } /// The UserGroupUpdateLockedException used when the user group update failed and the request failed as a result.. [Serializable] public class UserGroupUpdateLockedException : UserAccountManagerException { /// Creates a new UserGroupUpdateLockedException object. /// The name of the user who failed to change the group. /// New UserGroupUpdateLockedException object with a message containing the name of the user account that failed to change the group. public UserGroupUpdateLockedException(string name) : base(GetString($"Unable to update group of user {name}.")) { } } /// A GroupNotExistsException, used when a group does not exist. [Serializable] public class GroupNotExistsException : UserAccountManagerException { /// 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(GetString($"Group {group} does not exist")) { } } }