TShock/TShockAPI/DB/UserManager.cs
Lucas Nicodemus b15dfbc1ec
Don't catch the exceptions we throw in SetUserGroup
For some reason, we were catching our own exceptions and then
throwing UesrManagerExceptions despite the fact that we explicitly
threw more specific exceptions.

Fixes #1315.
2016-10-23 05:49:16 -06:00

646 lines
No EOL
21 KiB
C#
Executable file

/*
TShock, a server mod for Terraria
Copyright (C) 2011-2016 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 <http://www.gnu.org/licenses/>.
*/
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
{
/// <summary>UserManager - Methods for dealing with database users and other user functionality within TShock.</summary>
public class UserManager
{
/// <summary>database - The database object to use for connections.</summary>
private IDbConnection _database;
/// <summary>Creates a UserManager object. During instantiation, this method will verify the table structure against the format below.</summary>
/// <param name="db">The database to connect to.</param>
/// <returns>A UserManager object.</returns>
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);
}
/// <summary>
/// Adds a given username to the database
/// </summary>
/// <param name="user">User user</param>
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);
}
/// <summary>
/// Removes a given username from the database
/// </summary>
/// <param name="user">User user</param>
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);
}
}
/// <summary>
/// Sets the Hashed Password for a given username
/// </summary>
/// <param name="user">User user</param>
/// <param name="password">string password</param>
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);
}
}
/// <summary>
/// Sets the UUID for a given username
/// </summary>
/// <param name="user">User user</param>
/// <param name="uuid">string uuid</param>
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);
}
}
/// <summary>
/// Sets the group for a given username
/// </summary>
/// <param name="user">User user</param>
/// <param name="group">string group</param>
public void SetUserGroup(User user, string group)
{
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);
try
{
// Update player group reference for any logged in player
foreach (var player in TShock.Players.Where(p => p != null && p.User != null && p.User.Name == user.Name))
{
player.Group = grp;
}
}
catch (Exception ex)
{
throw new UserManagerException("SetUserGroup SQL returned an error", ex);
}
}
/// <summary>Updates the last accessed time for a database user to the current time.</summary>
/// <param name="user">The user object to modify.</param>
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);
}
}
/// <summary>Gets the database ID of a given user object from the database.</summary>
/// <param name="username">The username of the user to query for.</param>
/// <returns>The user's ID</returns>
public int GetUserID(string username)
{
try
{
using (var reader = _database.QueryReader("SELECT * FROM Users WHERE Username=@0", username))
{
if (reader.Read())
{
return reader.Get<int>("ID");
}
}
}
catch (Exception ex)
{
TShock.Log.ConsoleError("FetchHashedPasswordAndGroup SQL returned an error: " + ex);
}
return -1;
}
/// <summary>Gets a user object by name.</summary>
/// <param name="name">The user's name.</param>
/// <returns>The user object returned from the search.</returns>
public User GetUserByName(string name)
{
try
{
return GetUser(new User {Name = name});
}
catch (UserManagerException)
{
return null;
}
}
/// <summary>Gets a user object by their user ID.</summary>
/// <param name="id">The user's ID.</param>
/// <returns>The user object returned from the search.</returns>
public User GetUserByID(int id)
{
try
{
return GetUser(new User {ID = id});
}
catch (UserManagerException)
{
return null;
}
}
/// <summary>Gets a user object by a user object.</summary>
/// <param name="user">The user object to search by.</param>
/// <returns>The user object that is returned from the search.</returns>
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);
}
/// <summary>Gets all users from the database.</summary>
/// <returns>The users from the database.</returns>
public List<User> GetUsers()
{
try
{
List<User> users = new List<User>();
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;
}
/// <summary>
/// Gets all users from the database with a username that starts with or contains <see cref="username"/>
/// </summary>
/// <param name="username">Rough username search. "n" will match "n", "na", "nam", "name", etc</param>
/// <param name="notAtStart">If <see cref="username"/> is not the first part of the username. If true then "name" would match "name", "username", "wordsnamewords", etc</param>
/// <returns>Matching users or null if exception is thrown</returns>
public List<User> GetUsersByName(string username, bool notAtStart = false)
{
try
{
List<User> users = new List<User>();
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;
}
/// <summary>Fills out the fields of a User object with the results from a QueryResult object.</summary>
/// <param name="user">The user to add data to.</param>
/// <param name="result">The QueryResult object to add data from.</param>
/// <returns>The 'filled out' user object.</returns>
private User LoadUserFromResult(User user, QueryResult result)
{
user.ID = result.Get<int>("ID");
user.Group = result.Get<string>("Usergroup");
user.Password = result.Get<string>("Password");
user.UUID = result.Get<string>("UUID");
user.Name = result.Get<string>("Username");
user.Registered = result.Get<string>("Registered");
user.LastAccessed = result.Get<string>("LastAccessed");
user.KnownIps = result.Get<string>("KnownIps");
return user;
}
}
/// <summary>A database user.</summary>
public class User
{
/// <summary>The database ID of the user.</summary>
public int ID { get; set; }
/// <summary>The user's name.</summary>
public string Name { get; set; }
/// <summary>The hashed password for the user.</summary>
public string Password { get; internal set; }
/// <summary>The user's saved Univerally Unique Identifier token.</summary>
public string UUID { get; set; }
/// <summary>The group object that the user is a part of.</summary>
public string Group { get; set; }
/// <summary>The unix epoch corresponding to the registration date of the user.</summary>
public string Registered { get; set; }
/// <summary>The unix epoch corresponding to the last access date of the user.</summary>
public string LastAccessed { get; set; }
/// <summary>A JSON serialized list of known IP addresses for a user.</summary>
public string KnownIps { get; set; }
/// <summary>Constructor for the user object, assuming you define everything yourself.</summary>
/// <param name="name">The user's name.</param>
/// <param name="pass">The user's password hash.</param>
/// <param name="uuid">The user's UUID.</param>
/// <param name="group">The user's group name.</param>
/// <param name="registered">The unix epoch for the registration date.</param>
/// <param name="last">The unix epoch for the last access date.</param>
/// <param name="known">The known IPs for the user, serialized as a JSON object</param>
/// <returns>A completed user object.</returns>
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;
}
/// <summary>Default constructor for a user object; holds no data.</summary>
/// <returns>A user object.</returns>
public User()
{
Name = "";
Password = "";
UUID = "";
Group = "";
Registered = "";
LastAccessed = "";
KnownIps = "";
}
/// <summary>
/// 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.
/// </summary>
/// <param name="password">The password to check against the user object.</param>
/// <returns>bool true, if the password matched, or false, if it didn't.</returns>
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;
}
/// <summary>Upgrades a password to BCrypt, from an insecure hashing algorithm.</summary>
/// <param name="password">The raw user password (unhashed) to upgrade</param>
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
}
}
/// <summary>Upgrades a password to the highest work factor available in the config.</summary>
/// <param name="password">The raw user password (unhashed) to upgrade</param>
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());
}
}
}
/// <summary>Creates a BCrypt hash for a user and stores it in this object.</summary>
/// <param name="password">The plain text password to hash</param>
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());
}
}
/// <summary>Creates a BCrypt hash for a user and stores it in this object.</summary>
/// <param name="password">The plain text password to hash</param>
/// <param name="workFactor">The work factor to use in generating the password hash</param>
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);
}
/// <summary>
/// A dictionary of hashing algorithms and an implementation object.
/// </summary>
protected readonly Dictionary<string, Func<HashAlgorithm>> HashTypes = new Dictionary<string, Func<HashAlgorithm>>
{
{"sha512", () => new SHA512Managed()},
{"sha256", () => new SHA256Managed()},
{"md5", () => new MD5Cng()},
{"sha512-xp", () => SHA512.Create()},
{"sha256-xp", () => SHA256.Create()},
{"md5-xp", () => MD5.Create()},
};
/// <summary>
/// Returns a hashed string for a given string based on the config file's hash algo
/// </summary>
/// <param name="bytes">bytes to hash</param>
/// <returns>string hash</returns>
protected string HashPassword(byte[] bytes)
{
if (bytes == null)
throw new NullReferenceException("bytes");
Func<HashAlgorithm> 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"));
}
}
/// <summary>
/// Returns a hashed string for a given string based on the config file's hash algo
/// </summary>
/// <param name="password">string to hash</param>
/// <returns>string hash</returns>
protected string HashPassword(string password)
{
if (string.IsNullOrEmpty(password) && Password == "non-existant password")
return "non-existant password";
return HashPassword(Encoding.UTF8.GetBytes(password));
}
}
/// <summary>UserManagerException - An exception generated by the user manager.</summary>
[Serializable]
public class UserManagerException : Exception
{
/// <summary>Creates a new UserManagerException object.</summary>
/// <param name="message">The message for the object.</param>
/// <returns>A new UserManagerException object.</returns>
public UserManagerException(string message)
: base(message)
{
}
/// <summary>Creates a new UserManagerObject with an internal exception.</summary>
/// <param name="message">The message for the object.</param>
/// <param name="inner">The inner exception for the object.</param>
/// <returns>A new UserManagerException with a defined inner exception.</returns>
public UserManagerException(string message, Exception inner)
: base(message, inner)
{
}
}
/// <summary>A UserExistsException object, used when a user already exists when attempting to create a new one.</summary>
[Serializable]
public class UserExistsException : UserManagerException
{
/// <summary>Creates a new UserExistsException object.</summary>
/// <param name="name">The name of the user that already exists.</param>
/// <returns>A UserExistsException object with the user's name passed in the message.</returns>
public UserExistsException(string name)
: base("User '" + name + "' already exists")
{
}
}
/// <summary>A UserNotExistException, used when a user does not exist and a query failed as a result of it.</summary>
[Serializable]
public class UserNotExistException : UserManagerException
{
/// <summary>Creates a new UserNotExistException object, with the user's name in the message.</summary>
/// <param name="name">The user's name to be pasesd in the message.</param>
/// <returns>A new UserNotExistException object with a message containing the user's name that does not exist.</returns>
public UserNotExistException(string name)
: base("User '" + name + "' does not exist")
{
}
}
/// <summary>A GroupNotExistsException, used when a group does not exist.</summary>
[Serializable]
public class GroupNotExistsException : UserManagerException
{
/// <summary>Creates a new GroupNotExistsException object with the group's name in the message.</summary>
/// <param name="group">The group name.</param>
/// <returns>A new GroupNotExistsException with the group that does not exist's name in the message.</returns>
public GroupNotExistsException(string group)
: base("Group '" + group + "' does not exist")
{
}
}
}