Files
UnrealEngine/Engine/Source/Programs/Horde/HordeServer/Accounts/AccountCollection.cs
2025-05-18 13:04:45 +08:00

395 lines
12 KiB
C#

// 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
{
/// <summary>
/// Password hasher
/// </summary>
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
/// <summary>
/// Generate a new salt for use with password hash
/// </summary>
/// <returns>A salt</returns>
public static byte[] GenerateSalt()
{
using RandomNumberGenerator rng = RandomNumberGenerator.Create();
byte[] salt = new byte[SaltSize];
rng.GetBytes(salt);
return salt;
}
/// <summary>
/// Create a hash for the given password
/// </summary>
/// <param name="password">Clear text password</param>
/// <param name="salt">Salt to hash with</param>
/// <returns></returns>
public static byte[] HashPassword(string password, byte[] salt)
{
using Rfc2898DeriveBytes rfc2898DeriveBytes = new(password, salt, Iterations, HashAlgorithmName.SHA256);
return rfc2898DeriveBytes.GetBytes(KeySize);
}
/// <summary>
/// Validate a password
/// </summary>
/// <param name="password">Clear text password</param>
/// <param name="salt">Salt to hash with</param>
/// <param name="correctHash">Correct hash</param>
/// <returns>True if password matches correct hash</returns>
public static bool ValidatePassword(string password, byte[] salt, byte[] correctHash)
{
byte[] hash = HashPassword(password, salt);
return hash.SequenceEqual(correctHash);
}
/// <summary>
/// Convert a salt string to byte array
/// </summary>
/// <param name="saltString">Salt stored as a hex string</param>
/// <returns>A byte array representation</returns>
/// <exception cref="ArgumentException">If salt is invalid</exception>
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;
}
/// <summary>
/// Convert a hash string to byte array
/// </summary>
/// <param name="hashString">Hash stored as a hex string</param>
/// <returns>A byte array representation</returns>
/// <exception cref="ArgumentException">If hash is invalid</exception>
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;
}
}
/// <summary>
/// Collection of service account documents
/// </summary>
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)
{ }
}
/// <summary>
/// Concrete implementation of IHordeAccount
/// </summary>
private class AccountDocument : IAccount
{
[BsonIgnore]
AccountCollection? _accountCollection;
/// <inheritdoc/>
[BsonRequired, BsonId]
public AccountId Id { get; set; }
/// <inheritdoc/>
public string Name { get; set; } = "";
/// <inheritdoc/>
public string Login { get; set; } = "";
/// <inheritdoc/>
public string NormalizedLogin { get; set; } = "";
/// <inheritdoc/>
public string? Email { get; set; }
/// <inheritdoc/>
public string? PasswordHash { get; set; }
/// <inheritdoc/>
public string? PasswordSalt { get; set; }
[BsonElement("Claims2")]
public List<ClaimDocument> Claims { get; set; } = new List<ClaimDocument>();
/// <inheritdoc/>
public bool Enabled { get; set; }
/// <inheritdoc/>
public string Description { get; set; } = "";
/// <inheritdoc/>
public string SessionKey { get; set; } = "";
[BsonIgnoreIfDefault, BsonDefaultValue(0)]
public int UpdateIndex { get; set; }
IReadOnlyList<IUserClaim> 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<IAccount?> RefreshAsync(CancellationToken cancellationToken)
=> await _accountCollection!.GetAsync(Id, cancellationToken);
public async Task<IAccount?> 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<AccountDocument> _accounts;
static string NormalizeLogin(string login)
=> login.ToUpperInvariant();
/// <summary>
/// Constructor
/// </summary>
/// <param name="mongoService">The database service</param>
public AccountCollection(IMongoService mongoService)
{
_accounts = mongoService.GetCollection<AccountDocument>("Accounts", keys => keys.Ascending(x => x.NormalizedLogin), unique: true);
}
/// <inheritdoc/>
public async Task<IAccount> 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;
}
/// <inheritdoc/>
public async ValueTask CreateAdminAccountAsync(string password, CancellationToken cancellationToken)
{
if (!_hasCreatedAdminAccount)
{
(string passwordSalt, string passwordHash) = CreateSaltAndHashPassword(password);
const string AdminLogin = "Admin";
UpdateDefinition<AccountDocument> update = Builders<AccountDocument>.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<ClaimDocument> { 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;
}
}
/// <inheritdoc/>
public async Task<IReadOnlyList<IAccount>> FindAsync(int? index = null, int? count = null, CancellationToken cancellationToken = default)
{
List<IAccount> accounts = new List<IAccount>();
await foreach (AccountDocument account in _accounts.Find(FilterDefinition<AccountDocument>.Empty).Range(index, count).ToAsyncEnumerable(cancellationToken))
{
account.PostLoad(this);
accounts.Add(account);
}
return accounts;
}
/// <inheritdoc/>
public async Task<IAccount?> GetAsync(AccountId id, CancellationToken cancellationToken = default)
{
AccountDocument? account = await _accounts.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken);
account?.PostLoad(this);
return account;
}
/// <inheritdoc/>
public async Task<IAccount?> 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;
}
/// <inheritdoc/>
async Task<AccountDocument?> TryUpdateAsync(AccountDocument document, UpdateAccountOptions options, CancellationToken cancellationToken = default)
{
UpdateDefinition<AccountDocument> update = Builders<AccountDocument>.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<AccountDocument> filter;
if (document.UpdateIndex == 0)
{
filter = Builders<AccountDocument>.Filter.Eq(x => x.Id, document.Id) & Builders<AccountDocument>.Filter.Exists(x => x.UpdateIndex, false);
}
else
{
filter = Builders<AccountDocument>.Filter.Eq(x => x.Id, document.Id) & Builders<AccountDocument>.Filter.Eq(x => x.UpdateIndex, document.UpdateIndex);
}
try
{
return await _accounts.FindOneAndUpdateAsync(filter, update, new FindOneAndUpdateOptions<AccountDocument, AccountDocument?> { ReturnDocument = ReturnDocument.After }, cancellationToken);
}
catch (MongoCommandException ex) when (options.Login != null && ex.Code == DuplicateKeyErrorCode)
{
throw new LoginAlreadyTakenException(options.Login);
}
}
/// <inheritdoc/>
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));
}
}
/// <summary>
/// Exception thrown when a user name is already taken
/// </summary>
public class LoginAlreadyTakenException : Exception
{
internal LoginAlreadyTakenException(string name)
: base($"The login '{name}' is already taken")
{
}
}
}