/*
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"))
{
}
}
}