diff --git a/Nebula.Launcher/Converters/TypeConverters.cs b/Nebula.Launcher/Converters/TypeConverters.cs index 962247c..05c65c4 100644 --- a/Nebula.Launcher/Converters/TypeConverters.cs +++ b/Nebula.Launcher/Converters/TypeConverters.cs @@ -25,4 +25,7 @@ public static class TypeConverters public static FuncValueConverter NameColorRepresentation { get; } = new((str)=>ColorUtils.GetColorFromString(str ?? throw new ArgumentNullException(nameof(str),"Name of color is null!"))); + + public static FuncValueConverter StringIsNotEmpty { get; } = + new(iconKey => !string.IsNullOrEmpty(iconKey)); } \ No newline at end of file diff --git a/Nebula.Launcher/LauncherConVar.cs b/Nebula.Launcher/LauncherConVar.cs index d5a869a..5bcb4cc 100644 --- a/Nebula.Launcher/LauncherConVar.cs +++ b/Nebula.Launcher/LauncherConVar.cs @@ -14,10 +14,11 @@ public static class LauncherConVar public static readonly ConVar DoMigration = ConVarBuilder.Build("migration.doMigrate", true); - public static readonly ConVar AuthProfiles = - ConVarBuilder.BuildWithMigration("auth.profiles.v3", + public static readonly ConVar AuthProfiles = + ConVarBuilder.BuildWithMigration("auth.profiles.v4", MigrationQueueBuilder.Instance - .With(new ProfileMigrationV2("auth.profiles.v2","auth.profiles.v3")) + .With(new ProfileMigrationV2("auth.profiles.v2","auth.profiles.v4")) + .With(new ProfileMigrationV3V4("auth.profiles.v3","auth.profiles.v4")) .Build(), []); diff --git a/Nebula.Launcher/Models/Auth/ProfileAuthCredentials.cs b/Nebula.Launcher/Models/Auth/ProfileAuthCredentials.cs index ca0405e..64d914f 100644 --- a/Nebula.Launcher/Models/Auth/ProfileAuthCredentials.cs +++ b/Nebula.Launcher/Models/Auth/ProfileAuthCredentials.cs @@ -5,8 +5,8 @@ using Nebula.Shared.Services; namespace Nebula.Launcher.Models.Auth; -public sealed record ProfileAuthCredentials( - AuthTokenCredentials Credentials, +public sealed record ProfileEntry( + ProfileAuthCredentials Credentials, string AuthName, [property: JsonIgnore] ICommand OnSelect = default!, [property: JsonIgnore] ICommand OnDelete = default!); \ No newline at end of file diff --git a/Nebula.Launcher/ServerListProviders/HubServerListProvider.cs b/Nebula.Launcher/ServerListProviders/HubServerListProvider.cs index 6affa62..a26f075 100644 --- a/Nebula.Launcher/ServerListProviders/HubServerListProvider.cs +++ b/Nebula.Launcher/ServerListProviders/HubServerListProvider.cs @@ -74,6 +74,7 @@ public sealed partial class HubServerListProvider : IServerListProvider catch (Exception e) { _errors.Add(new Exception($"Some error while loading server list from {HubUrl}. See inner exception", e)); + _errors.Add(e); } IsLoaded = true; diff --git a/Nebula.Launcher/ViewModels/MainViewModel.cs b/Nebula.Launcher/ViewModels/MainViewModel.cs index b09f8ce..3e20760 100644 --- a/Nebula.Launcher/ViewModels/MainViewModel.cs +++ b/Nebula.Launcher/ViewModels/MainViewModel.cs @@ -114,7 +114,7 @@ public partial class MainViewModel : ViewModelBase { "login", AccountInfoViewModel.Credentials.Value?.Login ?? "" }, { "auth_server", - AccountInfoViewModel.GetServerAuthName(AccountInfoViewModel.Credentials.Value) ?? "" + AccountInfoViewModel.GetServerAuthName(AccountInfoViewModel.Credentials.Value?.AuthServer) ?? "" } }); } diff --git a/Nebula.Launcher/ViewModels/Pages/AccountInfoViewModel.cs b/Nebula.Launcher/ViewModels/Pages/AccountInfoViewModel.cs index 745ab1a..e4f018a 100644 --- a/Nebula.Launcher/ViewModels/Pages/AccountInfoViewModel.cs +++ b/Nebula.Launcher/ViewModels/Pages/AccountInfoViewModel.cs @@ -34,13 +34,13 @@ public partial class AccountInfoViewModel : ViewModelBase [ObservableProperty] private AuthServerCredentials _authItemSelect; private bool _isProfilesEmpty; - [GenerateProperty] private PopupMessageService PopupMessageService { get; } = default!; - [GenerateProperty] private ConfigurationService ConfigurationService { get; } = default!; + [GenerateProperty] private PopupMessageService PopupMessageService { get; } + [GenerateProperty] private ConfigurationService ConfigurationService { get; } [GenerateProperty] private DebugService DebugService { get; } - [GenerateProperty] private AuthService AuthService { get; } = default!; - [GenerateProperty, DesignConstruct] private ViewHelperService ViewHelperService { get; } = default!; + [GenerateProperty] private AuthService AuthService { get; } + [GenerateProperty, DesignConstruct] private ViewHelperService ViewHelperService { get; } - public ObservableCollection Accounts { get; } = new(); + public ObservableCollection Accounts { get; } = new(); public ObservableCollection AuthUrls { get; } = new(); public ComplexConVarBinder Credentials { get; private set; } @@ -50,10 +50,10 @@ public partial class AccountInfoViewModel : ViewModelBase //Design think protected override void InitialiseInDesignMode() { - AuthUrls.Add(new AuthServerCredentials("Test",["example.com"])); + AuthUrls.Add(new AuthServerCredentials("Test",["example.com","variant.lab"])); - AddAccount(new AuthTokenCredentials(Guid.Empty, LoginToken.Empty, "Binka", "example.com")); - AddAccount(new AuthTokenCredentials(Guid.Empty, LoginToken.Empty, "Binka", "")); + AddAccount(new ProfileAuthCredentials("Binka", "","example.com")); + AddAccount(new ProfileAuthCredentials("Vilka","", "variant.lab")); } //Real think @@ -197,21 +197,16 @@ public partial class AccountInfoViewModel : ViewModelBase } } - private void OnTfaEntered(string code) - { - DoAuth(code); - } - public void Logout() { Credentials.Value = null; CurrentAuthServer = ""; } - public string GetServerAuthName(AuthTokenCredentials? credentials) + public string GetServerAuthName(string? url) { - if (credentials is null) return ""; - return AuthUrls.FirstOrDefault(p => p.Servers.Contains(credentials.AuthServer))?.Name ?? "CustomAuth"; + if (url is null) return ""; + return AuthUrls.FirstOrDefault(p => p.Servers.Contains(url))?.Name ?? "CustomAuth"; } private void UpdateAuthMenu() @@ -222,14 +217,20 @@ public partial class AccountInfoViewModel : ViewModelBase AuthViewSpan = 1; } - private void AddAccount(AuthTokenCredentials credentials) + private void AddAccount(ProfileAuthCredentials credentials) { - var onDelete = new DelegateCommand(OnDeleteProfile); - var onSelect = new DelegateCommand((p) => Credentials.Value = p.Credentials); + var onDelete = new DelegateCommand(OnDeleteProfile); + var onSelect = new DelegateCommand((p) => + { + CurrentLogin = p.Credentials.Login; + CurrentPassword = p.Credentials.Password; + CurrentAuthServer = p.Credentials.AuthServer; + DoAuth(); + }); - var serverName = GetServerAuthName(credentials); + var serverName = GetServerAuthName(credentials.AuthServer); - var alpm = new ProfileAuthCredentials( + var alpm = new ProfileEntry( credentials, serverName, onSelect, @@ -255,22 +256,28 @@ public partial class AccountInfoViewModel : ViewModelBase foreach (var url in authUrls) AuthUrls.Add(url); if(authUrls.Length > 0) AuthItemSelect = authUrls[0]; - var profileCandidates = new List(); + var profileCandidates = new List(); - foreach (var profile in + foreach (var profileRaw in ConfigurationService.GetConfigValue(LauncherConVar.AuthProfiles)!) { - _logger.Log($"Reading profile {profile.Login}"); - var checkedCredit = await CheckOrRenewToken(profile); - if(checkedCredit is null) + _logger.Log($"Decrypting profile..."); + try { - _logger.Error($"Profile {profile.Login} is not available"); - continue; - } + var decoded = + await CryptographicStore.Decrypt(profileRaw, + CryptographicStore.GetComputerKey()); - _logger.Log($"Profile {profile.Login} is available"); - profileCandidates.Add(checkedCredit); - AddAccount(checkedCredit); + _logger.Log($"Decrypted profile: {decoded.Login}"); + + profileCandidates.Add(profileRaw); + AddAccount(decoded); + } + catch (Exception e) + { + _logger.Error("Error while decrypting profile"); + _logger.Error(e); + } } ConfigurationService.SetConfigValue(LauncherConVar.AuthProfiles, profileCandidates.ToArray()); @@ -297,14 +304,17 @@ public partial class AccountInfoViewModel : ViewModelBase _logger.Log("Token " + authTokenCredentials.Login + " is active, "+daysLeft+" days left, undo renewing!"); return authTokenCredentials; } - + try { _logger.Log($"Renewing token for {authTokenCredentials.Login}"); - return await ExceptionHelper.TryRun(() => AuthService.Refresh(authTokenCredentials),3, (attempt, e) => - { - _logger.Error(new Exception("Error while renewing, attempts: " + attempt, e)); - }); + return await ExceptionHelper.TryRun(() => AuthService.Refresh(authTokenCredentials), 3, + (attempt, e) => { _logger.Error(new Exception("Error while renewing, attempts: " + attempt, e)); }); + } + catch (AuthTokenExpiredException e) + { + _logger.Error(e); + return null; } catch (Exception e) { @@ -316,15 +326,16 @@ public partial class AccountInfoViewModel : ViewModelBase public void OnSaveProfile() { - if(Credentials.Value is null) return; + if(Credentials.Value is null || + string.IsNullOrEmpty(CurrentPassword)) return; - AddAccount(Credentials.Value); + AddAccount(new ProfileAuthCredentials(CurrentLogin, CurrentPassword, Credentials.Value.AuthServer)); _isProfilesEmpty = Accounts.Count == 0; UpdateAuthMenu(); DirtyProfile(); } - private void OnDeleteProfile(ProfileAuthCredentials account) + private void OnDeleteProfile(ProfileEntry account) { Accounts.Remove(account); _isProfilesEmpty = Accounts.Count == 0; @@ -357,7 +368,7 @@ public partial class AccountInfoViewModel : ViewModelBase private void DirtyProfile() { ConfigurationService.SetConfigValue(LauncherConVar.AuthProfiles, - Accounts.Select(a => a.Credentials).ToArray()); + Accounts.Select(a => CryptographicStore.Encrypt(a.Credentials, CryptographicStore.GetComputerKey())).ToArray()); } public sealed class AuthTokenCredentialsVar(AccountInfoViewModel accountInfoViewModel) diff --git a/Nebula.Launcher/Views/Pages/AccountInfoView.axaml b/Nebula.Launcher/Views/Pages/AccountInfoView.axaml index 1882b9b..5aa3af0 100644 --- a/Nebula.Launcher/Views/Pages/AccountInfoView.axaml +++ b/Nebula.Launcher/Views/Pages/AccountInfoView.axaml @@ -39,7 +39,7 @@ ItemsSource="{Binding Accounts}" Padding="0"> - + - + diff --git a/Nebula.Shared/ConfigMigrations/ProfileMigration.cs b/Nebula.Shared/ConfigMigrations/ProfileMigration.cs index 8ac9ffe..84e6abe 100644 --- a/Nebula.Shared/ConfigMigrations/ProfileMigration.cs +++ b/Nebula.Shared/ConfigMigrations/ProfileMigration.cs @@ -2,16 +2,17 @@ using Microsoft.Extensions.DependencyInjection; using Nebula.Shared.Configurations.Migrations; using Nebula.Shared.Models; using Nebula.Shared.Services; +using Nebula.Shared.Utils; namespace Nebula.Shared.ConfigMigrations; public class ProfileMigrationV2(string oldName, string newName) - : BaseConfigurationMigration(oldName, newName) + : BaseConfigurationMigration(oldName, newName) { - protected override async Task Migrate(IServiceProvider serviceProvider, ProfileAuthCredentialsV2[] oldValue, ILoadingHandler loadingHandler) + protected override async Task Migrate(IServiceProvider serviceProvider, ProfileAuthCredentials[] oldValue, ILoadingHandler loadingHandler) { - loadingHandler.SetLoadingMessage("Migrating Profile V2 -> V3"); - var list = new List(); + loadingHandler.SetLoadingMessage("Migrating Profile V2 -> V4"); + var list = new List(); var authService = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService().GetLogger("ProfileMigrationV2"); foreach (var oldCredentials in oldValue) @@ -19,8 +20,8 @@ public class ProfileMigrationV2(string oldName, string newName) try { loadingHandler.SetLoadingMessage($"Migrating {oldCredentials.Login}"); - var newCred = await authService.Auth(oldCredentials.Login, oldCredentials.Password, oldCredentials.AuthServer); - list.Add(newCred); + await authService.Auth(oldCredentials.Login, oldCredentials.Password, oldCredentials.AuthServer); + list.Add(CryptographicStore.Encrypt(oldCredentials, CryptographicStore.GetComputerKey())); } catch (Exception e) { @@ -34,7 +35,13 @@ public class ProfileMigrationV2(string oldName, string newName) } } -public sealed record ProfileAuthCredentialsV2( - string Login, - string Password, - string AuthServer); \ No newline at end of file +public class ProfileMigrationV3V4(string oldName, string newName) + : BaseConfigurationMigration(oldName, newName) +{ + protected override Task Migrate(IServiceProvider serviceProvider, AuthTokenCredentials[] oldValue, ILoadingHandler loadingHandler) + { + Console.WriteLine("Removing profile v3 because no password is provided"); + return Task.FromResult(Array.Empty()); + } +} + diff --git a/Nebula.Shared/Services/AuthService.cs b/Nebula.Shared/Services/AuthService.cs index e4bb345..369be6b 100644 --- a/Nebula.Shared/Services/AuthService.cs +++ b/Nebula.Shared/Services/AuthService.cs @@ -38,6 +38,7 @@ public class AuthService( var err = await e.Content.AsJson(); if (err is null) throw; + e.Dispose(); throw new AuthException(err); } } @@ -60,14 +61,36 @@ public class AuthService( public async Task Refresh(AuthTokenCredentials tokenCredentials) { var authUrl = new Uri($"{tokenCredentials.AuthServer}api/auth/refresh"); - var newToken = await restService.PostAsync( - TokenRequest.From(tokenCredentials), authUrl, cancellationService.Token); - - return tokenCredentials with { Token = newToken }; + try + { + var newToken = await restService.PostAsync( + TokenRequest.From(tokenCredentials), authUrl, cancellationService.Token); + + return tokenCredentials with { Token = newToken }; + } + catch (RestRequestException e) + { + if (e.StatusCode == HttpStatusCode.Unauthorized) + throw new AuthTokenExpiredException(tokenCredentials); + + e.Dispose(); + throw; + } + } +} + +public sealed class AuthTokenExpiredException : Exception +{ + public AuthTokenExpiredException(AuthTokenCredentials credentials): base("Taken token is expired. Login: " + credentials.Login) + { } } public sealed record AuthTokenCredentials(Guid UserId, LoginToken Token, string Login, string AuthServer); +public sealed record ProfileAuthCredentials( + string Login, + string Password, + string AuthServer); public sealed record AuthDenyError(string[] Errors, AuthenticateDenyCode Code); @@ -108,5 +131,4 @@ public sealed record TokenRequest(string Token) } public static TokenRequest Empty { get; } = new TokenRequest(""); - } \ No newline at end of file diff --git a/Nebula.Shared/Services/RestService.cs b/Nebula.Shared/Services/RestService.cs index c74673b..2bc90a7 100644 --- a/Nebula.Shared/Services/RestService.cs +++ b/Nebula.Shared/Services/RestService.cs @@ -29,7 +29,12 @@ public class RestService [Pure] public async Task GetAsync(Uri uri, CancellationToken cancellationToken) where T : notnull { - var response = await _client.GetAsync(uri, cancellationToken); + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri) + { + Version = HttpVersion.Version10, + }; + + var response = await _client.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); return await ReadResult(response, cancellationToken, uri); } @@ -85,10 +90,15 @@ public class RestService if (response.IsSuccessStatusCode) { - return await response.Content.AsJson(); + var data = await response.Content.AsJson(); + response.Dispose(); + return data; } + + var ex = new RestRequestException(response.Content, response.StatusCode, + $"Error while processing {uri.ToString()}: {response.ReasonPhrase}"); - throw new RestRequestException(response.Content, response.StatusCode, $"Error while processing {uri.ToString()}: {response.ReasonPhrase}"); + throw ex; } } @@ -96,8 +106,13 @@ public sealed class NullResponse { } -public sealed class RestRequestException(HttpContent content, HttpStatusCode statusCode, string message) : Exception(message) +public sealed class RestRequestException(HttpContent content, HttpStatusCode statusCode, string message) : Exception(message), IDisposable { public HttpStatusCode StatusCode { get; } = statusCode; public HttpContent Content { get; } = content; + + public void Dispose() + { + Content.Dispose(); + } } \ No newline at end of file diff --git a/Nebula.Shared/Utils/CryptographicStore.cs b/Nebula.Shared/Utils/CryptographicStore.cs new file mode 100644 index 0000000..dc9ceaa --- /dev/null +++ b/Nebula.Shared/Utils/CryptographicStore.cs @@ -0,0 +1,82 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Unicode; + +namespace Nebula.Shared.Utils; + +public static class CryptographicStore +{ + public static string Encrypt(object value, byte[] key) + { + using var memoryStream = new MemoryStream(); + using var aes = Aes.Create(); + aes.Key = key; + + var iv = aes.IV; + memoryStream.Write(iv, 0, iv.Length); + + var serializedData = JsonSerializer.Serialize(value); + + using CryptoStream cryptoStream = new( + memoryStream, + aes.CreateEncryptor(), + CryptoStreamMode.Write); + + using(StreamWriter encryptWriter = new(cryptoStream)) + { + encryptWriter.WriteLine(serializedData); + } + + return Convert.ToBase64String(memoryStream.ToArray()); + } + + public static async Task Decrypt(string base64EncryptedValue, byte[] key) + { + using var memoryStream = new MemoryStream(Convert.FromBase64String(base64EncryptedValue)); + using var aes = Aes.Create(); + + var iv = new byte[aes.IV.Length]; + var numBytesToRead = aes.IV.Length; + var numBytesRead = 0; + while (numBytesToRead > 0) + { + var n = memoryStream.Read(iv, numBytesRead, numBytesToRead); + if (n == 0) break; + + numBytesRead += n; + numBytesToRead -= n; + } + + + await using CryptoStream cryptoStream = new( + memoryStream, + aes.CreateDecryptor(key, iv), + CryptoStreamMode.Read); + + using StreamReader decryptReader = new(cryptoStream); + var decryptedMessage = await decryptReader.ReadToEndAsync(); + return JsonSerializer.Deserialize(decryptedMessage) ?? throw new InvalidOperationException(); + } + + public static byte[] GetKey(string input, int keySize = 256) + { + if (string.IsNullOrEmpty(input)) + throw new ArgumentException("Input string cannot be null or empty.", nameof(input)); + + var salt = Encoding.UTF8.GetBytes(input); + + using (var deriveBytes = new Rfc2898DeriveBytes(input, salt, 100_000, HashAlgorithmName.SHA256)) + { + return deriveBytes.GetBytes(keySize / 8); + } + } + + public static byte[] GetComputerKey(int keySize = 256) + { + var name = Environment.UserName; + if (string.IsNullOrEmpty(name)) + name = "LinuxUser"; + return GetKey(name, keySize); + } +} \ No newline at end of file diff --git a/Nebula.UnitTest/NebulaSharedTests/CryptographicTest.cs b/Nebula.UnitTest/NebulaSharedTests/CryptographicTest.cs new file mode 100644 index 0000000..4cce6f7 --- /dev/null +++ b/Nebula.UnitTest/NebulaSharedTests/CryptographicTest.cs @@ -0,0 +1,24 @@ +using Nebula.Shared.Utils; + +namespace Nebula.UnitTest.NebulaSharedTests; + +[TestFixture] +[TestOf(typeof(CryptographicStore))] +public class CryptographicTest +{ + [Test] + public async Task EncryptDecrypt() + { + var key = CryptographicStore.GetComputerKey(); + Console.WriteLine($"Key: {key}"); + var entry = new TestEncryptEntry("Hello", "World"); + Console.WriteLine($"Raw data: {entry}"); + var encrypt = CryptographicStore.Encrypt(entry, key); + Console.WriteLine($"Encrypted data: {encrypt}"); + var decrypt = await CryptographicStore.Decrypt(encrypt, key); + Console.WriteLine($"Decrypted data: {decrypt}"); + Assert.That(decrypt, Is.EqualTo(entry)); + } +} + +public record struct TestEncryptEntry(string Key, string Value); \ No newline at end of file diff --git a/Nebula.sln.DotSettings.user b/Nebula.sln.DotSettings.user index 61e5608..09ae3ff 100644 --- a/Nebula.sln.DotSettings.user +++ b/Nebula.sln.DotSettings.user @@ -17,6 +17,8 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -24,6 +26,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -97,5 +100,7 @@ <TestId>NUnit3x::735691F8-949C-4476-B9E4-5DF6FF8D3D0B::net9.0::Nebula.UnitTest.NebulaSharedTests.ConfigurationServiceTests.WriteConVarTest</TestId> <TestId>NUnit3x::735691F8-949C-4476-B9E4-5DF6FF8D3D0B::net9.0::Nebula.UnitTest.NebulaSharedTests.ConfigurationServiceTests.WriteArrayConvarTest</TestId> <TestId>NUnit3x::735691F8-949C-4476-B9E4-5DF6FF8D3D0B::net9.0::Nebula.UnitTest.NebulaSharedTests.ConfigurationServiceTests</TestId> + <TestId>NUnit3x::735691F8-949C-4476-B9E4-5DF6FF8D3D0B::net9.0::Nebula.UnitTest.NebulaSharedTests.CryptographicTest.EncryptDecrypt</TestId> + <TestId>NUnit3x::735691F8-949C-4476-B9E4-5DF6FF8D3D0B::net9.0::Nebula.UnitTest.NebulaSharedTests.CryptographicTest</TestId> </TestAncestor> </SessionState> \ No newline at end of file