feat(upserver): 接入认证与KV存档并完成限定钓鱼闭环
This commit is contained in:
224
Client/Assets/Scripts/Services/Account/S101AccountSession.cs
Normal file
224
Client/Assets/Scripts/Services/Account/S101AccountSession.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user