225 lines
10 KiB
C#
225 lines
10 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 S101AccountSession : IDisposable
|
|
{
|
|
public const string DefaultApiUrl = "http://s101.local:18080/";
|
|
readonly object gate = new object();
|
|
readonly Uri api;
|
|
readonly Func<CookieContainer, HttpMessageHandler> 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<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); }
|
|
}
|
|
|
|
/// <summary>Read-only probe: verifies that the server accepts this session cookie; never writes KV.</summary>
|
|
public async Task<AccountLoginResult> 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<string>(), 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;
|
|
}
|
|
}
|
|
}
|
|
}
|