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;
}
}
}
}