diff --git a/src/Snap.Hutao/Snap.Hutao/ApiEndpoints.csv b/src/Snap.Hutao/Snap.Hutao/ApiEndpoints.csv index 9bd520347..76b1d3f31 100644 --- a/src/Snap.Hutao/Snap.Hutao/ApiEndpoints.csv +++ b/src/Snap.Hutao/Snap.Hutao/ApiEndpoints.csv @@ -2,6 +2,7 @@ Name,CN,OS AccountCreateActionTicket(),https://passport-api.mihoyo.com/account/ma-cn-verifier/app/createActionTicketByToken, AccountCreateAuthTicketByGameBiz(),https://passport-api.mihoyo.com/account/ma-cn-verifier/app/createAuthTicketByGameBiz,https://sg-public-api.hoyoverse.com/account/ma-verifier/api/createAuthTicketBySToken AccountCreateLoginCaptcha(),https://passport-api.mihoyo.com/account/ma-cn-verifier/verifier/createLoginCaptcha, +AccountCreateQrLogin(),https://passport-api.mihoyo.com/account/ma-cn-passport/app/createQRLogin, AccountGetCookieTokenBySToken(),https://passport-api.mihoyo.com/account/auth/api/getCookieAccountInfoBySToken,https://api-account-os.hoyoverse.com/account/auth/api/getCookieAccountInfoBySToken AccountGetLTokenBySToken(),https://passport-api.mihoyo.com/account/auth/api/getLTokenBySToken,https://api-account-os.hoyoverse.com/account/auth/api/getLTokenBySToken AccountGetSTokenByGameToken(),https://passport-api.mihoyo.com/account/ma-cn-session/app/getTokenByGameToken, @@ -9,6 +10,7 @@ AccountGetSTokenByOldToken(),https://passport-api.mihoyo.com/account/ma-cn-sessi AccountLoginByMobileCaptcha(),https://passport-api.mihoyo.com/account/ma-cn-passport/app/loginByMobileCaptcha, AccountLoginByPassword(),https://passport-api.mihoyo.com/account/ma-cn-passport/app/loginByPassword,https://sg-public-api.hoyoverse.com/account/ma-passport/api/appLoginByPassword AccountLoginByThirdParty(),,https://sg-public-api.hoyoverse.com/account/ma-passport/api/appLoginByThirdParty +AccountQueryQrLoginStatus(),https://passport-api.mihoyo.com/account/ma-cn-passport/app/queryQRLoginStatus, AccountVerifyLtoken(),https://passport-api-v4.mihoyo.com/account/ma-cn-session/web/verifyLtoken, ActHoyolabReferer(),,https://act.hoyolab.com/ "AnnContent(string languageCode, Region region)","https://hk4e-ann-api.mihoyo.com/common/hk4e_cn/announcement/api/getAnnContent?{AnnouncementQuery(languageCode, region)}","https://sg-hk4e-api.hoyoverse.com/common/hk4e_global/announcement/api/getAnnContent?{AnnouncementQuery(languageCode, region)}" diff --git a/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/UserQRCodeDialog.xaml.cs b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/UserQRCodeDialog.xaml.cs index cb0c3286d..3aebd083b 100644 --- a/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/UserQRCodeDialog.xaml.cs +++ b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/UserQRCodeDialog.xaml.cs @@ -6,12 +6,9 @@ using Microsoft.UI.Xaml.Media.Imaging; using Snap.Hutao.Factory.QuickResponse; using Snap.Hutao.Service.Notification; -using Snap.Hutao.Web.Hoyolab.Hk4e.Sdk.Combo; using Snap.Hutao.Web.Hoyolab.Passport; using Snap.Hutao.Web.Response; -using System.Collections.Specialized; using System.IO; -using System.Web; namespace Snap.Hutao.UI.Xaml.View.Dialog; @@ -19,10 +16,10 @@ namespace Snap.Hutao.UI.Xaml.View.Dialog; [DependencyProperty("QRCodeSource", typeof(ImageSource))] internal sealed partial class UserQRCodeDialog : ContentDialog, IDisposable { + private readonly HoyoPlayPassportClient hoyoPlayPassportClient; private readonly IInfoBarService infoBarService; private readonly IQRCodeFactory qrCodeFactory; private readonly ITaskContext taskContext; - private readonly PandaClient pandaClient; private readonly CancellationTokenSource userManualCancellationTokenSource = new(); private bool disposed; @@ -43,11 +40,11 @@ public void Dispose() GC.SuppressFinalize(this); } - public async ValueTask> GetUidGameTokenAsync() + public async ValueTask> GetQrLoginResultAsync() { try { - return await GetUidGameTokenCoreAsync().ConfigureAwait(false); + return await GetQrLoginResultCoreAsync().ConfigureAwait(false); } finally { @@ -61,7 +58,7 @@ private void Cancel() userManualCancellationTokenSource.Cancel(); } - private async ValueTask> GetUidGameTokenCoreAsync() + private async ValueTask> GetQrLoginResultCoreAsync() { await taskContext.SwitchToMainThreadAsync(); _ = ShowAsync(); @@ -72,7 +69,7 @@ private async ValueTask> GetUidGameTokenCoreAsyn { CancellationToken token = userManualCancellationTokenSource.Token; string ticket = await FetchQRCodeAndSetImageAsync(token).ConfigureAwait(false); - UidGameToken? uidGameToken = await WaitQueryQRCodeConfirmAsync(ticket, token).ConfigureAwait(false); + QrLoginResult? uidGameToken = await WaitQueryQRCodeConfirmAsync(ticket, token).ConfigureAwait(false); if (uidGameToken is null) { @@ -94,47 +91,35 @@ private async ValueTask> GetUidGameTokenCoreAsyn private async ValueTask FetchQRCodeAndSetImageAsync(CancellationToken token) { - Response fetchResponse = await pandaClient.QRCodeFetchAsync(token).ConfigureAwait(false); - if (!ResponseValidator.TryValidate(fetchResponse, infoBarService, out UrlWrapper? wrapper)) + Response qrLoginResponse = await hoyoPlayPassportClient.CreateQrLoginAsync(token).ConfigureAwait(false); + if (!ResponseValidator.TryValidate(qrLoginResponse, infoBarService, out QrLogin? qrLogin)) { return string.Empty; } - string url = wrapper.Url; - string ticket = GetTicketFromUrl(url); - await taskContext.SwitchToMainThreadAsync(); BitmapImage bitmap = new(); - await bitmap.SetSourceAsync(new MemoryStream(qrCodeFactory.Create(url)).AsRandomAccessStream()); + await bitmap.SetSourceAsync(new MemoryStream(qrCodeFactory.Create(qrLogin.Url)).AsRandomAccessStream()); QRCodeSource = bitmap; - return ticket; - - static string GetTicketFromUrl(in ReadOnlySpan urlSpan) - { - ReadOnlySpan querySpan = urlSpan[urlSpan.IndexOf('?')..]; - NameValueCollection queryCollection = HttpUtility.ParseQueryString(querySpan.ToString()); - return queryCollection.TryGetSingleValue("ticket", out string? ticket) ? ticket : string.Empty; - } + return qrLogin.Ticket; } - private async ValueTask WaitQueryQRCodeConfirmAsync(string ticket, CancellationToken token) + private async ValueTask WaitQueryQRCodeConfirmAsync(string ticket, CancellationToken token) { using (PeriodicTimer timer = new(new(0, 0, 3))) { while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) { - Response query = await pandaClient.QRCodeQueryAsync(ticket, token).ConfigureAwait(false); + Response query = await hoyoPlayPassportClient.QueryQrLoginStatusAsync(ticket, token).ConfigureAwait(false); - if (query is { ReturnCode: 0, Data: { Stat: "Confirmed", Payload.Proto: "Account" } }) + if (query is { ReturnCode: 0, Data: { Status: "Confirmed", Tokens: [{ TokenType: 1 }] } }) { - UidGameToken? uidGameToken = JsonSerializer.Deserialize(query.Data.Payload.Raw); - ArgumentNullException.ThrowIfNull(uidGameToken); - return uidGameToken; + return query.Data; } - if (query.ReturnCode is (int)KnownReturnCode.QrCodeExpired) + if (query.ReturnCode is (int)KnownReturnCode.QRLoginExpired) { break; } diff --git a/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/UserView.xaml b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/UserView.xaml index bcc27cb14..2bf00852e 100644 --- a/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/UserView.xaml +++ b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/UserView.xaml @@ -323,14 +323,14 @@ Icon="{shuxm:FontIcon Glyph={StaticResource FontIconContentWebsite}}" IsEnabled="False" Label="{shuxm:ResourceString Name=ViewUserCookieOperationLoginMihoyoUserAction}"/> - + --> --> + Label="{shuxm:ResourceString Name=ViewUserCookieOperationLoginQRCodeAction}"/> () private async Task LoginByQRCodeAsync() { UserQRCodeDialog dialog = await contentDialogFactory.CreateInstanceAsync().ConfigureAwait(false); - (bool isOk, UidGameToken? token) = await dialog.GetUidGameTokenAsync().ConfigureAwait(false); + (bool isOk, QrLoginResult? qrLoginResult) = await dialog.GetQrLoginResultAsync().ConfigureAwait(false); if (!isOk) { return; } - using (IServiceScope scope = serviceProvider.CreateScope()) - { - Response response = await scope.ServiceProvider - .GetRequiredService>() - .Create(false) - .LoginByGameTokenAsync(token) - .ConfigureAwait(false); - - if (ResponseValidator.TryValidate(response, scope.ServiceProvider, out LoginResult? loginResult)) - { - Cookie stokenV2 = Cookie.FromLoginResult(loginResult); - (UserOptionResult optionResult, string uid) = await userService.ProcessInputCookieAsync(InputCookie.CreateForDeviceFpInference(stokenV2, false)).ConfigureAwait(false); - HandleUserOptionResult(optionResult, uid); - } - } + Cookie stokenV2 = Cookie.FromQrLoginResult(qrLoginResult); + (UserOptionResult optionResult, string uid) = await userService.ProcessInputCookieAsync(InputCookie.CreateForDeviceFpInference(stokenV2, false)).ConfigureAwait(false); + HandleUserOptionResult(optionResult, uid); } [Command("LoginByMobileCaptchaCommand")] diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Cookie.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Cookie.cs index afbade672..c39c3bda8 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Cookie.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Cookie.cs @@ -75,6 +75,23 @@ public static Cookie FromLoginResult(LoginResult? loginResult) return new(cookieMap); } + public static Cookie FromQrLoginResult(QrLoginResult? qrLoginResult) + { + if (qrLoginResult is null) + { + return new(); + } + + SortedDictionary cookieMap = new() + { + [STUID] = qrLoginResult.UserInfo.Aid, + [STOKEN] = qrLoginResult.Tokens.Single(token => token.TokenType is 1).Token, + [MID] = qrLoginResult.UserInfo.Mid, + }; + + return new(cookieMap); + } + public static Cookie FromSToken(string stuid, string stoken) { SortedDictionary cookieMap = new() diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/HoyolabOptions.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/HoyolabOptions.cs index c934237b0..fe06d6ce2 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/HoyolabOptions.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/HoyolabOptions.cs @@ -4,6 +4,7 @@ using Snap.Hutao.Web.Hoyolab.DataSigning; using System.Collections.Frozen; using System.Security.Cryptography; +using Random = Snap.Hutao.Core.Random; namespace Snap.Hutao.Web.Hoyolab; @@ -46,6 +47,9 @@ internal static class HoyolabOptions /// public static string DeviceId40 { get; } = GenerateDeviceId40(); + // TODO: 53位设备Id + public static string DeviceId53 { get; } = Random.GetLowerAndNumberString(53); + /// /// 盐 /// diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/HoyoPlayPassportClient.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/HoyoPlayPassportClient.cs index 18af5a17c..62753a8a6 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/HoyoPlayPassportClient.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/HoyoPlayPassportClient.cs @@ -49,6 +49,39 @@ public async ValueTask> CreateAuthTicketAsync(User u return Response.Response.DefaultIfNull(resp); } + public async ValueTask> CreateQrLoginAsync(CancellationToken token = default) + { + HttpRequestMessageBuilder builder = httpRequestMessageBuilderFactory.Create() + .SetRequestUri(apiEndpoints.AccountCreateQrLogin()) + .SetHeader("x-rpc-device_id", HoyolabOptions.DeviceId53) + .PostJson(default(EmptyContent)); + + Response? resp = await builder + .SendAsync>(httpClient, logger, token) + .ConfigureAwait(false); + + return Response.Response.DefaultIfNull(resp); + } + + public async ValueTask> QueryQrLoginStatusAsync(string ticket, CancellationToken token = default) + { + QrTicketWrapper data = new() + { + Ticket = ticket, + }; + + HttpRequestMessageBuilder builder = httpRequestMessageBuilderFactory.Create() + .SetRequestUri(apiEndpoints.AccountQueryQrLoginStatus()) + .SetHeader("x-rpc-device_id", HoyolabOptions.DeviceId53) + .PostJson(data); + + Response? resp = await builder + .SendAsync>(httpClient, logger, token) + .ConfigureAwait(false); + + return Response.Response.DefaultIfNull(resp); + } + public ValueTask<(string? Aigis, Response Response)> LoginByPasswordAsync(IPassportPasswordProvider provider, CancellationToken token = default) { return ValueTask.FromException<(string? Aigis, Response Response)>(new NotSupportedException()); diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/HoyoPlayPassportClientOversea.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/HoyoPlayPassportClientOversea.cs index a0b65237f..320dd1bb5 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/HoyoPlayPassportClientOversea.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/HoyoPlayPassportClientOversea.cs @@ -48,6 +48,16 @@ public async ValueTask> CreateAuthTicketAsync(User u return Response.Response.DefaultIfNull(resp); } + public ValueTask> CreateQrLoginAsync(CancellationToken token = default) + { + return ValueTask.FromException>(new NotSupportedException()); + } + + public ValueTask> QueryQrLoginStatusAsync(string ticket, CancellationToken token = default) + { + return ValueTask.FromException>(new NotSupportedException()); + } + public ValueTask<(string? Aigis, Response Response)> LoginByPasswordAsync(IPassportPasswordProvider provider, CancellationToken token = default) { ArgumentNullException.ThrowIfNull(provider.Account); diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/IHoyoPlayPassportClient.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/IHoyoPlayPassportClient.cs index 44196e895..96d51be54 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/IHoyoPlayPassportClient.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/IHoyoPlayPassportClient.cs @@ -10,6 +10,10 @@ internal interface IHoyoPlayPassportClient { ValueTask> CreateAuthTicketAsync(User user, CancellationToken token = default); + ValueTask> CreateQrLoginAsync(CancellationToken token = default); + + ValueTask> QueryQrLoginStatusAsync(string ticket, CancellationToken token = default); + ValueTask<(string? Aigis, Response Response)> LoginByPasswordAsync(IPassportPasswordProvider provider, CancellationToken token = default); ValueTask<(string? Aigis, Response Response)> LoginByPasswordAsync(string account, string password, string? aigis, CancellationToken token = default); diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/QrLogin.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/QrLogin.cs new file mode 100644 index 000000000..fa5a811a8 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/QrLogin.cs @@ -0,0 +1,13 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Hoyolab.Passport; + +internal sealed class QrLogin +{ + [JsonPropertyName("url")] + public string Url { get; set; } = default!; + + [JsonPropertyName("ticket")] + public string Ticket { get; set; } = default!; +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/QrLoginResult.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/QrLoginResult.cs new file mode 100644 index 000000000..4e407ac97 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/QrLoginResult.cs @@ -0,0 +1,40 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Hoyolab.Passport; + +internal sealed class QrLoginResult +{ + [JsonPropertyName("status")] + public string Status { get; set; } = default!; + + [JsonPropertyName("app_id")] + public string AppId { get; set; } = default!; + + [JsonPropertyName("client_type")] + public int ClientType { get; set; } + + [JsonPropertyName("created_at")] + public string CreatedAt { get; set; } = default!; + + [JsonPropertyName("scanned_at")] + public string ScannedAt { get; set; } = default!; + + [JsonPropertyName("tokens")] + public List Tokens { get; set; } = default!; + + [JsonPropertyName("user_info")] + public UserInformation UserInfo { get; set; } = default!; + + [JsonPropertyName("realname_info")] + public RealnameInfo RealnameInfo { get; set; } = default!; + + [JsonPropertyName("need_realperson")] + public bool NeedRealperson { get; set; } + + [JsonPropertyName("ext")] + public string Ext { get; set; } = default!; + + [JsonPropertyName("scan_game_biz")] + public string ScanGameBiz { get; set; } = default!; +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/QrTicketWrapper.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/QrTicketWrapper.cs new file mode 100644 index 000000000..21e4d90de --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/QrTicketWrapper.cs @@ -0,0 +1,10 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Hoyolab.Passport; + +internal sealed class QrTicketWrapper +{ + [JsonPropertyName("ticket")] + public string Ticket { get; set; } = default!; +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/RealnameInfo.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/RealnameInfo.cs new file mode 100644 index 000000000..d0c54a266 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/RealnameInfo.cs @@ -0,0 +1,16 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Hoyolab.Passport; + +internal sealed class RealnameInfo +{ + [JsonPropertyName("required")] + public bool Required { get; set; } + + [JsonPropertyName("action_type")] + public string ActionType { get; set; } = default!; + + [JsonPropertyName("action_ticket")] + public string ActionTicket { get; set; } = default!; +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Request/Builder/EmptyContent.cs b/src/Snap.Hutao/Snap.Hutao/Web/Request/Builder/EmptyContent.cs new file mode 100644 index 000000000..4f29ad1b6 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Request/Builder/EmptyContent.cs @@ -0,0 +1,6 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Request.Builder; + +internal readonly struct EmptyContent; \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs b/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs index fbf973d49..65e94726a 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs @@ -30,6 +30,11 @@ internal enum KnownReturnCode /// CODEN3503 = -3503, + /// + /// 二维码已过期 + /// + QRLoginExpired = -3501, + /// /// 需要风险验证(闪验) ///