// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using System.Collections.Generic; using System.Diagnostics; using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Online.Notifications.WebSocket; using osu.Game.Users; namespace osu.Game.Online.API { public partial class APIAccess : Component, IAPIProvider { private readonly OsuGameBase game; private readonly OsuConfigManager config; private readonly string versionHash; private readonly OAuth authentication; private readonly Queue queue = new Queue(); public string APIEndpointUrl { get; } public string WebsiteRootUrl { get; } public int APIVersion => 20220705; // We may want to pull this from the game version eventually. public Exception LastLoginError { get; private set; } public string ProvidedUsername { get; private set; } public string SecondFactorCode { get; private set; } private string password; public IBindable LocalUser => localUser; public IBindableList Friends => friends; public IBindable Activity => activity; public IBindable Statistics => statistics; public INotificationsClient NotificationsClient { get; } public Language Language => game.CurrentLanguage.Value; private Bindable localUser { get; } = new Bindable(createGuestUser()); private BindableList friends { get; } = new BindableList(); private Bindable activity { get; } = new Bindable(); private Bindable configStatus { get; } = new Bindable(); private Bindable localUserStatus { get; } = new Bindable(); private Bindable statistics { get; } = new Bindable(); protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); private readonly Logger log; public APIAccess(OsuGameBase game, OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash) { this.game = game; this.config = config; this.versionHash = versionHash; APIEndpointUrl = endpointConfiguration.APIEndpointUrl; WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl; NotificationsClient = setUpNotificationsClient(); authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl); log = Logger.GetLogger(LoggingTarget.Network); ProvidedUsername = config.Get(OsuSetting.Username); authentication.TokenString = config.Get(OsuSetting.Token); authentication.Token.ValueChanged += onTokenChanged; config.BindWith(OsuSetting.UserOnlineStatus, configStatus); localUser.BindValueChanged(u => { u.OldValue?.Activity.UnbindFrom(activity); u.NewValue.Activity.BindTo(activity); if (u.OldValue != null) localUserStatus.UnbindFrom(u.OldValue.Status); localUserStatus.BindTo(u.NewValue.Status); }, true); localUserStatus.BindValueChanged(val => configStatus.Value = val.NewValue); var thread = new Thread(run) { Name = "APIAccess", IsBackground = true }; thread.Start(); } private WebSocketNotificationsClientConnector setUpNotificationsClient() { var connector = new WebSocketNotificationsClientConnector(this); connector.MessageReceived += msg => { switch (msg.Event) { case @"verified": if (state.Value == APIState.RequiresSecondFactorAuth) state.Value = APIState.Online; break; case @"logout": if (state.Value == APIState.Online) Logout(); break; } }; return connector; } private void onTokenChanged(ValueChangedEvent e) => config.SetValue(OsuSetting.Token, config.Get(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty); internal new void Schedule(Action action) => base.Schedule(action); public string AccessToken => authentication.RequestAccessToken(); /// /// Number of consecutive requests which failed due to network issues. /// private int failureCount; /// /// The main API thread loop, which will continue to run until the game is shut down. /// private void run() { while (!cancellationToken.IsCancellationRequested) { if (state.Value == APIState.Failing) { // To recover from a failing state, falling through and running the full reconnection process seems safest for now. // This could probably be replaced with a ping-style request if we want to avoid the reconnection overheads. log.Add($@"{nameof(APIAccess)} is in a failing state, waiting a bit before we try again..."); Thread.Sleep(5000); } // Ensure that we have valid credentials. // If not, setting the offline state will allow the game to prompt the user to provide new credentials. if (!HasLogin) { state.Value = APIState.Offline; Thread.Sleep(50); continue; } Debug.Assert(HasLogin); // Ensure that we are in an online state. If not, attempt a connect. if (state.Value != APIState.Online) { attemptConnect(); if (state.Value != APIState.Online) continue; } // hard bail if we can't get a valid access token. if (authentication.RequestAccessToken() == null) { Logout(); continue; } processQueuedRequests(); Thread.Sleep(50); } } /// /// Dequeue from the queue and run each request synchronously until the queue is empty. /// private void processQueuedRequests() { while (true) { APIRequest req; lock (queue) { if (queue.Count == 0) return; req = queue.Dequeue(); } handleRequest(req); } } /// /// From a non-connected state, perform a full connection flow, obtaining OAuth tokens and populating the local user and friends. /// /// /// This method takes control of and transitions from to either /// - (pending 2fa) /// - (successful connection) /// - (failed connection but retrying) /// - (failed and can't retry, clear credentials and require user interaction) /// /// Whether the connection attempt was successful. private void attemptConnect() { if (localUser.IsDefault) { // Show a placeholder user if saved credentials are available. // This is useful for storing local scores and showing a placeholder username after starting the game, // until a valid connection has been established. setLocalUser(new APIUser { Username = ProvidedUsername, Status = { Value = configStatus.Value ?? UserStatus.Online } }); } // save the username at this point, if the user requested for it to be. config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); if (!authentication.HasValidAccessToken) { state.Value = APIState.Connecting; LastLoginError = null; try { authentication.AuthenticateWithLogin(ProvidedUsername, password); } catch (Exception e) { //todo: this fails even on network-related issues. we should probably handle those differently. LastLoginError = e; log.Add($@"Login failed for username {ProvidedUsername} ({LastLoginError.Message})!"); Logout(); return; } } switch (state.Value) { case APIState.RequiresSecondFactorAuth: { if (string.IsNullOrEmpty(SecondFactorCode)) return; state.Value = APIState.Connecting; LastLoginError = null; var verificationRequest = new VerifySessionRequest(SecondFactorCode); verificationRequest.Success += () => state.Value = APIState.Online; verificationRequest.Failure += ex => { state.Value = APIState.RequiresSecondFactorAuth; LastLoginError = ex; SecondFactorCode = null; }; if (!handleRequest(verificationRequest)) { state.Value = APIState.Failing; return; } if (state.Value != APIState.Online) return; break; } default: { var userReq = new GetMeRequest(); userReq.Failure += ex => { if (ex is APIException) { LastLoginError = ex; log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!"); Logout(); } else if (ex is WebException webException && webException.Message == @"Unauthorized") { log.Add(@"Login no longer valid"); Logout(); } else { state.Value = APIState.Failing; } }; userReq.Success += me => { me.Status.Value = configStatus.Value ?? UserStatus.Online; setLocalUser(me); state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth; failureCount = 0; }; if (!handleRequest(userReq)) { state.Value = APIState.Failing; return; } break; } } var friendsReq = new GetFriendsRequest(); friendsReq.Failure += _ => state.Value = APIState.Failing; friendsReq.Success += res => { friends.Clear(); friends.AddRange(res); }; if (!handleRequest(friendsReq)) { state.Value = APIState.Failing; return; } // The Success callback event is fired on the main thread, so we should wait for that to run before proceeding. // Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests // before actually going online. while (State.Value == APIState.Connecting && !cancellationToken.IsCancellationRequested) Thread.Sleep(500); } public void Perform(APIRequest request) { try { request.Perform(this); } catch (Exception e) { // todo: fix exception handling request.Fail(e); } } public Task PerformAsync(APIRequest request) => Task.Factory.StartNew(() => Perform(request), TaskCreationOptions.LongRunning); public void Login(string username, string password) { Debug.Assert(State.Value == APIState.Offline); ProvidedUsername = username; this.password = password; } public void AuthenticateSecondFactor(string code) { Debug.Assert(State.Value == APIState.RequiresSecondFactorAuth); SecondFactorCode = code; } public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack); public IChatClient GetChatClient() => new WebSocketChatClient(this); public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Debug.Assert(State.Value == APIState.Offline); var req = new RegistrationRequest { Url = $@"{APIEndpointUrl}/users", Method = HttpMethod.Post, Username = username, Email = email, Password = password }; try { req.Perform(); } catch (Exception e) { try { return JObject.Parse(req.GetResponseString().AsNonNull()).SelectToken(@"form_error", true).AsNonNull().ToObject(); } catch { try { // attempt to parse a non-form error message var response = JObject.Parse(req.GetResponseString().AsNonNull()); string redirect = (string)response.SelectToken(@"url", true); string message = (string)response.SelectToken(@"error", false); if (!string.IsNullOrEmpty(redirect)) { return new RegistrationRequest.RegistrationRequestErrors { Redirect = redirect, Message = message, }; } // if we couldn't deserialize the error message let's throw the original exception outwards. e.Rethrow(); } catch { // if we couldn't deserialize the error message let's throw the original exception outwards. e.Rethrow(); } } } return null; } /// /// Handle a single API request. /// Ensures all exceptions are caught and dealt with correctly. /// /// The request. /// true if the request succeeded. private bool handleRequest(APIRequest req) { try { req.Perform(this); if (req.CompletionState != APIRequestCompletionState.Completed) return false; // Reset failure count if this request succeeded. failureCount = 0; return true; } catch (HttpRequestException re) { log.Add($"{nameof(HttpRequestException)} while performing request {req}: {re.Message}"); handleFailure(); return false; } catch (SocketException se) { log.Add($"{nameof(SocketException)} while performing request {req}: {se.Message}"); handleFailure(); return false; } catch (WebException we) { log.Add($"{nameof(WebException)} while performing request {req}: {we.Message}"); handleWebException(we); return false; } catch (Exception ex) { Logger.Error(ex, "Error occurred while handling an API request."); return false; } } private readonly Bindable state = new Bindable(); /// /// The current connectivity state of the API. /// public IBindable State => state; private void handleWebException(WebException we) { HttpStatusCode statusCode = (we.Response as HttpWebResponse)?.StatusCode ?? (we.Status == WebExceptionStatus.UnknownError ? HttpStatusCode.NotAcceptable : HttpStatusCode.RequestTimeout); // special cases for un-typed but useful message responses. switch (we.Message) { case "Unauthorized": case "Forbidden": statusCode = HttpStatusCode.Unauthorized; break; } switch (statusCode) { case HttpStatusCode.Unauthorized: Logout(); break; case HttpStatusCode.RequestTimeout: handleFailure(); break; } } private void handleFailure() { failureCount++; log.Add($@"API failure count is now {failureCount}"); if (failureCount >= 3) { state.Value = APIState.Failing; flushQueue(); } } public bool IsLoggedIn => State.Value > APIState.Offline; public void Queue(APIRequest request) { lock (queue) { if (state.Value == APIState.Offline) { request.Fail(new WebException(@"User not logged in")); return; } queue.Enqueue(request); } } private void flushQueue(bool failOldRequests = true) { lock (queue) { var oldQueueRequests = queue.ToArray(); queue.Clear(); if (failOldRequests) { foreach (var req in oldQueueRequests) req.Fail(new WebException($@"Request failed from flush operation (state {state.Value})")); } } } public void Logout() { password = null; SecondFactorCode = null; authentication.Clear(); // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present Schedule(() => { setLocalUser(createGuestUser()); friends.Clear(); }); state.Value = APIState.Offline; flushQueue(); } public void UpdateStatistics(UserStatistics newStatistics) { statistics.Value = newStatistics; if (IsLoggedIn) localUser.Value.Statistics = newStatistics; } private static APIUser createGuestUser() => new GuestUser(); private void setLocalUser(APIUser user) => Scheduler.Add(() => { localUser.Value = user; statistics.Value = user.Statistics; }, false); protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); flushQueue(); cancellationToken.Cancel(); } } internal class GuestUser : APIUser { public GuestUser() { Username = @"Guest"; Id = SYSTEM_USER_ID; } } public enum APIState { /// /// We cannot login (not enough credentials). /// Offline, /// /// We are having connectivity issues. /// Failing, /// /// Waiting on second factor authentication. /// RequiresSecondFactorAuth, /// /// We are in the process of (re-)connecting. /// Connecting, /// /// We are online. /// Online } }