using System; using System.Globalization; using System.IO; using System.Net; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace game.Account { public enum AccountLoginError { None, Validation, Http, Rejected, Protocol, Network, Timeout, Cancelled } public sealed class AccountLoginResult { public bool Success => Error == AccountLoginError.None; public AccountLoginError Error { get; } public int? StatusCode { get; } internal AccountLoginResult(AccountLoginError error, int? status = null) { Error = error; StatusCode = status; } } /// /// Minimal Tuyou-ID authentication, independent of gameplay and PlayFab storage. /// Each login owns a fresh cookie jar; dispose the session on logout. /// public sealed class GameServerAccountSession : IDisposable { private readonly object gate = new object(); private readonly Uri api; private readonly Func handlerFactory; private HttpClient http; private int generation; private bool disposed; public bool IsAuthenticated { get; private set; } public string PlayerId { get; private set; } public uint InternalId { get; private set; } public DateTime CreateTimeUtc { get; private set; } public DateTime ServerTimeUtc { get; private set; } public bool NewlyCreated { get; private set; } public CookieContainer Cookies { get; private set; } public Uri ApiBaseUri => api; // KV supplies its owned transport; the PF path uses the default handler. public GameServerAccountSession(Uri api, Func handlerFactory = null) { if (api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") || !string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment) || !string.IsNullOrEmpty(api.UserInfo)) { throw new ArgumentException("An absolute HTTP(S) API base URI is required.", nameof(api)); } this.api = new Uri(api.AbsoluteUri.TrimEnd('/') + "/"); this.handlerFactory = handlerFactory ?? (jar => new HttpClientHandler { CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }); } public async Task LoginAsync(string tuyooId, CancellationToken token = default) { HttpClient client; CookieContainer cookies; int requestGeneration; string id; lock (gate) { if (disposed) { return Result(AccountLoginError.Cancelled); } generation++; ClearIdentity(); http?.Dispose(); http = null; Cookies = null; if (string.IsNullOrEmpty(tuyooId) || !long.TryParse(tuyooId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0) { return Result(AccountLoginError.Validation); } if (token.IsCancellationRequested) { return Result(AccountLoginError.Cancelled); } id = numericId.ToString(CultureInfo.InvariantCulture); cookies = new CookieContainer(); client = new HttpClient(handlerFactory(cookies)) { Timeout = TimeSpan.FromSeconds(15) }; http = client; Cookies = cookies; requestGeneration = generation; } int? status = null; try { using (var request = Request("account/Login", new JObject { ["playerId"] = id })) using (var response = await client.SendAsync(request, token)) { status = (int)response.StatusCode; if (response.StatusCode != HttpStatusCode.OK) { return Result(AccountLoginError.Http, status); } var body = Parse(await response.Content.ReadAsStringAsync()); if (body["state"]?.Type != JTokenType.Integer) { return Result(AccountLoginError.Protocol, status); } if (body["state"].Value() != 0) { return Result(AccountLoginError.Rejected, status); } if (body["internalId"]?.Type != JTokenType.Integer || !uint.TryParse(body["internalId"].ToString(), out var internalId) || internalId == 0 || body["newCreate"]?.Type != JTokenType.Boolean || !ReadUtc(body["createTime"], out var created) || !ReadUtc(body["svrTime"], out var serverTime) || cookies.GetCookies(api).Count == 0) { return Result(AccountLoginError.Protocol, status); } lock (gate) { if (disposed || generation != requestGeneration || token.IsCancellationRequested) { return Result(AccountLoginError.Cancelled, status); } PlayerId = id; InternalId = internalId; CreateTimeUtc = created; ServerTimeUtc = serverTime; NewlyCreated = body["newCreate"].Value(); IsAuthenticated = true; } return Result(AccountLoginError.None, status); } } catch (OperationCanceledException) { lock (gate) { return Result(disposed || generation != requestGeneration || token.IsCancellationRequested ? AccountLoginError.Cancelled : AccountLoginError.Timeout, status); } } catch (ObjectDisposedException) { return Result(AccountLoginError.Cancelled, status); } catch (HttpRequestException) { return Result(AccountLoginError.Network, status); } catch (JsonException) { return Result(AccountLoginError.Protocol, status); } catch (OverflowException) { return Result(AccountLoginError.Protocol, status); } } private HttpRequestMessage Request(string path, JObject body) => new HttpRequestMessage(HttpMethod.Post, new Uri(api, path)) { Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json") }; private static JObject Parse(string text) { using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) { var value = JObject.Load(reader); if (reader.Read()) { throw new JsonReaderException("Trailing JSON content."); } return value; } } private static bool ReadUtc(JToken value, out DateTime date) { date = default; return value?.Type == JTokenType.String && DateTime.TryParse(value.Value(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out date); } private static AccountLoginResult Result(AccountLoginError error, int? status = null) => new AccountLoginResult(error, status); private void ClearIdentity() { IsAuthenticated = false; PlayerId = null; InternalId = 0; CreateTimeUtc = default; ServerTimeUtc = default; NewlyCreated = false; } public void Dispose() { lock (gate) { if (disposed) { return; } disposed = true; generation++; ClearIdentity(); http?.Dispose(); http = null; Cookies = null; } } } }