Files
ichni_Official/Assets/Scripts/Online/Logic/AuthService.cs
2026-06-18 18:03:47 +08:00

454 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Text;
using System.Threading;
using Cysharp.Threading.Tasks;
using IchniOnline.Online.Models;
using IchniOnline.Online.Network;
using IchniOnline.Online.Network.Models;
using TapSDK.Login;
using UnityEngine;
namespace IchniOnline.Online.Logic
{
/// <summary>
/// Authentication orchestration service that coordinates TapTap login,
/// password login, registration, and logout flows.
/// Pure orchestration — no UI state management. Results are delivered via events.
/// </summary>
public static class IchniOnlineAuthService
{
/// <summary>
/// 登录成功时触发参数为服务端返回的登录响应Register 成功时可能为 null
/// </summary>
public static event Action<LoginResponseDto> OnLoginSuccess;
/// <summary>
/// 登录失败时触发,参数为错误信息
/// </summary>
public static event Action<string> OnLoginFailed;
/// <summary>
/// 登录被用户取消时触发
/// </summary>
public static event Action OnLoginCanceled;
/// <summary>
/// TapTap 未绑定账号时触发,参数为 pendingBindOauthId 和浏览器绑定页面 URL
/// </summary>
public static event Action<string, string> OnTapTapUnbound;
/// <summary>
/// 是否正在进行登录流程,用于防止并发登录请求
/// </summary>
public static bool IsLoggingIn { get; private set; }
/// <summary>
/// 当前未绑定的 TapTap oauthId
/// </summary>
private static string _pendingBindOauthId;
private static CancellationTokenSource _pollingCts;
private const string WebBaseUrl = "https://ichni.hoshino.fan";
#region TapTap Login
/// <summary>
/// 使用 TapTap 登录的完整流程:
/// TapTap SDK → 第三方登录 API → JWT → 缓存 → 事件
/// </summary>
public static void LoginWithTapTap()
{
if (IsLoggingIn)
{
Debug.LogWarning("[IchniOnlineAuthService] 已有登录流程正在进行中");
return;
}
IsLoggingIn = true;
Action<TapTapAccount> onSuccess = null;
Action onCanceled = null;
Action<string> onFailed = null;
onSuccess = account =>
{
UnsubscribeTapTapEvents(onSuccess, onCanceled, onFailed);
CompleteTapTapLoginAsync(account).Forget();
};
onCanceled = () =>
{
UnsubscribeTapTapEvents(onSuccess, onCanceled, onFailed);
IsLoggingIn = false;
OnLoginCanceled?.Invoke();
};
onFailed = errorMessage =>
{
UnsubscribeTapTapEvents(onSuccess, onCanceled, onFailed);
IsLoggingIn = false;
OnLoginFailed?.Invoke($"TapTap 登录失败: {errorMessage}");
};
ThirdPartyServiceManager.Instance.OnLoginSuccess += onSuccess;
ThirdPartyServiceManager.Instance.OnLoginCanceled += onCanceled;
ThirdPartyServiceManager.Instance.OnLoginFailed += onFailed;
ThirdPartyServiceManager.Instance.StartTapTapLogin();
}
private static async UniTaskVoid CompleteTapTapLoginAsync(TapTapAccount account)
{
if (account?.accessToken == null)
{
IsLoggingIn = false;
OnLoginFailed?.Invoke("TapTap 登录成功但 accessToken 为空");
return;
}
var dto = new ThirdPartyLoginRequestDto
{
token = account.accessToken.kid,
tokenType = account.accessToken.tokenType,
macKey = account.accessToken.macKey,
macAlgorithm = account.accessToken.macAlgorithm
};
try
{
var result = await IchniOnlineApiClient.Instance.PostAsync<LoginResponseDto>("/api/auth/third-party/login", dto);
Debug.Log(JsonUtility.ToJson(result.Data));
IsLoggingIn = false;
if (result.IsSuccess && result.Data != null)
{
if (!string.IsNullOrEmpty(result.Data.pendingBindOauthId))
{
_pendingBindOauthId = result.Data.pendingBindOauthId;
var bindUrl = $"{WebBaseUrl}/bind?method=0&id={_pendingBindOauthId}";
OnTapTapUnbound?.Invoke(_pendingBindOauthId, bindUrl);
OpenBrowserBindPage(bindUrl);
_pollingCts?.Cancel();
_pollingCts = new CancellationTokenSource();
PollBindStatusAsync(_pendingBindOauthId, _pollingCts.Token).Forget();
}
else
{
LoginCacheManager.SaveAuthSession(result.Data.token, result.Data);
IchniOnlineApiClient.Instance.JwtToken = result.Data.token;
OnLoginSuccess?.Invoke(result.Data);
}
}
else if (result.IsSuccess && result.Data == null)
{
OnLoginFailed?.Invoke("TapTap login successful but account not bound");
}
else
{
string errorMessage = $"第三方登录 API 失败: {result.Message}";
if (!string.IsNullOrEmpty(result.ErrorDetail))
errorMessage += $" ({result.ErrorDetail})";
OnLoginFailed?.Invoke(errorMessage);
}
}
catch (Exception ex)
{
IsLoggingIn = false;
Debug.LogError($"[IchniOnlineAuthService] TapTap 登录 API 异常: {ex}");
OnLoginFailed?.Invoke($"TapTap 登录 API 异常: {ex.Message}");
}
}
private static void OpenBrowserBindPage(string url)
{
Application.OpenURL(url);
}
private static async UniTaskVoid PollBindStatusAsync(string oauthId, CancellationToken cancellationToken)
{
var startTime = DateTime.UtcNow;
var timeout = TimeSpan.FromMinutes(5);
var pollInterval = TimeSpan.FromSeconds(2);
while (!cancellationToken.IsCancellationRequested)
{
if (DateTime.UtcNow - startTime > timeout)
{
IsLoggingIn = false;
OnLoginFailed?.Invoke("绑定超时");
return;
}
try
{
var result = await IchniOnlineApiClient.Instance.GetAsync<BindStatusDto>(
$"/api/auth/third-party/bind-status?oauthId={oauthId}",
cancellationToken);
if (result.IsSuccess && result.Data != null)
{
if (result.Data.status == "bound" && !string.IsNullOrEmpty(result.Data.token))
{
var loginResponse = new LoginResponseDto
{
token = result.Data.token,
user = result.Data.user
};
LoginCacheManager.SaveAuthSession(result.Data.token, loginResponse);
IchniOnlineApiClient.Instance.JwtToken = result.Data.token;
IsLoggingIn = false;
OnLoginSuccess?.Invoke(loginResponse);
return;
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"[IchniOnlineAuthService] Polling error: {ex.Message}");
}
await UniTask.Delay(pollInterval,DelayType.Realtime,PlayerLoopTiming.Update,cancellationToken);
}
}
public static void HandleIchniProtocolCallback(string token)
{
if (string.IsNullOrEmpty(token))
{
OnLoginFailed?.Invoke("浏览器回调 token 为空");
return;
}
_pollingCts?.Cancel();
_pollingCts = null;
_pendingBindOauthId = null;
IchniOnlineApiClient.Instance.JwtToken = token;
LoginCacheData cachedData = LoginCacheManager.CachedData;
if (cachedData != null)
{
cachedData.jwtToken = token;
cachedData.hasServerSession = true;
}
OnLoginSuccess?.Invoke(new LoginResponseDto
{
token = token,
user = cachedData != null ? new UserResponseDto
{
userId = cachedData.userId,
username = cachedData.name,
displayName = cachedData.displayName,
avatarUrl = cachedData.avatarUrl,
permission = cachedData.permission
} : null
});
}
private static void UnsubscribeTapTapEvents(
Action<TapTapAccount> onSuccess,
Action onCanceled,
Action<string> onFailed)
{
if (ThirdPartyServiceManager.Instance != null)
{
ThirdPartyServiceManager.Instance.OnLoginSuccess -= onSuccess;
ThirdPartyServiceManager.Instance.OnLoginCanceled -= onCanceled;
ThirdPartyServiceManager.Instance.OnLoginFailed -= onFailed;
}
}
#endregion
#region Password Login
/// <summary>
/// 使用用户名和密码登录的完整流程:
/// 获取 session-key → XOR 加密密码 → 登录 API → JWT → 缓存 → 事件
/// </summary>
public static void LoginWithPassword(string username, string password)
{
if (IsLoggingIn)
{
Debug.LogWarning("[IchniOnlineAuthService] 已有登录流程正在进行中");
return;
}
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
OnLoginFailed?.Invoke("用户名或密码不能为空");
return;
}
IsLoggingIn = true;
LoginWithPasswordAsync(username, password).Forget();
}
private static async UniTaskVoid LoginWithPasswordAsync(string username, string password)
{
try
{
// 1. 获取 session-key
var sessionResult = await IchniOnlineApiClient.Instance.GetAsync<string>("/api/auth/session-key");
if (!sessionResult.IsSuccess)
{
IsLoggingIn = false;
string errorMessage = $"获取 session-key 失败: {sessionResult.Message}";
if (!string.IsNullOrEmpty(sessionResult.ErrorDetail))
errorMessage += $" ({sessionResult.ErrorDetail})";
OnLoginFailed?.Invoke(errorMessage);
return;
}
string sessionKey = sessionResult.Data;
// 2. XOR 加密密码
string encryptedPassword = EncryptPassword(password, sessionKey);
// 3. 调用登录 API
var loginDto = new LoginRequestDto
{
username = username,
encryptedPassword = encryptedPassword,
sessionKey = sessionKey
};
var loginResult = await IchniOnlineApiClient.Instance.PostAsync<LoginResponseDto>("/api/auth/login", loginDto);
IsLoggingIn = false;
if (loginResult.IsSuccess)
{
LoginCacheManager.SaveAuthSession(loginResult.Data.token, loginResult.Data);
IchniOnlineApiClient.Instance.JwtToken = loginResult.Data.token;
OnLoginSuccess?.Invoke(loginResult.Data);
}
else
{
string errorMessage = $"登录失败: {loginResult.Message}";
if (!string.IsNullOrEmpty(loginResult.ErrorDetail))
errorMessage += $" ({loginResult.ErrorDetail})";
OnLoginFailed?.Invoke(errorMessage);
}
}
catch (Exception ex)
{
IsLoggingIn = false;
Debug.LogError($"[IchniOnlineAuthService] 密码登录异常: {ex}");
OnLoginFailed?.Invoke($"登录异常: {ex.Message}");
}
}
#endregion
#region Register
/// <summary>
/// 用户注册流程POST /api/auth/register
/// 注册成功后触发 OnLoginSuccess(null)(注册不返回 JWT
/// </summary>
public static void Register(string username, string password, string displayName)
{
if (IsLoggingIn)
{
Debug.LogWarning("[IchniOnlineAuthService] 已有登录流程正在进行中");
return;
}
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
OnLoginFailed?.Invoke("用户名或密码不能为空");
return;
}
IsLoggingIn = true;
RegisterAsync(username, password, displayName).Forget();
}
private static async UniTaskVoid RegisterAsync(string username, string password, string displayName)
{
try
{
var registerDto = new RegisterRequestDto
{
username = username,
password = password,
displayName = displayName
};
var result = await IchniOnlineApiClient.Instance.PostAsync<UserResponseDto>("/api/auth/register", registerDto);
IsLoggingIn = false;
if (result.IsSuccess)
{
OnLoginSuccess?.Invoke(null);
}
else
{
string errorMessage = $"注册失败: {result.Message}";
if (!string.IsNullOrEmpty(result.ErrorDetail))
errorMessage += $" ({result.ErrorDetail})";
OnLoginFailed?.Invoke(errorMessage);
}
}
catch (Exception ex)
{
IsLoggingIn = false;
Debug.LogError($"[IchniOnlineAuthService] 注册异常: {ex}");
OnLoginFailed?.Invoke($"注册异常: {ex.Message}");
}
}
#endregion
#region Logout
/// <summary>
/// 登出流程:清除本地会话、登出 TapTap、清除 API JWT Token
/// </summary>
public static void Logout()
{
LoginCacheManager.ClearSession();
ThirdPartyServiceManager.Instance?.Logout();
IchniOnlineApiClient.Instance.JwtToken = null;
Debug.Log("[IchniOnlineAuthService] 已登出");
}
#endregion
#region Encryption
/// <summary>
/// 使用 XOR 算法加密密码,与服务器 UserService.DecryptPassword 对应。
/// sessionKey 为服务器返回的 Base64 字符串。
/// </summary>
public static string EncryptPassword(string password, string sessionKey)
{
if (string.IsNullOrEmpty(password))
throw new ArgumentException("密码不能为空", nameof(password));
if (string.IsNullOrEmpty(sessionKey))
throw new ArgumentException("sessionKey 不能为空", nameof(sessionKey));
byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
byte[] sessionBytes = Convert.FromBase64String(sessionKey);
byte[] encrypted = new byte[passwordBytes.Length];
for (int i = 0; i < passwordBytes.Length; i++)
{
encrypted[i] = (byte)(passwordBytes[i] ^ sessionBytes[i % sessionBytes.Length]);
}
return Convert.ToBase64String(encrypted);
}
#endregion
}
}