279 lines
9.3 KiB
C#
279 lines
9.3 KiB
C#
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;
|
|
}
|
|
}
|
|
/// <summary>
|
|
/// Minimal Tuyou-ID authentication, independent of gameplay and PlayFab storage.
|
|
/// Each login owns a fresh cookie jar; dispose the session on logout.
|
|
/// </summary>
|
|
public sealed class GameServerAccountSession : IDisposable
|
|
{
|
|
private readonly object gate = new object();
|
|
private readonly Uri api;
|
|
private readonly Func<CookieContainer, HttpMessageHandler> 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<CookieContainer, HttpMessageHandler> 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<AccountLoginResult> 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<long>() != 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<bool>();
|
|
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<string>(), 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;
|
|
}
|
|
}
|
|
}
|
|
}
|