// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using EpicGames.Core;
using EpicGames.Horde.Accounts;
using HordeServer.Acls;
using HordeServer.Server;
using HordeServer.Users;
using HordeServer.Utilities;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
namespace HordeServer.Accounts
{
///
/// Password hasher
///
public static class PasswordHasher
{
private const int SaltSize = 16; // 128 bit
private const int KeySize = 32; // 256 bit
private const int Iterations = 100000; // Number of iterations
///
/// Generate a new salt for use with password hash
///
/// A salt
public static byte[] GenerateSalt()
{
using RandomNumberGenerator rng = RandomNumberGenerator.Create();
byte[] salt = new byte[SaltSize];
rng.GetBytes(salt);
return salt;
}
///
/// Create a hash for the given password
///
/// Clear text password
/// Salt to hash with
///
public static byte[] HashPassword(string password, byte[] salt)
{
using Rfc2898DeriveBytes rfc2898DeriveBytes = new(password, salt, Iterations, HashAlgorithmName.SHA256);
return rfc2898DeriveBytes.GetBytes(KeySize);
}
///
/// Validate a password
///
/// Clear text password
/// Salt to hash with
/// Correct hash
/// True if password matches correct hash
public static bool ValidatePassword(string password, byte[] salt, byte[] correctHash)
{
byte[] hash = HashPassword(password, salt);
return hash.SequenceEqual(correctHash);
}
///
/// Convert a salt string to byte array
///
/// Salt stored as a hex string
/// A byte array representation
/// If salt is invalid
public static byte[] SaltFromString(string? saltString)
{
byte[] salt = Convert.FromHexString(saltString ?? "");
if (salt.Length != 16)
{
throw new ArgumentException($"Invalid salt length: {salt.Length}");
}
return salt;
}
///
/// Convert a hash string to byte array
///
/// Hash stored as a hex string
/// A byte array representation
/// If hash is invalid
public static byte[] HashFromString(string? hashString)
{
byte[] hash = Convert.FromHexString(hashString ?? "");
if (hash.Length != 32)
{
throw new ArgumentException($"Invalid hash length: {hash.Length}");
}
return hash;
}
}
///
/// Collection of service account documents
///
public class AccountCollection : IAccountCollection
{
record class ClaimDocument(string Type, string Value) : IUserClaim
{
public ClaimDocument(IUserClaim claim) : this(claim.Type, claim.Value)
{ }
public ClaimDocument(AclClaimConfig claim) : this(claim.Type, claim.Value)
{ }
}
///
/// Concrete implementation of IHordeAccount
///
private class AccountDocument : IAccount
{
[BsonIgnore]
AccountCollection? _accountCollection;
///
[BsonRequired, BsonId]
public AccountId Id { get; set; }
///
public string Name { get; set; } = "";
///
public string Login { get; set; } = "";
///
public string NormalizedLogin { get; set; } = "";
///
public string? Email { get; set; }
///
public string? PasswordHash { get; set; }
///
public string? PasswordSalt { get; set; }
[BsonElement("Claims2")]
public List Claims { get; set; } = new List();
///
public bool Enabled { get; set; }
///
public string Description { get; set; } = "";
///
public string SessionKey { get; set; } = "";
[BsonIgnoreIfDefault, BsonDefaultValue(0)]
public int UpdateIndex { get; set; }
IReadOnlyList IAccount.Claims => Claims;
[BsonConstructor]
private AccountDocument()
{
}
public AccountDocument(AccountId id, string name, string login)
{
Id = id;
Name = name;
Login = login;
NormalizedLogin = NormalizeLogin(login);
}
public void PostLoad(AccountCollection accountCollection)
{
_accountCollection = accountCollection;
}
public bool ValidatePassword(string password)
{
if (String.IsNullOrEmpty(PasswordHash))
{
return String.IsNullOrEmpty(password);
}
else
{
return PasswordSalt != null && PasswordHasher.ValidatePassword(password, PasswordHasher.SaltFromString(PasswordSalt), PasswordHasher.HashFromString(PasswordHash));
}
}
public async Task RefreshAsync(CancellationToken cancellationToken)
=> await _accountCollection!.GetAsync(Id, cancellationToken);
public async Task TryUpdateAsync(UpdateAccountOptions options, CancellationToken cancellationToken)
=> await _accountCollection!.TryUpdateAsync(this, options, cancellationToken);
}
const int DuplicateKeyErrorCode = 11000;
private static readonly AccountId s_defaultAdminAccountId = AccountId.Parse("65d4f282ff286703e0609ccd");
private bool _hasCreatedAdminAccount = false;
private readonly IMongoCollection _accounts;
static string NormalizeLogin(string login)
=> login.ToUpperInvariant();
///
/// Constructor
///
/// The database service
public AccountCollection(IMongoService mongoService)
{
_accounts = mongoService.GetCollection("Accounts", keys => keys.Ascending(x => x.NormalizedLogin), unique: true);
}
///
public async Task CreateAsync(
CreateAccountOptions options,
CancellationToken cancellationToken = default)
{
AccountDocument account = new(new AccountId(BinaryIdUtils.CreateNew()), options.Name, options.Login)
{
Email = options.Email,
Description = options.Description ?? "",
Enabled = options.Enabled ?? true
};
if (options.Claims != null)
{
account.Claims = options.Claims.ConvertAll(x => new ClaimDocument(x));
}
if (options.Password != null)
{
(string passwordSalt, string passwordHash) = CreateSaltAndHashPassword(options.Password);
account.PasswordSalt = passwordSalt;
account.PasswordHash = passwordHash;
}
try
{
await _accounts.InsertOneAsync(account, (InsertOneOptions?)null, cancellationToken);
}
catch (MongoWriteException ex) when (ex.WriteError.Code == DuplicateKeyErrorCode)
{
throw new LoginAlreadyTakenException(options.Login);
}
account.PostLoad(this);
return account;
}
///
public async ValueTask CreateAdminAccountAsync(string password, CancellationToken cancellationToken)
{
if (!_hasCreatedAdminAccount)
{
(string passwordSalt, string passwordHash) = CreateSaltAndHashPassword(password);
const string AdminLogin = "Admin";
UpdateDefinition update = Builders.Update
.SetOnInsert(x => x.Name, "Admin")
.SetOnInsert(x => x.Login, AdminLogin)
.SetOnInsert(x => x.NormalizedLogin, NormalizeLogin(AdminLogin))
.SetOnInsert(x => x.Description, "Default administrator account")
.SetOnInsert(x => x.Claims, new List { new ClaimDocument(HordeClaims.AdminClaim) })
.SetOnInsert(x => x.PasswordSalt, passwordSalt)
.SetOnInsert(x => x.PasswordHash, passwordHash)
.SetOnInsert(x => x.Enabled, true);
await _accounts.UpdateOneAsync(x => x.Id == s_defaultAdminAccountId, update, new UpdateOptions { IsUpsert = true }, cancellationToken);
_hasCreatedAdminAccount = true;
}
}
///
public async Task> FindAsync(int? index = null, int? count = null, CancellationToken cancellationToken = default)
{
List accounts = new List();
await foreach (AccountDocument account in _accounts.Find(FilterDefinition.Empty).Range(index, count).ToAsyncEnumerable(cancellationToken))
{
account.PostLoad(this);
accounts.Add(account);
}
return accounts;
}
///
public async Task GetAsync(AccountId id, CancellationToken cancellationToken = default)
{
AccountDocument? account = await _accounts.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken);
account?.PostLoad(this);
return account;
}
///
public async Task FindByLoginAsync(string login, CancellationToken cancellationToken = default)
{
string normalizedLogin = NormalizeLogin(login);
AccountDocument? account = await _accounts.Find(x => x.NormalizedLogin == normalizedLogin).FirstOrDefaultAsync(cancellationToken);
account?.PostLoad(this);
return account;
}
///
async Task TryUpdateAsync(AccountDocument document, UpdateAccountOptions options, CancellationToken cancellationToken = default)
{
UpdateDefinition update = Builders.Update.Set(x => x.UpdateIndex, document.UpdateIndex + 1);
if (options.Name != null)
{
update = update.Set(x => x.Name, options.Name);
}
if (options.Login != null)
{
update = update.Set(x => x.Login, options.Login).Set(x => x.NormalizedLogin, NormalizeLogin(options.Login));
}
if (options.Email != null)
{
update = update.Set(x => x.Email, options.Email);
}
if (options.Password != null)
{
(string salt, string hash) = CreateSaltAndHashPassword(options.Password);
update = update.Set(x => x.PasswordSalt, salt).Set(x => x.PasswordHash, hash);
}
if (options.Claims != null)
{
update = update.Set(x => x.Claims, options.Claims.ConvertAll(x => new ClaimDocument(x)));
}
if (options.Enabled != null)
{
update = update.Set(x => x.Enabled, options.Enabled);
}
if (options.Description != null)
{
update = update.Set(x => x.Description, options.Description);
}
if (options.SessionKey != null)
{
update = update.Set(x => x.SessionKey, options.SessionKey);
}
FilterDefinition filter;
if (document.UpdateIndex == 0)
{
filter = Builders.Filter.Eq(x => x.Id, document.Id) & Builders.Filter.Exists(x => x.UpdateIndex, false);
}
else
{
filter = Builders.Filter.Eq(x => x.Id, document.Id) & Builders.Filter.Eq(x => x.UpdateIndex, document.UpdateIndex);
}
try
{
return await _accounts.FindOneAndUpdateAsync(filter, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }, cancellationToken);
}
catch (MongoCommandException ex) when (options.Login != null && ex.Code == DuplicateKeyErrorCode)
{
throw new LoginAlreadyTakenException(options.Login);
}
}
///
public Task DeleteAsync(AccountId id, CancellationToken cancellationToken = default)
{
if (id == s_defaultAdminAccountId)
{
throw new InvalidOperationException("The default administrator account cannot be deleted.");
}
return _accounts.DeleteOneAsync(x => x.Id == id, cancellationToken);
}
static (string Salt, string Hash) CreateSaltAndHashPassword(string password)
{
byte[] salt = PasswordHasher.GenerateSalt();
byte[] hashedPassword = PasswordHasher.HashPassword(password, salt);
return (Convert.ToHexString(salt), Convert.ToHexString(hashedPassword));
}
}
///
/// Exception thrown when a user name is already taken
///
public class LoginAlreadyTakenException : Exception
{
internal LoginAlreadyTakenException(string name)
: base($"The login '{name}' is already taken")
{
}
}
}