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