diff --git a/README.md b/README.md index aaa2d9e..fbb32c3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Flathub](https://img.shields.io/flathub/v/org.unitystation.StationHub?style=flat-square)](https://flathub.org/apps/details/org.unitystation.StationHub) [![Discord](https://img.shields.io/discord/273774715741667329?style=flat-square)](https://discord.com/invite/tFcTpBp) -StationHub is the official launcher for Unitystation, it handles downloading, updating, and joining servers. +This is the official launcher for Unitystation, it handles account creation, downloading, updating, and server joining. ## Tech-stack diff --git a/UnitystationLauncher.Tests/MocksRepository/InstallationService/MockNoActiveDownloads.cs b/UnitystationLauncher.Tests/MocksRepository/InstallationService/MockNoActiveDownloads.cs index 8bc1b53..588a368 100644 --- a/UnitystationLauncher.Tests/MocksRepository/InstallationService/MockNoActiveDownloads.cs +++ b/UnitystationLauncher.Tests/MocksRepository/InstallationService/MockNoActiveDownloads.cs @@ -28,7 +28,7 @@ public List GetInstallations() throw new NotImplementedException(); } - public (bool, string) StartInstallation(Guid installationId, string? server = null, short? port = null) + public Task<(bool, string)> StartInstallation(Guid installationId, string? server = null, short? port = null) { throw new NotImplementedException(); } diff --git a/UnitystationLauncher/Assets/buttoncontext.png b/UnitystationLauncher/Assets/buttoncontext.png new file mode 100644 index 0000000..b15a093 Binary files /dev/null and b/UnitystationLauncher/Assets/buttoncontext.png differ diff --git a/UnitystationLauncher/Assets/org.unitystation.StationHub.metainfo.xml b/UnitystationLauncher/Assets/org.unitystation.StationHub.metainfo.xml index f5ae82a..9dedfc9 100644 --- a/UnitystationLauncher/Assets/org.unitystation.StationHub.metainfo.xml +++ b/UnitystationLauncher/Assets/org.unitystation.StationHub.metainfo.xml @@ -65,6 +65,15 @@ + + +

New:

+
    +
  • login is now in the hub
  • +
  • Hub now supports a new authentication flow
  • +
+
+

Fix:

diff --git a/UnitystationLauncher/Assets/userbg.png b/UnitystationLauncher/Assets/userbg.png new file mode 100644 index 0000000..2e27f66 Binary files /dev/null and b/UnitystationLauncher/Assets/userbg.png differ diff --git a/UnitystationLauncher/Assets/userico.jpg b/UnitystationLauncher/Assets/userico.jpg new file mode 100644 index 0000000..f90445b Binary files /dev/null and b/UnitystationLauncher/Assets/userico.jpg differ diff --git a/UnitystationLauncher/Constants/ApiUrls.cs b/UnitystationLauncher/Constants/ApiUrls.cs index 6cd3fb5..96826d0 100644 --- a/UnitystationLauncher/Constants/ApiUrls.cs +++ b/UnitystationLauncher/Constants/ApiUrls.cs @@ -2,7 +2,7 @@ namespace UnitystationLauncher.Constants; public static class ApiUrls { - private static string ApiBaseUrl => "https://api.unitystation.org"; + public static string ApiBaseUrl => "https://api.unitystation.org"; public static string ServerListUrl => $"{ApiBaseUrl}/serverlist"; public static string ValidateUrl => $"{ApiBaseUrl}/validatehubclient"; public static string ValidateTokenUrl => $"{ApiBaseUrl}/validatetoken?data="; @@ -21,4 +21,7 @@ public static class ApiUrls public static string TTSFiles => $"{CdnBaseUrl}/STTBundleTTS/TTS"; public static string TTSVersionFile => $"{TTSFiles}/version.txt"; + + public static string ApiBaseUrlLogin => "https://prod-api.unitystation.org"; + } \ No newline at end of file diff --git a/UnitystationLauncher/Constants/AppInfo.cs b/UnitystationLauncher/Constants/AppInfo.cs index f2bafd0..a6f9048 100644 --- a/UnitystationLauncher/Constants/AppInfo.cs +++ b/UnitystationLauncher/Constants/AppInfo.cs @@ -4,5 +4,5 @@ public static class AppInfo { // Whenever you change the currentBuild here, please also update the one in // UnitystationLauncher/Assets/org.unitystation.StationHub.metainfo.xml - public const int CurrentBuild = 938; + public const int CurrentBuild = 939; } \ No newline at end of file diff --git a/UnitystationLauncher/GameCommunicationPipe/PipeHubBuildCommunication.cs b/UnitystationLauncher/GameCommunicationPipe/PipeHubBuildCommunication.cs index 2233d61..078516b 100644 --- a/UnitystationLauncher/GameCommunicationPipe/PipeHubBuildCommunication.cs +++ b/UnitystationLauncher/GameCommunicationPipe/PipeHubBuildCommunication.cs @@ -13,18 +13,32 @@ namespace UnitystationLauncher.GameCommunicationPipe; public class PipeHubBuildCommunication : IDisposable { - private NamedPipeServerStream _serverPipe; - private StreamReader? _reader; - private StreamWriter? _writer; + private static NamedPipeServerStream? _serverPipe; + private static StreamReader? _reader; + private static StreamWriter? _writer; + private const string Unitystation_Hub_Build_Communication = "Unitystation_Hub_Build_Communication"; public PipeHubBuildCommunication() { - _serverPipe = new("Unitystation_Hub_Build_Communication", PipeDirection.InOut, 1, + _serverPipe?.Close(); + _serverPipe?.Dispose(); + _reader?.Close(); + _reader?.Dispose(); + _writer?.Close(); + _writer?.Dispose(); + + _serverPipe = new(Unitystation_Hub_Build_Communication, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); } - public async Task StartServerPipe() + public static async Task StartServerPipe() { + if (_serverPipe == null) + { + _serverPipe = new(Unitystation_Hub_Build_Communication, PipeDirection.InOut, 1, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + } + await _serverPipe.WaitForConnectionAsync(); _reader = new(_serverPipe); _writer = new(_serverPipe); @@ -42,7 +56,7 @@ public async Task StartServerPipe() { Log.Error(e.ToString()); _serverPipe.Close(); - _serverPipe = new("Unitystation_Hub_Build_Communication", PipeDirection.InOut, + _serverPipe = new(Unitystation_Hub_Build_Communication, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); await _serverPipe.WaitForConnectionAsync(); @@ -137,8 +151,11 @@ The main purpose of this Prompt is to allow the Variable viewer (Variable editin public void Dispose() { - _serverPipe.Dispose(); + _serverPipe?.Close(); + _serverPipe?.Dispose(); + _reader?.Close(); _reader?.Dispose(); + _writer?.Close(); _writer?.Dispose(); GC.SuppressFinalize(this); diff --git a/UnitystationLauncher/Models/Api/CharacterTokenResponse.cs b/UnitystationLauncher/Models/Api/CharacterTokenResponse.cs new file mode 100644 index 0000000..32b9e53 --- /dev/null +++ b/UnitystationLauncher/Models/Api/CharacterTokenResponse.cs @@ -0,0 +1,6 @@ +namespace UnitystationLauncher.Models.Api; + +public class CharacterTokenResponse : JsonObject +{ + public string token { get; set; } +} \ No newline at end of file diff --git a/UnitystationLauncher/Models/Api/Server.cs b/UnitystationLauncher/Models/Api/Server.cs index 6bd6dc0..92ce41e 100644 --- a/UnitystationLauncher/Models/Api/Server.cs +++ b/UnitystationLauncher/Models/Api/Server.cs @@ -8,12 +8,13 @@ namespace UnitystationLauncher.Models.Api [Serializable] public class Server { - public Server(string forkName, int buildVersion, string serverIp, int serverPort) + public Server(string forkName, int buildVersion, string serverIp, int serverPort, int ServerConnectionNegotiationPort) { ForkName = forkName; BuildVersion = buildVersion; ServerIp = serverIp; ServerPort = serverPort; + this.ServerConnectionNegotiationPort = ServerConnectionNegotiationPort; } public string? ServerName { get; set; } @@ -26,11 +27,15 @@ public Server(string forkName, int buildVersion, string serverIp, int serverPort public int PlayerCountMax { get; set; } public string ServerIp { get; } public int ServerPort { get; } + public int ServerConnectionNegotiationPort { get; } public string? WinDownload { get; set; } public string? OsxDownload { get; set; } public string? LinuxDownload { get; set; } public string GoodFileVersion { get; set; } = string.Empty; + public string ServerPublicKey { get; set; } + + public string ServerConnectionPublicKey { get; set; } public (string, int) ForkAndVersion => (ForkName, BuildVersion); diff --git a/UnitystationLauncher/Models/Api/ServerConnectionAuthenticationRequest.cs b/UnitystationLauncher/Models/Api/ServerConnectionAuthenticationRequest.cs new file mode 100644 index 0000000..ae223b3 --- /dev/null +++ b/UnitystationLauncher/Models/Api/ServerConnectionAuthenticationRequest.cs @@ -0,0 +1,14 @@ +namespace UnitystationLauncher.Models.Api; + +public class ServerConnectionAuthenticationRequest +{ + public string? ClientFork { get; set; } + + public string? ClientVersion { get; set; } + + public string? GoodFileVersion { get; set; } + public string? EncryptedSharedSecret { get; set; } + public string? EncryptedAccountID { get; set; } + + public string? ConnectionPublicServerKey { get; set; } +} \ No newline at end of file diff --git a/UnitystationLauncher/Models/AuthenticationStuff.cs b/UnitystationLauncher/Models/AuthenticationStuff.cs new file mode 100644 index 0000000..47730cf --- /dev/null +++ b/UnitystationLauncher/Models/AuthenticationStuff.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using Newtonsoft.Json; + +namespace UnitystationLauncher.Models; + +public abstract class JsonObject +{ + public virtual string ToJson() + { + return JsonConvert.SerializeObject(this); + } + + public virtual StringContent ToStringContent() + { + return new StringContent(ToJson(), Encoding.UTF8, "application/json"); + } +} + + +[Serializable] +public class AccountRegister : JsonObject +{ + [JsonProperty("unique_identifier")] + public string? UniqueIdentifier { get; set; } + + [JsonProperty("email")] + public string? Email { get; set; } + + [JsonProperty("username")] + public string? Username { get; set; } + + [JsonProperty("password")] + public string? Password { get; set; } +} + + +[Serializable] +public class ForgotPasswordModel : JsonObject +{ + [JsonProperty("email")] + public string? Email { get; set; } +} + +[Serializable] +public class Registersha512token : JsonObject +{ + [JsonProperty("sha512_token")] + public string? sha512_token { get; set; } +} + + +[Serializable] +public class GetCharacterForkToken : JsonObject +{ + [JsonProperty("fork_compatibility")] + public string? fork_compatibility { get; set; } +} + + +[Serializable] +public class AccountRegisterResponse : JsonObject +{ + [JsonProperty("account")] + public AccountRegisterDetails Account { get; set; } +} + +[Serializable] +public class AccountRegisterDetails : JsonObject +{ + [JsonProperty("unique_identifier")] + public string UniqueIdentifier { get; set; } + + [JsonProperty("email")] + public string Email { get; set; } + + [JsonProperty("username")] + public string Username { get; set; } +} + +[Serializable] +public class AccountLoginResponse : JsonObject +{ + [JsonProperty("token")] public string Token { get; set; } = ""; + + [JsonProperty("account")] + public AccountGetResponse Account { get; set; } +} + +[Serializable] +public class AccountGetResponse : JsonObject +{ + [JsonProperty("unique_identifier")] + public string UniqueIdentifier { get; set; } = ""; + + [JsonProperty("username")] + public string Username { get; set; } = ""; + + [JsonProperty("is_verified")] + public bool IsVerified { get; set; } +} + +[Serializable] +public class AccountLoginToken : JsonObject, ITokenAuthable +{ + public string Token { get; set; } = ""; +} + +[Serializable] +public class AccountLogout : JsonObject, ITokenAuthable +{ + public string Token { get; set; } = ""; +} + +[Serializable] +public class AccountResendEmailConfirmationRequest : JsonObject +{ + [JsonProperty("email")] + public string Email { get; set; } = ""; +} + +[Serializable] +public class AccountLoginCredentials : JsonObject +{ + [JsonProperty("email")] + public string Email { get; set; } = ""; + + [JsonProperty("password")] public string Password { get; set; } = ""; +} + +public class ApiResult : JsonObject where T : JsonObject + +{ + public HttpStatusCode StatusCode { get; set; } + public T Data { get; set; } + public ApiHttpException? Exception { get; set; } + + public bool IsSuccess => Exception == null; + + private ApiResult(HttpStatusCode statusCode, T data, ApiHttpException? exception = null) + { + StatusCode = statusCode; + Data = data; + Exception = exception; + } + + public static ApiResult Success(HttpStatusCode statusCode, T data) => new(statusCode, data); + public static ApiResult Failure(HttpStatusCode statusCode, T data, ApiHttpException exception) => new(statusCode, data, exception); +} + +/// +/// Error class for any HTTP-related errors as returned by the API server. +/// +public class ApiHttpException : Exception +{ + public HttpStatusCode StatusCode { get; private set; } + + public ApiHttpException(string message, HttpStatusCode code) : base(message) + { + StatusCode = code; + } +} + +/// +/// Marks an API request as having or requiring an authentication token. +/// +public interface ITokenAuthable +{ + string Token { get; } +} + +/// +/// Error class for any usage-specific API errors as returned by the API server. +/// +public class ApiRequestException : ApiHttpException +{ + /// A list of all error messages returned by the API server. + /// You can use Message to get the first one. + public List Messages { get; set; } + + public ApiRequestException(string message, HttpStatusCode statusCode) : base(message, statusCode) + { + Messages = new List(); + } +} + diff --git a/UnitystationLauncher/Models/Installation.cs b/UnitystationLauncher/Models/Installation.cs index 16f5060..77d07ba 100644 --- a/UnitystationLauncher/Models/Installation.cs +++ b/UnitystationLauncher/Models/Installation.cs @@ -10,6 +10,8 @@ public class Installation public string? ForkName { get; set; } public int BuildVersion { get; set; } + public string? GoodFileVersion { get; set; } + public string? InstallationPath { get; set; } public DateTime LastPlayedDate { get; set; } diff --git a/UnitystationLauncher/Services/ApiServer.cs b/UnitystationLauncher/Services/ApiServer.cs new file mode 100644 index 0000000..db51e2c --- /dev/null +++ b/UnitystationLauncher/Services/ApiServer.cs @@ -0,0 +1,159 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnitystationLauncher.Models; + +namespace UnitystationLauncher.Services; + +/// +/// HTTP wrapper for database API requests. +/// +public static class ApiServer +{ + public const string AuthenticationHeaderValue = "Token"; + + internal static async Task> Get(Uri uri, string? token = default) where T : JsonObject + { + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri); + + if (token != default) + { + request.Headers.Authorization = new AuthenticationHeaderValue(AuthenticationHeaderValue, token); + } + + return await Send(request); + } + + internal static async Task> Post(Uri uri, JsonObject body, string? token = default) + where T : JsonObject + { + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, uri); + + if (body is ITokenAuthable authable) + { + request.Headers.Authorization = new AuthenticationHeaderValue(AuthenticationHeaderValue, authable.Token); + } + + if (token != default) + { + request.Headers.Authorization = new AuthenticationHeaderValue(AuthenticationHeaderValue, token); + } + + string sss = JsonConvert.SerializeObject(body); + request.Content = new StringContent(sss, Encoding.UTF8, "application/json"); + return await Send(request); + } + + internal static async Task> Put(Uri uri, JsonObject body, string? token = default) + where T : JsonObject + { + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Put, uri); + + if (body is ITokenAuthable authable) + { + request.Headers.Authorization = new AuthenticationHeaderValue(AuthenticationHeaderValue, authable.Token); + } + + if (token != default) + { + request.Headers.Authorization = new AuthenticationHeaderValue(AuthenticationHeaderValue, token); + } + + var sss = JsonConvert.SerializeObject(body); + request.Content = new StringContent(sss, Encoding.UTF8, "application/json"); + return await Send(request); + } + + internal static async Task> Delete(Uri uri, string? token = default) where T : JsonObject + { + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Delete, uri); + + if (token != default) + { + request.Headers.Authorization = new AuthenticationHeaderValue(AuthenticationHeaderValue, token); + } + + return await Send(request); + } + + + private static async Task> Send(HttpRequestMessage request) where T : JsonObject + { + request.Headers.Add("Accept", "application/json"); + HttpClient Client = new HttpClient(); + HttpResponseMessage response = await Client.SendAsync(request); + string responseBody = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode == false) + { + if (TryGetApiRequestException(responseBody, response.StatusCode, out ApiRequestException requestException)) + { + return ApiResult.Failure(response.StatusCode, null, requestException); + } + + return ApiResult.Failure(response.StatusCode, null, + new ApiHttpException(response.ReasonPhrase, response.StatusCode)); + } + + return ApiResult.Success(response.StatusCode, JsonConvert.DeserializeObject(responseBody)); + } + + /// + /// Attempts to get any usage-related errors from the given API server's response and + /// provides an unthrown instance if any are found. + /// + /// Response body to test + /// + /// An or null + /// True if an API error found + private static bool TryGetApiRequestException(string response, HttpStatusCode statusCode, + out ApiRequestException requestException) + { + ApiErrorResponse errorResponse = JsonConvert.DeserializeObject(response); + ApiRequestException tempException = new ApiRequestException("An error occurred", statusCode); + requestException = null; + + if (errorResponse.Error is JValue) + { + HandleSimpleError(tempException, errorResponse); + requestException = tempException; + } + else if (errorResponse.Error is JObject) + { + HandleComplexError(tempException, errorResponse); + requestException = tempException; + } + + return requestException != null && requestException.Messages.Any(); + } + + public class ApiErrorResponse + { + [JsonProperty("error")] public JToken Error { get; set; } + } + + private static void HandleComplexError(ApiRequestException requestException, ApiErrorResponse errorResponse) + { + if (errorResponse.Error is JObject errorObject) + { + foreach (var prop in errorObject.Properties()) + { + foreach (var message in prop.Value) + { + requestException.Messages.Add(message.ToString()); + } + } + } + } + + private static void HandleSimpleError(ApiRequestException requestException, ApiErrorResponse errorResponse) + { + requestException.Messages.Add(errorResponse.Error.ToString()); + } +} \ No newline at end of file diff --git a/UnitystationLauncher/Services/AuthService.cs b/UnitystationLauncher/Services/AuthService.cs new file mode 100644 index 0000000..388590e --- /dev/null +++ b/UnitystationLauncher/Services/AuthService.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Net.Mail; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using UnitystationLauncher.Constants; +using UnitystationLauncher.Models; +using UnitystationLauncher.Models.Api; +using UnitystationLauncher.Models.ConfigFile; +using UnitystationLauncher.Services.Interface; + +namespace UnitystationLauncher.Services +{ + public class AuthService + { + private readonly HttpClient _http; + public LoginMsg? LoginMsg { get; set; } + public bool AttemptingAutoLogin { get; set; } + private readonly IAuthProvider _IAuthProvider; + private readonly IPreferencesService _preferencesService; + + public AuthService(HttpClient http, IAuthProvider IAuthProvider, IPreferencesService preferencesService) + { + _http = http; + _IAuthProvider = IAuthProvider; + _preferencesService = preferencesService; + LoadAuthSettings(); + } + + + private string AuthSettingsPath => Path.Combine(_preferencesService.GetPreferences().InstallationPath, "authSettings.json"); + + public AccountLoginResponse? AccountLoginResponse { get; set; } + + + private void LoadAuthSettings() + { + try + { + if (File.Exists(AuthSettingsPath)) + { + var json = File.ReadAllText(AuthSettingsPath); + var AccountLoginResponseA = JsonSerializer.Deserialize(json); + AccountLoginResponse = AccountLoginResponseA; + } + } + catch (Exception ex) + { + Log.Error(ex.ToString()); + // Something went wrong reading the auth settings. Just ask the user to log in again. + // The auth settings file will get overwritten after they do so we don't need to clean it up. + } + + } + + public void SaveAuthSettings() + { + var json = JsonSerializer.Serialize(AccountLoginResponse); + + using (StreamWriter writer = File.CreateText(AuthSettingsPath)) + { + writer.WriteLine(json); + } + } + + public async Task GenerateCharacterSheetTokenForFork(string Fork) + { + return (await _IAuthProvider.GenerateCharacterSheetTokenForFork(AccountLoginResponse.Token, Fork)).Data; + } + + public void RegisterJoiningServerWithSecret(string SharedSecret) + { + _IAuthProvider.SendRegisterSharedSecret(AccountLoginResponse.Token, SharedSecret); + } + + public void ResendVerificationEmail(string email) + { + _IAuthProvider.ResendEmailConfirmation(email); + } + + public void SendForgotPasswordEmail(string email) + { + _IAuthProvider.SendForgotPasswordEmail(email); + } + + internal Task SignInWithEmailAndPasswordAsync(string email, string password) + { + return _IAuthProvider.SignInWithEmailAndPasswordAsync(email, password); + } + + + + + internal async Task> CreateAccountAsync(string userId, string username, string email, string password) + { + ApiResult registerResponse = await _IAuthProvider.Register(userId, email, username, password); + + if (registerResponse.IsSuccess == false) + { + throw new InvalidOperationException("Failed to register account"); + } + + return registerResponse; + } + + + + public async Task GetCustomTokenAsync(string refreshToken) + { + try + { + ApiResult Response = await _IAuthProvider.Login(refreshToken); + if (Response.IsSuccess == false) + { + Log.Error("Error: {Error}", Response.Exception); + return ""; + } + else + { + AccountLoginResponse = Response.Data; + return Response.Data.Token; + } + } + catch (Exception e) + { + Log.Error(e, "Failed when sending token validation request"); + return ""; + } + + } + + public async Task SignOutUserAsync() + { + await _IAuthProvider.Logout(AccountLoginResponse.Token); + AccountLoginResponse = null; + } + } + + + + public class LoginMsg + { + public string Email { get; set; } = ""; + public string Pass { get; set; } = ""; + } + + [Serializable] + public class RefreshToken + { + [JsonPropertyName("RefreshToken")] public string? Token { get; set; } + public string? UserId { get; set; } + } + + [Serializable] + public class ApiResponse + { + /// + /// 0 = all good, read the message variable now, otherwise read errorMsg + /// + public int ErrorCode { get; set; } + + public string? ErrorMsg { get; set; } + public string? Message { get; set; } + } +} \ No newline at end of file diff --git a/UnitystationLauncher/Services/GameCommunicationPipeService.cs b/UnitystationLauncher/Services/GameCommunicationPipeService.cs index 8069516..c30585c 100644 --- a/UnitystationLauncher/Services/GameCommunicationPipeService.cs +++ b/UnitystationLauncher/Services/GameCommunicationPipeService.cs @@ -10,7 +10,7 @@ public class GameCommunicationPipeService : IGameCommunicationPipeService public void Init() { PipeHubBuildCommunication data = new(); - _ = data.StartServerPipe(); + _ = PipeHubBuildCommunication.StartServerPipe(); _coolPipeHubBuildCommunication = data; } } \ No newline at end of file diff --git a/UnitystationLauncher/Services/InstallationService.cs b/UnitystationLauncher/Services/InstallationService.cs index befb863..4fea3d6 100644 --- a/UnitystationLauncher/Services/InstallationService.cs +++ b/UnitystationLauncher/Services/InstallationService.cs @@ -43,9 +43,15 @@ public class InstallationService : IInstallationService private readonly string _installationsJsonFilePath; private readonly ITTSService _TTSVersionService; + private readonly IServerAuthenticationService _IServerAuthenticationService; + private readonly AuthService _authService; + public InstallationService(HttpClient httpClient, IPreferencesService preferencesService, IEnvironmentService environmentService, IServerService serverService, ICodeScanService codeScanService, - ICodeScanConfigService codeScanConfigService, ITTSService ITTSVersionService) + ICodeScanConfigService codeScanConfigService, ITTSService ITTSVersionService, + IServerAuthenticationService IServerAuthenticationService, + AuthService authService + ) { _httpClient = httpClient; _preferencesService = preferencesService; @@ -54,7 +60,8 @@ public InstallationService(HttpClient httpClient, IPreferencesService preference _codeScanService = codeScanService; _codeScanConfigService = codeScanConfigService; _TTSVersionService = ITTSVersionService; - + _IServerAuthenticationService = IServerAuthenticationService; + _authService = authService; _downloads = new(); _installationsJsonFilePath = Path.Combine(_environmentService.GetUserdataDirectory(), "installations.json"); @@ -151,7 +158,7 @@ public List GetInstallations() return (download, string.Empty); } - public (bool, string) StartInstallation(Guid installationId, string? server = null, short? port = null) + public async Task<(bool, string)> StartInstallation(Guid installationId, string? server = null, short? NegotiationPort = null , short? port = null) { _TTSVersionService.StartTTS(); @@ -173,7 +180,7 @@ public List GetInstallations() EnsureExecutableFlagOnUnixSystems(executable); - string arguments = GetArguments(server, port); + string arguments = await GetArguments(installation, server, NegotiationPort, port); ProcessStartInfo? startInfo = _environmentService.GetGameProcessStartInfo(executable, arguments); if (startInfo == null) @@ -418,21 +425,35 @@ private static (bool, string) CanStartDownload(Download download) return (true, string.Empty); } - private static string GetArguments(string? server, long? port) + private async Task GetArguments(Installation Installation, string? server, int? NegotiationPort, long? port) { - string arguments = string.Empty; + StringBuilder arguments = new StringBuilder(); - if (!string.IsNullOrWhiteSpace(server)) + if (string.IsNullOrWhiteSpace(server) == false) { - arguments += $"--server {server}"; + var Arguments = await _IServerAuthenticationService.AuthenticateWithServer(server, NegotiationPort.Value, Installation); + + + foreach (var Argument in Arguments) + { + arguments.Append($"{Argument.Key} {Argument.Value} "); + } + + arguments.Append($"--server {server} "); if (port.HasValue) { - arguments += $" --port {port}"; + arguments.Append($"--port {port} "); } } - return arguments; + var AccountID = _authService.AccountLoginResponse.Account.UniqueIdentifier; + var Username = _authService.AccountLoginResponse.Account.Username; + arguments.Append($"-AccountID {AccountID} "); + arguments.Append($"-Username {Username} "); + var CharacterToken = await _authService.GenerateCharacterSheetTokenForFork(Installation.ForkName); + arguments.Append($"-CharacterToken {CharacterToken.token} "); + return arguments.ToString(); } @@ -512,7 +533,8 @@ void ScanLogs(ScanLog log) ForkName = download.ForkName, InstallationId = Guid.NewGuid(), InstallationPath = download.InstallPath, - LastPlayedDate = DateTime.Now + LastPlayedDate = DateTime.Now, + GoodFileVersion = download.GoodFileVersion }); WriteInstallations(); diff --git a/UnitystationLauncher/Services/Interface/IInstallationService.cs b/UnitystationLauncher/Services/Interface/IInstallationService.cs index 6f927d6..7ee3937 100644 --- a/UnitystationLauncher/Services/Interface/IInstallationService.cs +++ b/UnitystationLauncher/Services/Interface/IInstallationService.cs @@ -47,7 +47,7 @@ public interface IInstallationService /// Server to connect to, either IP or domain /// Port to use, requires server parameter /// Status code for if it was successful, if unsuccessful the string will have the reason - public (bool, string) StartInstallation(Guid installationId, string? server = null, short? port = null); + public Task<(bool, string)> StartInstallation(Guid installationId, string? server = null, short? NegotiationPort = null , short? port = null); /// /// Deletes an installation diff --git a/UnitystationLauncher/Services/OfficialCentralCommandAuthentication.cs b/UnitystationLauncher/Services/OfficialCentralCommandAuthentication.cs new file mode 100644 index 0000000..c056aef --- /dev/null +++ b/UnitystationLauncher/Services/OfficialCentralCommandAuthentication.cs @@ -0,0 +1,214 @@ +using System; +using System.Threading.Tasks; +using UnitystationLauncher.Constants; +using UnitystationLauncher.Models; +using UnitystationLauncher.Models.Api; + +namespace UnitystationLauncher.Services; + +public interface IAuthProvider +{ + public Task SignInWithEmailAndPasswordAsync(string emailAddress, string password); + public Task> Login(string token); + + public Task Logout(string token, bool destroyAllSessions = false); + + public Task> Register( + string uniqueIdentifier, string emailAddress, string username, string password); + + public Task> ResendEmailConfirmation(string email); + + public Task> SendForgotPasswordEmail(string email); + + public Task> SendRegisterSharedSecret(string token, string SharedSecret); + + public Task> GenerateCharacterSheetTokenForFork(string token, string ForkName); +} + +public class OfficialCentralCommandAuthentication : IAuthProvider +{ + + public static string Host => ApiUrls.ApiBaseUrlLogin; + private static UriBuilder UriBuilder = new(Host); + public static Uri GetUri(string endpoint, string? queries = null, string BeginningOverride = null) + { + + UriBuilder.Path = $"/accounts/{endpoint}"; + + if (string.IsNullOrEmpty(BeginningOverride) == false) + { + UriBuilder.Path = BeginningOverride + $"{endpoint}"; + + } + + if (string.IsNullOrEmpty(queries) == false) + { + UriBuilder.Query = queries; + } + + return UriBuilder.Uri; + } + + public async Task> Login(string token) + { + AccountLoginToken requestBody = new() + { + Token = token, + }; + + ApiResult response = await ApiServer.Post(GetUri("login-token"), requestBody); + + if (response.IsSuccess == false) + { + throw response.Exception!; + } + + return response; + } + + public static async Task> Login(string emailAddress, string password) + { + AccountLoginCredentials requestBody = new() + { + Email = emailAddress, + Password = password, + }; + + ApiResult response = await ApiServer.Post(GetUri("login-credentials"), requestBody); + + if (response.IsSuccess == false) + { + throw response.Exception!; + } + + return response; + } + + public async Task SignInWithEmailAndPasswordAsync(string emailAddress, string password) + { + ApiResult loginResponse = await Login(emailAddress, password); + + AccountLoginResponse account = loginResponse.Data; + + return account; + } + public async Task Logout(string token, bool destroyAllSessions = false) // TODO: but no response? + { + AccountLogout requestBody = new() + { + Token = token, + }; + + var response = await ApiServer.Post(GetUri(destroyAllSessions ? "logoutall" : "logout"), requestBody); + + return response; + } + + public async Task> ResendEmailConfirmation(string email) + { + AccountResendEmailConfirmationRequest requestBody = new() + { + Email = email, + }; + + var response = await ApiServer.Post(GetUri("resend-account-confirmation"), requestBody); + return response; + } + + public async Task> Register( + string uniqueIdentifier, string emailAddress, string username, string password) + { + var requestBody = new AccountRegister + { + Email = emailAddress, + UniqueIdentifier = uniqueIdentifier, + Username = username, + Password = password, + }; + + var response = await ApiServer.Post(GetUri("register"), requestBody); + + if (response.IsSuccess == false) + { + throw response.Exception!; + } + + return response; + } + + + public async Task> SendForgotPasswordEmail(string email) + { + try + { + var requestBody = new ForgotPasswordModel + { + Email = email, + }; + + var response = await ApiServer.Post(GetUri("reset-password/"), requestBody); + + if (response.IsSuccess == false) + { + throw response.Exception!; + } + + return response; + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + public async Task> SendRegisterSharedSecret(string token, string SharedSecret) + { + try + { + var requestBody = new Registersha512token + { + sha512_token = SharedSecret, + }; + + var response = await ApiServer.Post(GetUri("register-SHA512-for-account/"), requestBody, token); + + if (response.IsSuccess == false) + { + throw response.Exception!; + } + + return response; + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + public async Task> GenerateCharacterSheetTokenForFork(string token, string ForkName) + { + try + { + var requestBody = new GetCharacterForkToken + { + fork_compatibility = ForkName, + }; + + var response = await ApiServer.Post(GetUri("GenForkToken", BeginningOverride: "/persistence/characters/"), requestBody, token); + + if (response.IsSuccess == false) + { + throw response.Exception!; + } + + return response; + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } +} \ No newline at end of file diff --git a/UnitystationLauncher/Services/ServerAuthenticationService.cs b/UnitystationLauncher/Services/ServerAuthenticationService.cs new file mode 100644 index 0000000..978e62b --- /dev/null +++ b/UnitystationLauncher/Services/ServerAuthenticationService.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using Newtonsoft.Json; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Encodings; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.OpenSsl; +using UnitystationLauncher.Models; +using UnitystationLauncher.Models.Api; + +namespace UnitystationLauncher.Services; + +public interface IServerAuthenticationService +{ + public Task GetServerInfoByIP(string IP, int Port); + + public Task> AuthenticateWithServer(string IP, int Port, Installation Installation); +} + +public class ServerAuthenticationService : IServerAuthenticationService +{ + public ServerAuthenticationService(AuthService AuthService) + { + _AuthService = AuthService; + } + + + private readonly AuthService _AuthService; + private readonly HttpClient _httpClient = new HttpClient(); + + private readonly SHA512 SHA512 = SHA512.Create(); + + public async Task GetServerInfoByIP(string IP, int Port) + { + try + { + string url = $"http://{IP}:{Port}/"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + string content = await response.Content.ReadAsStringAsync(); + var serverInfo = JsonConvert.DeserializeObject(content); + + return serverInfo; + } + catch (Exception ex) + { + return null; + } + } + + + static AsymmetricKeyParameter ImportKeyFromPem(string pem) + { + using (var sr = new StringReader(pem)) + { + var pr = new PemReader(sr); + return (AsymmetricKeyParameter)pr.ReadObject(); + } + } + + + public async Task> AuthenticateWithServer(string IP, int Port, Installation Installation) + { + var Info = await GetServerInfoByIP(IP, Port); + var RSAEncrypt = new OaepEncoding( + new RsaEngine(), + new Sha256Digest() + ); + + RSAEncrypt.Init(true, ImportKeyFromPem(Info.ServerPublicKey)); // false = decrypt mode + + byte[] sharedSecret = new byte[32]; // 256-bit key + RandomNumberGenerator.Fill(sharedSecret); + + // Optional: convert to Base64 if you want to transmit/store it + string base64Secret = Convert.ToBase64String(sharedSecret); + + var SHA512Check = Convert.ToBase64String( + SHA512.ComputeHash( + Encoding.UTF8.GetBytes( + base64Secret + + Info.ServerPublicKey + + Installation.BuildVersion.ToString() + + Installation.ForkName.ToString() + + Installation.GoodFileVersion.ToString() + + Info.ServerConnectionPublicKey))); + _AuthService.RegisterJoiningServerWithSecret(SHA512Check); + + + var ToSend = new ServerConnectionAuthenticationRequest + { + ConnectionPublicServerKey = Info.ServerConnectionPublicKey, + ClientVersion = Installation.BuildVersion.ToString(), + GoodFileVersion = Installation.GoodFileVersion.ToString(), + ClientFork = Installation.ForkName.ToString(), + EncryptedSharedSecret = EncryptString(RSAEncrypt, base64Secret), + EncryptedAccountID = EncryptString(RSAEncrypt, _AuthService.AccountLoginResponse.Account.UniqueIdentifier) + }; + + + // Serialize the object to JSON + string json = JsonConvert.SerializeObject(ToSend); + + // Wrap it in a StringContent with JSON media type + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + string url = $"http://{IP}:{Port}/"; + + var response = await _httpClient.PostAsync(url, content); + response.EnsureSuccessStatusCode(); + + string contentBack = await response.Content.ReadAsStringAsync(); + if (contentBack != "OK") + { + throw new AuthenticationException(contentBack + $" When trying to authenticate with {url}"); + } + + return new Dictionary + { + {"-SharedSecret", base64Secret}, + {"-ServerPublicConnectionKey", Info.ServerConnectionPublicKey}, + }; + } + + private string EncryptString(OaepEncoding rsa, string ToEncrypt) + { + var Bytes = Encoding.UTF8.GetBytes(ToEncrypt); + return Convert.ToBase64String(rsa.ProcessBlock(Bytes, 0, Bytes.Length)); + } +} \ No newline at end of file diff --git a/UnitystationLauncher/StandardModule.cs b/UnitystationLauncher/StandardModule.cs index a4bfb11..e09df36 100644 --- a/UnitystationLauncher/StandardModule.cs +++ b/UnitystationLauncher/StandardModule.cs @@ -24,6 +24,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); // View Models builder.RegisterAssemblyTypes(ThisAssembly) diff --git a/UnitystationLauncher/UnitystationLauncher.csproj b/UnitystationLauncher/UnitystationLauncher.csproj index 7128117..ef6f020 100644 --- a/UnitystationLauncher/UnitystationLauncher.csproj +++ b/UnitystationLauncher/UnitystationLauncher.csproj @@ -72,6 +72,7 @@ + diff --git a/UnitystationLauncher/ViewModels/ForgotPasswordViewModel.cs b/UnitystationLauncher/ViewModels/ForgotPasswordViewModel.cs new file mode 100644 index 0000000..2e15a09 --- /dev/null +++ b/UnitystationLauncher/ViewModels/ForgotPasswordViewModel.cs @@ -0,0 +1,72 @@ +using System; +using System.Reactive; +using ReactiveUI; +using UnitystationLauncher.Services; + +namespace UnitystationLauncher.ViewModels +{ + public class ForgotPasswordViewModel : ViewModelBase + { + private readonly Lazy _loginVm; + private readonly AuthService _authService; + private bool _isFormVisible; + private bool _isSuccessVisible; + string _email = ""; + + public string Email + { + get => _email; + set => this.RaiseAndSetIfChanged(ref _email, value); + } + + public ReactiveCommand Submit { get; } + public ReactiveCommand DoneButton { get; } + + public bool IsFormVisible + { + get => _isFormVisible; + set => this.RaiseAndSetIfChanged(ref _isFormVisible, value); + } + + public bool IsSuccessVisible + { + get => _isSuccessVisible; + set => this.RaiseAndSetIfChanged(ref _isSuccessVisible, value); + } + + public ForgotPasswordViewModel(AuthService authService, Lazy loginVm) + { + IsFormVisible = true; + IsSuccessVisible = false; + _authService = authService; + _loginVm = loginVm; + + var inputValidation = this.WhenAnyValue( + x => x.Email, + (e) => !string.IsNullOrWhiteSpace(e) && + e.Contains("@") && e.Contains(".")); + + Submit = ReactiveCommand.Create( + TrySendResetPassword, inputValidation); + + DoneButton = ReactiveCommand.Create(ReturnToLogin); + } + + void TrySendResetPassword() + { + _authService.SendForgotPasswordEmail(Email); + IsFormVisible = false; + IsSuccessVisible = true; + } + + public override void Refresh() + { + // Do nothing + } + + public LoginViewModel ReturnToLogin() + { + return _loginVm.Value; + } + } +} diff --git a/UnitystationLauncher/ViewModels/InstallationViewModel.cs b/UnitystationLauncher/ViewModels/InstallationViewModel.cs index 8a07b9d..6a75d36 100644 --- a/UnitystationLauncher/ViewModels/InstallationViewModel.cs +++ b/UnitystationLauncher/ViewModels/InstallationViewModel.cs @@ -30,7 +30,7 @@ public InstallationViewModel(Installation installation, IInstallationService ins private void LaunchInstallation() { - _installationService.StartInstallation(Installation.InstallationId); + _ = _installationService.StartInstallation(Installation.InstallationId); } private void DeleteInstallation() diff --git a/UnitystationLauncher/ViewModels/LauncherViewModel.cs b/UnitystationLauncher/ViewModels/LauncherViewModel.cs index 3367c28..e337418 100644 --- a/UnitystationLauncher/ViewModels/LauncherViewModel.cs +++ b/UnitystationLauncher/ViewModels/LauncherViewModel.cs @@ -12,6 +12,7 @@ using UnitystationLauncher.Infrastructure; using UnitystationLauncher.Models.ConfigFile; using UnitystationLauncher.Models.Enums; +using UnitystationLauncher.Services; using UnitystationLauncher.Services.Interface; namespace UnitystationLauncher.ViewModels @@ -22,9 +23,9 @@ public class LauncherViewModel : ViewModelBase private readonly IHubService _hubService; private readonly IPreferencesService _preferencesService; private readonly IEnvironmentService _environmentService; - + private readonly Lazy _logoutVm; private readonly ITTSService _ITTSService; - + private readonly AuthService _authService; public ReactiveCommand OpenMainSite { get; } public ReactiveCommand OpenPatreon { get; } public ReactiveCommand OpenDiscordInvite { get; } @@ -42,7 +43,7 @@ public ViewModelBase? SelectedPanel get => _selectedPanel; set => this.RaiseAndSetIfChanged(ref _selectedPanel, value); } - + public ReactiveCommand Logout { get; } public ReactiveCommand ShowUpdateView { get; } public LauncherViewModel( @@ -55,8 +56,11 @@ public LauncherViewModel( IPreferencesService preferencesService, IEnvironmentService environmentService, IGameCommunicationPipeService gameCommunicationPipeService, - ITTSService ITTSService) + ITTSService ITTSService, + Lazy logoutVm, AuthService authService) { + _authService = authService; + _logoutVm = logoutVm; _hubUpdateVm = hubUpdateVm; _hubService = hubService; _preferencesService = preferencesService; @@ -67,7 +71,7 @@ public LauncherViewModel( OpenMainSite = ReactiveCommand.Create(() => OpenLink(LinkUrls.MainSiteUrl)); OpenPatreon = ReactiveCommand.Create(() => OpenLink(LinkUrls.PatreonUrl)); OpenDiscordInvite = ReactiveCommand.Create(() => OpenLink(LinkUrls.DiscordInviteUrl)); - + Logout = ReactiveCommand.CreateFromTask(LogoutAsync); _panels = GetEnabledPanels(newsPanel, serversPanel, installationsPanel, preferencesPanel); ShowUpdateView = ReactiveCommand.Create(ShowUpdateImp); SelectedPanel = serversPanel; @@ -76,6 +80,13 @@ public LauncherViewModel( RxApp.MainThreadScheduler.Schedule((_) => StartTTSIfInstalled()); } + private async Task LogoutAsync() + { + await _authService.SignOutUserAsync(); + return _logoutVm.Value; + } + + private static PanelBase[] GetEnabledPanels( NewsPanelViewModel newsPanel, diff --git a/UnitystationLauncher/ViewModels/LoginStatusViewModel.cs b/UnitystationLauncher/ViewModels/LoginStatusViewModel.cs new file mode 100644 index 0000000..ac2d163 --- /dev/null +++ b/UnitystationLauncher/ViewModels/LoginStatusViewModel.cs @@ -0,0 +1,179 @@ +using System; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading.Tasks; +using ReactiveUI; +using Serilog; +using UnitystationLauncher.Infrastructure; +using UnitystationLauncher.Services; + +namespace UnitystationLauncher.ViewModels +{ + public class LoginStatusViewModel : ViewModelBase + { + private readonly AuthService _authService; + private readonly Lazy _launcherVm; + private readonly LoginViewModel _loginVm; + private string? _failedMessage; + private bool _isFailedVisible; + private bool _isResendEmailVisible; + private bool _resendClicked; + private bool _isWaitingVisible; + private string? _resendEmailAddress; + public LoginStatusViewModel(AuthService authService, Lazy launcherVm, + LoginViewModel loginVm) + { + IsFailedVisible = false; + IsResendEmailVisible = false; + ResendClicked = false; + _authService = authService; + _loginVm = loginVm; + _launcherVm = launcherVm; + + var hasAlreadyResent = this.WhenAnyValue( + x => x.ResendClicked, + (r) => !r); + + ResendEmail = ReactiveCommand.Create(OnResend, hasAlreadyResent); + + GoBack = ReactiveCommand.Create(GoBackToLogin); + + OpenLauncher = ReactiveCommand.Create(SignInComplete); + + if (!authService.AttemptingAutoLogin) + { + RxApp.MainThreadScheduler.ScheduleAsync((_, _) => UserLoginAsync()); + } + else + { + IsWaitingVisible = true; + } + } + + + + public string? ResendEmailAddress + { + get => _resendEmailAddress; + set => this.RaiseAndSetIfChanged(ref _resendEmailAddress, value); + } + public bool IsFailedVisible + { + get => _isFailedVisible; + set => this.RaiseAndSetIfChanged(ref _isFailedVisible, value); + } + + public bool IsResendEmailVisible + { + get => _isResendEmailVisible; + set => this.RaiseAndSetIfChanged(ref _isResendEmailVisible, value); + } + + public string? FailedMessage + { + get => _failedMessage; + set => this.RaiseAndSetIfChanged(ref _failedMessage, value); + } + + public bool ResendClicked + { + get => _resendClicked; + set => this.RaiseAndSetIfChanged(ref _resendClicked, value); + } + + public bool IsWaitingVisible + { + get => _isWaitingVisible; + set => this.RaiseAndSetIfChanged(ref _isWaitingVisible, value); + } + + public ReactiveCommand GoBack { get; } + public ReactiveCommand ResendEmail { get; } + public ReactiveCommand OpenLauncher { get; } + + public async Task UserLoginAsync() + { + bool signInSuccess = true; + ResendClicked = false; + IsResendEmailVisible = false; + IsWaitingVisible = true; + + if (string.IsNullOrEmpty(_authService.LoginMsg?.Email) || + string.IsNullOrEmpty(_authService.LoginMsg.Pass)) + { + Log.Error("Login failed"); + FailedMessage = "Login failed.\r\n" + + "Check your email and password\r\n" + + "and try again."; + return; + } + + try + { + await _authService.SignInWithEmailAndPasswordAsync( + _authService.LoginMsg.Email, _authService.LoginMsg.Pass).AwaitWithTimeout(TimeSpan.FromSeconds(20), + AccountLoginResponse => _authService.AccountLoginResponse = AccountLoginResponse); + } + catch (OperationCanceledException) + { + Log.Error("Error: {Error}", "Login timed out"); + FailedMessage = "Timed out while trying to log in.\n" + + "Please check your network connection."; + signInSuccess = false; + } + catch (Exception e) + { + Log.Error(e, "Login failed"); + FailedMessage = "Login failed.\r\n" + + "Check your email and password\r\n" + + "and try again."; + signInSuccess = false; + } + + + _authService.LoginMsg = null; + + IsWaitingVisible = false; + if (!signInSuccess) + { + IsFailedVisible = true; + return; + } + + _authService.SaveAuthSettings(); + + Observable.Start(() => { }).InvokeCommand(this, vm => vm.OpenLauncher); + } + + public void OnResend() + { + if (string.IsNullOrWhiteSpace(ResendEmailAddress)) + { + FailedMessage = "Please enter a valid email address to resend verification."; + return; + } + + _authService.ResendVerificationEmail(ResendEmailAddress); + ResendClicked = true; + FailedMessage = "A new verification email has been sent to:\r\n" + + $"{ResendEmailAddress ?? "{ no email }"}\r\n" + + $"Please activate your account by clicking the link\r\n" + + $"in the email and try again."; + } + + public LoginViewModel GoBackToLogin() + { + return _loginVm; + } + public LauncherViewModel SignInComplete() + { + return _launcherVm.Value; + } + + public override void Refresh() + { + // Do nothing + } + } +} \ No newline at end of file diff --git a/UnitystationLauncher/ViewModels/LoginViewModel.cs b/UnitystationLauncher/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..f431226 --- /dev/null +++ b/UnitystationLauncher/ViewModels/LoginViewModel.cs @@ -0,0 +1,91 @@ +using System; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Threading.Tasks; +using ReactiveUI; +using UnitystationLauncher.Models.ConfigFile; +using UnitystationLauncher.Services; + +namespace UnitystationLauncher.ViewModels +{ + public class LoginViewModel : ViewModelBase + { + private readonly Lazy _signUpVm; + private readonly Lazy _forgotVm; + private readonly Lazy _loginStatusVm; + private readonly AuthService _authService; + string _email = ""; + string _password = ""; + + public LoginViewModel( + Lazy loginStatusVm, + Lazy signUpVm, + Lazy forgotVm, + AuthService authService) + { + _authService = authService; + _signUpVm = signUpVm; + _loginStatusVm = loginStatusVm; + _forgotVm = forgotVm; + + var possibleCredentials = this.WhenAnyValue( + x => x.Email, + x => x.Password, + (u, p) => + !string.IsNullOrWhiteSpace(u) && + !string.IsNullOrWhiteSpace(p)); + + Login = ReactiveCommand.Create( + UserLoginAsync, + possibleCredentials); + + Create = ReactiveCommand.Create( + UserCreate); + + ForgotPw = ReactiveCommand.Create( + ForgotPass); + } + + public string Email + { + get => _email; + set => this.RaiseAndSetIfChanged(ref _email, value); + } + + public string Password + { + get => _password; + set => this.RaiseAndSetIfChanged(ref _password, value); + } + + public ReactiveCommand Login { get; } + public ReactiveCommand Create { get; } + public ReactiveCommand ForgotPw { get; } + + public LoginStatusViewModel UserLoginAsync() + { + _authService.LoginMsg = new LoginMsg + { + Email = Email, + Pass = Password + }; + + return _loginStatusVm.Value; + } + + public SignUpViewModel UserCreate() + { + return _signUpVm.Value; + } + + public ForgotPasswordViewModel ForgotPass() + { + return _forgotVm.Value; + } + + public override void Refresh() + { + // Do nothing + } + } +} diff --git a/UnitystationLauncher/ViewModels/MainWindowViewModel.cs b/UnitystationLauncher/ViewModels/MainWindowViewModel.cs index 8985603..2eb16d4 100644 --- a/UnitystationLauncher/ViewModels/MainWindowViewModel.cs +++ b/UnitystationLauncher/ViewModels/MainWindowViewModel.cs @@ -1,14 +1,23 @@ using System; +using System.Reactive.Concurrency; using ReactiveUI; using System.Reactive.Linq; using System.Threading; +using System.Threading.Tasks; using Avalonia.Media; +using Serilog; +using UnitystationLauncher.Services; namespace UnitystationLauncher.ViewModels { public class MainWindowViewModel : ViewModelBase { - private ViewModelBase _content; + private readonly Lazy _launcherVm; + private readonly Lazy _loginStatusVm; + private readonly AuthService _authService; + private readonly LoginViewModel _loginVm; + + ViewModelBase _content; private Geometry _maximizeIcon; private string _maximizeToolTip; @@ -24,11 +33,18 @@ public string MaximizeToolTip set => this.RaiseAndSetIfChanged(ref _maximizeToolTip, value); } - public MainWindowViewModel(LauncherViewModel launcherVm) + public MainWindowViewModel(LoginViewModel loginVm, Lazy loginStatusVm, Lazy launcherVm, + AuthService authService) { + _loginStatusVm = loginStatusVm; + _loginVm = loginVm; + _authService = authService; + _launcherVm = launcherVm; + Content = _content = loginVm; + authService.AttemptingAutoLogin = false; _maximizeIcon = Geometry.Parse("M2048 2048v-2048h-2048v2048h2048zM1843 1843h-1638v-1638h1638v1638z"); _maximizeToolTip = "Maximize"; - Content = _content = launcherVm; + RxApp.MainThreadScheduler.ScheduleAsync((_, _) => CheckForExistingUserAsync()); } private void Maximize() @@ -57,17 +73,71 @@ private set } } + async Task CheckForExistingUserAsync() + { + if (_authService.AccountLoginResponse != null) + { + _authService.AttemptingAutoLogin = true; + Content = _loginStatusVm.Value; + await AttemptAuthRefreshAsync(); + } + //Will go to login screen if null + } + + async Task AttemptAuthRefreshAsync() + { + if (_authService.AccountLoginResponse == null) + { + Log.Error("Login failed"); + Content = _loginVm; + _authService.AttemptingAutoLogin = false; + return; + } + + string token = await _authService.GetCustomTokenAsync(_authService.AccountLoginResponse.Token); + + if (string.IsNullOrEmpty(token)) + { + Log.Error("Login failed"); + Content = _loginVm; + _authService.AttemptingAutoLogin = false; + return; + } + + _authService.AttemptingAutoLogin = false; + _authService.SaveAuthSettings(); + Content = _launcherVm.Value; + } + private void ContentChanged() { SubscribeToVm(Content switch { + LoginViewModel loginVm => Observable.Merge( + loginVm.Login.Select(vm => (ViewModelBase)vm), + loginVm.Create.Select(vm => (ViewModelBase)vm), + loginVm.ForgotPw.Select(vm => (ViewModelBase)vm)), + + LoginStatusViewModel loginStatusVm => Observable.Merge( + loginStatusVm.GoBack.Select(vm => (ViewModelBase)vm), + loginStatusVm.OpenLauncher.Select(vm => (ViewModelBase)vm)), + LauncherViewModel launcherVm => Observable.Merge( + launcherVm.Logout.Select(vm => (ViewModelBase)vm), launcherVm.ShowUpdateView.Select(vm => (ViewModelBase)vm)), + SignUpViewModel signUpViewModel => Observable.Merge( + signUpViewModel.Cancel, + signUpViewModel.DoneButton), + HubUpdateViewModel hubUpdateViewModel => Observable.Merge( hubUpdateViewModel.Skip, hubUpdateViewModel.Ignore), + ForgotPasswordViewModel forgotPasswordViewModel => Observable.Merge( + forgotPasswordViewModel.DoneButton), + + _ => throw new ArgumentException($"ViewModel type is not handled and will never be able to change") }); } diff --git a/UnitystationLauncher/ViewModels/ServerViewModel.cs b/UnitystationLauncher/ViewModels/ServerViewModel.cs index d553cde..13b10a3 100644 --- a/UnitystationLauncher/ViewModels/ServerViewModel.cs +++ b/UnitystationLauncher/ViewModels/ServerViewModel.cs @@ -56,7 +56,7 @@ public void LaunchGame() return; } - _installationService.StartInstallation(Installation.InstallationId, Server.ServerIp, (short)Server.ServerPort); + _ = _installationService.StartInstallation(Installation.InstallationId, Server.ServerIp, (short)Server.ServerConnectionNegotiationPort, (short)Server.ServerPort); } private async Task GetPing(IScheduler _, CancellationToken cancellationToken) diff --git a/UnitystationLauncher/ViewModels/SignUpViewModel.cs b/UnitystationLauncher/ViewModels/SignUpViewModel.cs new file mode 100644 index 0000000..8dcb3af --- /dev/null +++ b/UnitystationLauncher/ViewModels/SignUpViewModel.cs @@ -0,0 +1,163 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; +using ReactiveUI; +using Serilog; +using UnitystationLauncher.Services; +using ZstdSharp.Unsafe; + +namespace UnitystationLauncher.ViewModels +{ + public class SignUpViewModel : ViewModelBase + { + private readonly AuthService _authService; + private readonly Lazy _loginVm; + string _email = ""; + string _password = ""; + string _username = ""; + string _usernameID = ""; + private string? _creationMessage; + private string? _endButtonText; + private bool _isFormVisible; + private bool _isWaitingVisible; + private bool _isCreatedVisible; + + public string UsernameID + { + get => _usernameID; + set => this.RaiseAndSetIfChanged(ref _usernameID, value); + } + + public string Username + { + get => _username; + set => this.RaiseAndSetIfChanged(ref _username, value); + } + + public string Email + { + get => _email; + set => this.RaiseAndSetIfChanged(ref _email, value); + } + + public string Password + { + get => _password; + set => this.RaiseAndSetIfChanged(ref _password, value); + } + + public bool IsFormVisible + { + get => _isFormVisible; + set => this.RaiseAndSetIfChanged(ref _isFormVisible, value); + } + + public bool IsCreatedVisible + { + get => _isCreatedVisible; + set => this.RaiseAndSetIfChanged(ref _isCreatedVisible, value); + } + + public bool IsWaitingVisible + { + get => _isWaitingVisible; + set => this.RaiseAndSetIfChanged(ref _isWaitingVisible, value); + } + + public string? CreationMessage + { + get => _creationMessage; + set => this.RaiseAndSetIfChanged(ref _creationMessage, value); + } + + public string? EndButtonText + { + get => _endButtonText; + set => this.RaiseAndSetIfChanged(ref _endButtonText, value); + } + + public ReactiveCommand Cancel { get; } + public ReactiveCommand DoneButton { get; } + public ReactiveCommand Submit { get; } + + public SignUpViewModel(AuthService authService, Lazy loginVm) + { + IsFormVisible = true; + IsWaitingVisible = false; + IsCreatedVisible = false; + _authService = authService; + _loginVm = loginVm; + var possibleCredentials = this.WhenAnyValue( + x => x.Email, + x => x.Password, + x => x.Username, + x => x.UsernameID, + (u, p, i, m) => + !string.IsNullOrWhiteSpace(u) && + !string.IsNullOrWhiteSpace(p) && + p.Length > 6 && + !string.IsNullOrEmpty(i) && + !string.IsNullOrEmpty(m)); + + + + Submit = ReactiveCommand.CreateFromTask( + UserCreateAsync, possibleCredentials); + + Cancel = ReactiveCommand.Create(ReturnToLogin); + + DoneButton = ReactiveCommand.Create(CreationEndButton); + } + + public override void Refresh() + { + // Do nothing + } + + public async Task UserCreateAsync() + { + IsFormVisible = false; + var creationSuccess = true; + IsWaitingVisible = true; + + try + { + await _authService.CreateAccountAsync(_usernameID, _username, _email, _password); + } + catch (Exception e) + { + Log.Error(e, "Login failed"); + creationSuccess = false; + } + + if (creationSuccess) + { + CreationMessage = $"Success! An email has been sent to \r\n{_email}\r\n" + + $"Please click the link in the email to verify\r\n" + + $"your account before signing in."; + EndButtonText = "Done"; + } + else + { + CreationMessage = $"Something went wrong with the verification email server.\r\n" + + $"A reset password email has been sent to {_email} as a work around.\r\n" + + $"Please reset your password and try to log in."; + _authService.SendForgotPasswordEmail(_email); + EndButtonText = "Back"; + } + + IsWaitingVisible = false; + IsCreatedVisible = true; + } + + public LoginViewModel ReturnToLogin() + { + return _loginVm.Value; + } + + public LoginViewModel CreationEndButton() + { + return _loginVm.Value; + } + } +} \ No newline at end of file diff --git a/UnitystationLauncher/Views/ForgotPasswordView.xaml b/UnitystationLauncher/Views/ForgotPasswordView.xaml new file mode 100644 index 0000000..8580534 --- /dev/null +++ b/UnitystationLauncher/Views/ForgotPasswordView.xaml @@ -0,0 +1,48 @@ + + + + + + + + + Enter your email address: + + + + + + + + + + + + + + + A reset password email has been sent to your address. + Please reset your password and try logging in again + + + + + + \ No newline at end of file diff --git a/UnitystationLauncher/Views/ForgotPasswordView.xaml.cs b/UnitystationLauncher/Views/ForgotPasswordView.xaml.cs new file mode 100644 index 0000000..1f08072 --- /dev/null +++ b/UnitystationLauncher/Views/ForgotPasswordView.xaml.cs @@ -0,0 +1,18 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace UnitystationLauncher.Views +{ + public class ForgotPasswordView : UserControl + { + public ForgotPasswordView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} \ No newline at end of file diff --git a/UnitystationLauncher/Views/LauncherView.xaml b/UnitystationLauncher/Views/LauncherView.xaml index ef83d33..ba1ee64 100644 --- a/UnitystationLauncher/Views/LauncherView.xaml +++ b/UnitystationLauncher/Views/LauncherView.xaml @@ -15,9 +15,14 @@ - + + + + + + + - diff --git a/UnitystationLauncher/Views/LoginStatusView.xaml b/UnitystationLauncher/Views/LoginStatusView.xaml new file mode 100644 index 0000000..20ee258 --- /dev/null +++ b/UnitystationLauncher/Views/LoginStatusView.xaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/UnitystationLauncher/Views/LoginView.xaml.cs b/UnitystationLauncher/Views/LoginView.xaml.cs new file mode 100644 index 0000000..7cb6e70 --- /dev/null +++ b/UnitystationLauncher/Views/LoginView.xaml.cs @@ -0,0 +1,18 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace UnitystationLauncher.Views +{ + public class LoginView : UserControl + { + public LoginView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} \ No newline at end of file diff --git a/UnitystationLauncher/Views/SignUpView.xaml b/UnitystationLauncher/Views/SignUpView.xaml new file mode 100644 index 0000000..f014e88 --- /dev/null +++ b/UnitystationLauncher/Views/SignUpView.xaml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + Create Account: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +