From 830a401c9026ca4e25ac465724847f54f19f1c1d Mon Sep 17 00:00:00 2001 From: Eauldane Date: Tue, 7 Oct 2025 09:28:45 +0100 Subject: [PATCH] XIVAuth --- MareSynchronos/UI/CompactUI.cs | 94 +++++++++++++++++-- MareSynchronos/UI/IntroUI.cs | 37 +++++++- .../WebAPI/AccountRegistrationService.cs | 89 +++++++++++++++++- 3 files changed, 210 insertions(+), 10 deletions(-) diff --git a/MareSynchronos/UI/CompactUI.cs b/MareSynchronos/UI/CompactUI.cs index 17c54a0..cc95edc 100644 --- a/MareSynchronos/UI/CompactUI.cs +++ b/MareSynchronos/UI/CompactUI.cs @@ -25,6 +25,8 @@ using System.Diagnostics; using System.Globalization; using System.Numerics; using System.Reflection; +using MareSynchronos.API.Dto.Account; +using MareSynchronos.MareConfiguration.Models; namespace MareSynchronos.UI; @@ -67,10 +69,18 @@ public class CompactUi : WindowMediatorSubscriberBase private int _secretKeyIdx = -1; private bool _showModalForUserAddition; private bool _wasOpen; + private bool _registrationInProgress = false; + private bool _registrationSuccess = false; + private string? _registrationMessage; + private RegisterReplyDto? _registrationReply; + private readonly AccountRegistrationService _registerService; + private string _secretKey = string.Empty; + + public CompactUi(ILogger logger, UiSharedService uiShared, MareConfigService configService, ApiController apiController, PairManager pairManager, ChatService chatService, ServerConfigurationManager serverManager, MareMediator mediator, FileUploadManager fileTransferManager, UidDisplayHandler uidDisplayHandler, CharaDataManager charaDataManager, - PerformanceCollectorService performanceCollectorService) + PerformanceCollectorService performanceCollectorService, AccountRegistrationService registerService) : base(logger, mediator, "###SnowcloakSyncMainUI", performanceCollectorService) { _uiSharedService = uiShared; @@ -78,6 +88,7 @@ public class CompactUi : WindowMediatorSubscriberBase _apiController = apiController; _pairManager = pairManager; _serverManager = serverManager; + _registerService = registerService; _fileTransferManager = fileTransferManager; _uidDisplayHandler = uidDisplayHandler; _charaDataManager = charaDataManager; @@ -109,7 +120,7 @@ public class CompactUi : WindowMediatorSubscriberBase // changed min size SizeConstraints = new WindowSizeConstraints() { - MinimumSize = new Vector2(300, 400), + MinimumSize = new Vector2(500, 400), MaximumSize = new Vector2(600, 2000), }; } @@ -146,7 +157,7 @@ public class CompactUi : WindowMediatorSubscriberBase } else { - if (_uiSharedService.IconTextButton(icon, label, (150 - ImGui.GetStyle().WindowPadding.X * 2) * ImGuiHelpers.GlobalScale)) + if (_uiSharedService.IconTextButton(icon, label, (165 - ImGui.GetStyle().WindowPadding.X * 2) * ImGuiHelpers.GlobalScale)) { _selectedMenu = menu; } @@ -166,7 +177,7 @@ public class CompactUi : WindowMediatorSubscriberBase } else { - if (_uiSharedService.IconTextButton(icon, label, (150 - ImGui.GetStyle().WindowPadding.X * 2) * ImGuiHelpers.GlobalScale)) + if (_uiSharedService.IconTextButton(icon, label, (165 - ImGui.GetStyle().WindowPadding.X * 2) * ImGuiHelpers.GlobalScale)) { onClick(); } @@ -176,7 +187,8 @@ public class CompactUi : WindowMediatorSubscriberBase { // Adjust both values below to change size, 40 seems good to fit the buttons // 150 seems decent enough to fit the text into it, could be smaller - var sidebarWidth = (_sidebarCollapsed ? 40 : 150) * ImGuiHelpers.GlobalScale; + // Elf note: Adjusted to 165 since "Character Analysis" hung off the end a bit + var sidebarWidth = (_sidebarCollapsed ? 40 : 165) * ImGuiHelpers.GlobalScale; using (var child = ImRaii.Child("Sidebar", new Vector2(sidebarWidth, -1), true)) { @@ -356,10 +368,44 @@ public class CompactUi : WindowMediatorSubscriberBase { ImGui.Dummy(new(10)); var keys = _serverManager.CurrentServer!.SecretKeys; + ImGui.BeginDisabled(_registrationInProgress || _uiSharedService.ApiController.ServerState == ServerState.Connecting || _uiSharedService.ApiController.ServerState == ServerState.Reconnecting); if (keys.Any()) { if (_secretKeyIdx == -1) _secretKeyIdx = keys.First().Key; - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "Add current character with secret key")) + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "Log in with XIVAuth (experimental)")) + { + _registrationInProgress = true; + _ = Task.Run(async () => { + try + { + var reply = await _registerService.XIVAuth(CancellationToken.None).ConfigureAwait(false); + if (!reply.Success) + { + _logger.LogWarning("Registration failed: {err}", reply.ErrorMessage); + _registrationMessage = reply.ErrorMessage; + if (_registrationMessage.IsNullOrEmpty()) + _registrationMessage = "An unknown error occured. Please try again later."; + return; + } + _registrationMessage = "Account registered. Welcome to Snowcloak!"; + _secretKey = reply.SecretKey ?? ""; + _registrationReply = reply; + _registrationSuccess = true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Registration failed"); + _registrationSuccess = false; + _registrationMessage = "An unknown error occured. Please try again later."; + } + finally + { + _registrationInProgress = false; + } + }); + } + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "Add character with existing key")) { _serverManager.CurrentServer!.Authentications.Add(new MareConfiguration.Models.Authentication() { @@ -372,7 +418,43 @@ public class CompactUi : WindowMediatorSubscriberBase _ = _apiController.CreateConnections(); } + ImGui.EndDisabled(); // _registrationInProgress || _registrationSuccess + if (_registrationInProgress) + { + ImGui.TextUnformatted("Waiting for the server..."); + } + else if (!_registrationMessage.IsNullOrEmpty()) + { + if (!_registrationSuccess) + ImGui.TextColored(ImGuiColors.DalamudYellow, _registrationMessage); + else + ImGui.TextWrapped(_registrationMessage); + } + if (_secretKey.Length > 0 && _secretKey.Length != 64) + { + UiSharedService.ColorTextWrapped("Your secret key must be exactly 64 characters long.", ImGuiColors.DalamudRed); + } + else if (_secretKey.Length == 64) + { + using var saveDisabled = ImRaii.Disabled(_uiSharedService.ApiController.ServerState == ServerState.Connecting || _uiSharedService.ApiController.ServerState == ServerState.Reconnecting); + if (ImGui.Button("Save and Connect")) + { + string keyName; + if (_serverManager.CurrentServer == null) _serverManager.SelectServer(0); + if (_registrationReply != null && _secretKey.Equals(_registrationReply.SecretKey, StringComparison.Ordinal)) + keyName = _registrationReply.UID + $" (registered {DateTime.Now:yyyy-MM-dd})"; + else + keyName = $"Secret Key added on Setup ({DateTime.Now:yyyy-MM-dd})"; + _serverManager.CurrentServer!.SecretKeys.Add(_serverManager.CurrentServer.SecretKeys.Select(k => k.Key).LastOrDefault() + 1, new SecretKey() + { + FriendlyName = keyName, + Key = _secretKey, + }); + _serverManager.AddCurrentCharacterToServer(save: false); + _ = Task.Run(() => _uiSharedService.ApiController.CreateConnections()); + } + } _uiSharedService.DrawCombo("Secret Key##addCharacterSecretKey", keys, (f) => f.Value.FriendlyName, (f) => _secretKeyIdx = f.Key); } else diff --git a/MareSynchronos/UI/IntroUI.cs b/MareSynchronos/UI/IntroUI.cs index 74da657..949e999 100644 --- a/MareSynchronos/UI/IntroUI.cs +++ b/MareSynchronos/UI/IntroUI.cs @@ -167,7 +167,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone. """); UiSharedService.TextWrapped(""" - Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files will be automatically deleted. + Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files may be automatically deleted. """); UiSharedService.TextWrapped(""" Accounts that are inactive for ninety (90) days will be deleted for privacy reasons. @@ -263,6 +263,39 @@ public partial class IntroUi : WindowMediatorSubscriberBase ImGui.BeginDisabled(_registrationInProgress || _registrationSuccess || _secretKey.Length > 0); ImGui.Separator(); ImGui.TextUnformatted("If you have not used Snowcloak before, click below to register a new account."); + if (_uiShared.IconTextButton(FontAwesomeIcon.Plus, "Log in with XIVAuth (experimental)")) + { + _registrationInProgress = true; + _ = Task.Run(async () => { + try + { + var reply = await _registerService.XIVAuth(CancellationToken.None).ConfigureAwait(false); + if (!reply.Success) + { + _logger.LogWarning("Registration failed: {err}", reply.ErrorMessage); + _registrationMessage = reply.ErrorMessage; + if (_registrationMessage.IsNullOrEmpty()) + _registrationMessage = "An unknown error occured. Please try again later."; + return; + } + _registrationMessage = "Account registered. Welcome to Snowcloak!"; + _secretKey = reply.SecretKey ?? ""; + _registrationReply = reply; + _registrationSuccess = true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Registration failed"); + _registrationSuccess = false; + _registrationMessage = "An unknown error occured. Please try again later."; + } + finally + { + _registrationInProgress = false; + } + }); + } + ImGui.SameLine(); if (_uiShared.IconTextButton(FontAwesomeIcon.Plus, "Register a new Snowcloak account")) { _registrationInProgress = true; @@ -298,7 +331,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase ImGui.EndDisabled(); // _registrationInProgress || _registrationSuccess if (_registrationInProgress) { - ImGui.TextUnformatted("Sending request..."); + ImGui.TextUnformatted("Waiting for the server..."); } else if (!_registrationMessage.IsNullOrEmpty()) { diff --git a/MareSynchronos/WebAPI/AccountRegistrationService.cs b/MareSynchronos/WebAPI/AccountRegistrationService.cs index 4b5d81b..7335881 100644 --- a/MareSynchronos/WebAPI/AccountRegistrationService.cs +++ b/MareSynchronos/WebAPI/AccountRegistrationService.cs @@ -9,6 +9,8 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Reflection; using System.Security.Cryptography; +using Dalamud.Utility; +using System.Net; namespace MareSynchronos.WebAPI; @@ -17,16 +19,18 @@ public sealed class AccountRegistrationService : IDisposable private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly ServerConfigurationManager _serverManager; + private readonly DalamudUtilService _dalamudUtilService; private string GenerateSecretKey() { return Convert.ToHexString(SHA256.HashData(RandomNumberGenerator.GetBytes(64))); } - public AccountRegistrationService(ILogger logger, ServerConfigurationManager serverManager) + public AccountRegistrationService(ILogger logger, DalamudUtilService dalamudUtilService, ServerConfigurationManager serverManager) { _logger = logger; _serverManager = serverManager; + _dalamudUtilService = dalamudUtilService; _httpClient = new( new HttpClientHandler { @@ -43,6 +47,72 @@ public sealed class AccountRegistrationService : IDisposable _httpClient.Dispose(); } + public async Task XIVAuth(CancellationToken token) + { + var secretKey = GenerateSecretKey(); + var hashedSecretKey = secretKey.GetHash256(); + var playerName = _dalamudUtilService.GetPlayerNameAsync().GetAwaiter().GetResult(); + var worldId = (ushort)_dalamudUtilService.GetHomeWorldIdAsync().GetAwaiter().GetResult(); + var worldName = _dalamudUtilService.WorldData.Value[(worldId)]; + + + + var sessionID = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).Replace('+', '-').Replace('/', '_').TrimEnd('='); + + Uri handshakeUri = new Uri("https://account.snowcloak-sync.com/register"); + var handshakePayload = new { session_id = sessionID, hashed_secret = hashedSecretKey, character_name = playerName, home_world = worldName }; + var handshakeResponse = await _httpClient.PostAsJsonAsync(handshakeUri, handshakePayload, token).ConfigureAwait(false); + handshakeResponse.EnsureSuccessStatusCode(); + var register = await handshakeResponse.Content.ReadFromJsonAsync(cancellationToken: token) + .ConfigureAwait(false); + if (register is null || string.IsNullOrWhiteSpace(register.link_url) || + string.IsNullOrWhiteSpace(register.poll_url)) + { + return new RegisterReplyDto() { Success = false, ErrorMessage = "Malformed registration response." }; + } + + Util.OpenLink(register.link_url); + const int maxAttempts = 600 / 15; // Try once every 15 seconds for 10 minutes + var pollUri = new Uri(register.poll_url); + PollResponse? lastPoll = null; + for (int i = 0; i < maxAttempts; i++) + { + token.ThrowIfCancellationRequested(); + using var resp = await _httpClient.GetAsync(pollUri, token).ConfigureAwait(false); + if (resp.StatusCode == HttpStatusCode.Gone) + { + // Server marked this as having been consumed already OR it got TLL'd out + return new RegisterReplyDto() + { + Success = false, ErrorMessage = "Registration session expired. Please try again." + }; + } + + if (resp.StatusCode == HttpStatusCode.OK) + { + lastPoll = await resp.Content.ReadFromJsonAsync(cancellationToken: token) + .ConfigureAwait(false); + if (lastPoll?.status?.Equals("bound", StringComparison.OrdinalIgnoreCase) == true) + { + // yay + return new RegisterReplyDto() + { + Success = true, ErrorMessage = null, UID = lastPoll?.uid, SecretKey = secretKey + }; + } + // Pending, keep polling + } + await Task.Delay(TimeSpan.FromSeconds(15), token).ConfigureAwait(false); + } + // Timed out + return new RegisterReplyDto() + { + Success = false, + ErrorMessage = + "Timed out waiting for authorisation. Please try again, and complete the process within 10 minutes." + }; + + } public async Task RegisterAccount(CancellationToken token) { var secretKey = GenerateSecretKey(); @@ -67,4 +137,19 @@ public sealed class AccountRegistrationService : IDisposable SecretKey = secretKey }; } -} \ No newline at end of file + + private sealed class RegisterResponse + { + public string link_url { get; set; } = ""; + public string poll_url { get; set; } = ""; + } + + private sealed class PollResponse + { + public string status { get; set; } = ""; + public string? uid { get; set; } + + } + +} +