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 S101AccountSession : IDisposable { public const string DefaultApiUrl = "http://s101.local:18080/"; readonly object gate = new object(); readonly Uri api; readonly Func handlerFactory; HttpClient http; int generation; 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; public S101AccountSession() : this(new Uri(DefaultApiUrl)) { } // The factory is optional for transport tests. Returned handlers are owned by this session. public S101AccountSession(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); } } /// Read-only probe: verifies that the server accepts this session cookie; never writes KV. public async Task CheckAuthorizationAsync(CancellationToken token = default) { HttpClient client; int requestGeneration; lock (gate) { if (disposed || !IsAuthenticated) return Result(AccountLoginError.Rejected); client = http; requestGeneration = generation; } int? status = null; try { using (var request = Request("playerdata/Get", new JObject())) using (var response = await client.SendAsync(request, token)) { status = (int)response.StatusCode; lock (gate) { if (disposed || generation != requestGeneration || token.IsCancellationRequested) return Result(AccountLoginError.Cancelled, status); if (status == 401 || status == 403) ClearIdentity(); } if (response.StatusCode != HttpStatusCode.OK) return Result(AccountLoginError.Http, status); var body = Parse(await response.Content.ReadAsStringAsync()); if (!(body["data"] is JObject data)) return Result(AccountLoginError.Protocol, status); foreach (var item in data.Properties()) if (item.Value.Type != JTokenType.String) return Result(AccountLoginError.Protocol, status); lock (gate) return Result(disposed || generation != requestGeneration || token.IsCancellationRequested ? AccountLoginError.Cancelled : 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); } } 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") }; 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; } } 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); } static AccountLoginResult Result(AccountLoginError error, int? status = null) => new AccountLoginResult(error, status); 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; } } } }