diff --git a/osu-framework b/osu-framework index 3bbfe01375..79572e2da7 160000 --- a/osu-framework +++ b/osu-framework @@ -1 +1 @@ -Subproject commit 3bbfe0137546497e767f7863cda66efc42b1686c +Subproject commit 79572e2da7d5f0467f6fd6ad277cfbade2e21b79 diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 5f39925307..367b64a07a 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -2,6 +2,7 @@ //Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using osu.Framework.Configuration; +using osu.Game.Online.API; namespace osu.Game.Configuration { @@ -12,6 +13,10 @@ namespace osu.Game.Configuration Set(OsuConfig.Width, 1366); Set(OsuConfig.Height, 768); Set(OsuConfig.MouseSensitivity, 1.0); + + Set(OsuConfig.Username, string.Empty); + Set(OsuConfig.Password, string.Empty); + Set(OsuConfig.Token, string.Empty); } } @@ -20,5 +25,8 @@ namespace osu.Game.Configuration Width, Height, MouseSensitivity, + Username, + Password, + Token } } diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs new file mode 100644 index 0000000000..59b3d91d51 --- /dev/null +++ b/osu.Game/Online/API/APIAccess.cs @@ -0,0 +1,277 @@ +//Copyright (c) 2007-2016 ppy Pty Ltd . +//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; +using System.Collections.Concurrent; +using System.Net; +using System.Threading; +using osu.Framework.Logging; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Online.API +{ + internal class APIAccess + { + private OAuth authentication; + + internal string Endpoint = @"https://new.ppy.sh"; + const string ClientId = @"daNBnfdv7SppRVc61z0XuOI13y6Hroiz"; + const string ClientSecret = @"d6fgZuZeQ0eSXkEj5igdqQX6ztdtS6Ow"; + + ConcurrentQueue queue = new ConcurrentQueue(); + + public string Username; + + private SecurePassword password; + + public string Password + { + set + { + password = string.IsNullOrEmpty(value) ? null : new SecurePassword(value); + } + } + + public string Token + { + get { return authentication.Token?.ToString(); } + + set + { + + if (string.IsNullOrEmpty(value)) + authentication.Token = null; + else + authentication.Token = OAuthToken.Parse(value); + } + } + + protected bool HasLogin => Token != null || (!string.IsNullOrEmpty(Username) && password != null); + + private Thread thread; + + Logger log; + + internal APIAccess() + { + authentication = new OAuth(ClientId, ClientSecret, Endpoint); + log = Logger.GetLogger(LoggingTarget.Network); + + thread = new Thread(run) { IsBackground = true }; + thread.Start(); + } + + internal string AccessToken => authentication.RequestAccessToken(); + + /// + /// Number of consecutive requests which failed due to network issues. + /// + int failureCount = 0; + + private void run() + { + while (true) + { + switch (State) + { + case APIState.Failing: + //todo: replace this with a ping request. + log.Add($@"In a failing state, waiting a bit before we try again..."); + Thread.Sleep(5000); + if (queue.Count == 0) + { + log.Add($@"Queueing a ping request"); + Queue(new ListChannelsRequest() { Timeout = 5000 }); + } + break; + case APIState.Offline: + //work to restore a connection... + if (!HasLogin) + { + //OsuGame.Scheduler.Add(() => { OsuGame.ShowLogin(); }); + + State = APIState.Offline; + Thread.Sleep(500); + continue; + } + + if (State < APIState.Connecting) + State = APIState.Connecting; + + if (!authentication.HasValidAccessToken && !authentication.AuthenticateWithLogin(Username, password.Get(Representation.Raw))) + { + //todo: this fails even on network-related issues. we should probably handle those differently. + //NotificationManager.ShowMessage("Login failed!"); + log.Add(@"Login failed!"); + ClearCredentials(); + continue; + } + + //we're connected! + State = APIState.Online; + failureCount = 0; + break; + } + + //hard bail if we can't get a valid access token. + if (authentication.RequestAccessToken() == null) + { + State = APIState.Offline; + continue; + } + + //process the request queue. + APIRequest req; + while (queue.TryPeek(out req)) + { + if (handleRequest(req)) + { + //we have succeeded, so let's unqueue. + queue.TryDequeue(out req); + } + } + + Thread.Sleep(1); + } + } + + private void ClearCredentials() + { + Username = null; + password = null; + } + + /// + /// Handle a single API request. + /// + /// The request. + /// true if we should remove this request from the queue. + private bool handleRequest(APIRequest req) + { + try + { + req.Perform(this); + + State = APIState.Online; + failureCount = 0; + return true; + } + catch (WebException we) + { + HttpStatusCode statusCode = (we.Response as HttpWebResponse)?.StatusCode ?? HttpStatusCode.RequestTimeout; + + switch (statusCode) + { + case HttpStatusCode.Unauthorized: + State = APIState.Offline; + return true; + case HttpStatusCode.RequestTimeout: + failureCount++; + log.Add($@"API failure count is now {failureCount}"); + + if (failureCount < 3) + //we might try again at an api level. + return false; + + State = APIState.Failing; + return true; + } + + req.Fail(we); + return true; + } + catch (Exception e) + { + if (e is TimeoutException) + log.Add(@"API level timeout exception was hit"); + + req.Fail(e); + return true; + } + } + + private APIState state; + public APIState State + { + get { return state; } + set + { + APIState oldState = state; + APIState newState = value; + + state = value; + + switch (state) + { + case APIState.Failing: + case APIState.Offline: + flushQueue(); + break; + } + + if (oldState != newState) + { + //OsuGame.Scheduler.Add(delegate + { + //NotificationManager.ShowMessage($@"We just went {newState}!", newState == APIState.Online ? Color4.YellowGreen : Color4.OrangeRed, 5000); + log.Add($@"We just went {newState}!"); + OnStateChange?.Invoke(oldState, newState); + } + } + } + } + + internal void Queue(APIRequest request) + { + queue.Enqueue(request); + } + + internal event StateChangeDelegate OnStateChange; + + internal delegate void StateChangeDelegate(APIState oldState, APIState newState); + + internal enum APIState + { + /// + /// We cannot login (not enough credentials). + /// + Offline, + + /// + /// We are having connectivity issues. + /// + Failing, + + /// + /// We are in the process of (re-)connecting. + /// + Connecting, + + /// + /// We are online. + /// + Online + } + + private void flushQueue(bool failOldRequests = true) + { + var oldQueue = queue; + + //flush the queue. + queue = new ConcurrentQueue(); + + if (failOldRequests) + { + APIRequest req; + while (queue.TryDequeue(out req)) + req.Fail(new Exception(@"Disconnected from server")); + } + } + + internal void Logout() + { + authentication.Clear(); + State = APIState.Offline; + } + } +} diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs new file mode 100644 index 0000000000..63689d5b38 --- /dev/null +++ b/osu.Game/Online/API/APIRequest.cs @@ -0,0 +1,93 @@ +//Copyright (c) 2007-2016 ppy Pty Ltd . +//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API +{ + /// + /// An API request with a well-defined response type. + /// + /// Type of the response (used for deserialisation). + internal class APIRequest : APIRequest + { + protected override WebRequest CreateWebRequest() => new JsonWebRequest(Uri); + + public APIRequest() + { + base.Success += onSuccess; + } + + private void onSuccess() + { + Success?.Invoke((WebRequest as JsonWebRequest).ResponseObject); + } + + public new event APISuccessHandler Success; + } + + /// + /// AN API request with no specified response type. + /// + public class APIRequest + { + /// + /// The maximum amount of time before this request will fail. + /// + internal int Timeout = WebRequest.DEFAULT_TIMEOUT; + + protected virtual string Target => string.Empty; + + protected virtual WebRequest CreateWebRequest() => new WebRequest(Uri); + + protected virtual string Uri => $@"{api.Endpoint}/api/v2/{Target}"; + + private double remainingTime => Math.Max(0, Timeout - (DateTime.Now.TotalMilliseconds() - (startTime ?? 0))); + + internal bool ExceededTimeout => remainingTime == 0; + + private double? startTime; + + internal double StartTime => startTime ?? -1; + + private APIAccess api; + protected WebRequest WebRequest; + + public event APISuccessHandler Success; + public event APIFailureHandler Failure; + + internal void Perform(APIAccess api) + { + if (startTime == null) + startTime = DateTime.Now.TotalMilliseconds(); + + this.api = api; + + if (remainingTime <= 0) + throw new TimeoutException(@"API request timeout hit"); + + WebRequest = CreateWebRequest(); + WebRequest.RetryCount = 0; + WebRequest.Headers[@"Authorization"] = $@"Bearer {api.AccessToken}"; + + WebRequest.BlockingPerform(); + + //OsuGame.Scheduler.Add(delegate { + Success?.Invoke(); + //}); + } + + internal void Fail(Exception e) + { + WebRequest?.Abort(); + //OsuGame.Scheduler.Add(delegate { + Failure?.Invoke(e); + //}); + } + } + + public delegate void APIFailureHandler(Exception e); + public delegate void APISuccessHandler(); + public delegate void APISuccessHandler(T content); +} diff --git a/osu.Game/Online/API/OAuth.cs b/osu.Game/Online/API/OAuth.cs new file mode 100644 index 0000000000..bbd33c0b95 --- /dev/null +++ b/osu.Game/Online/API/OAuth.cs @@ -0,0 +1,162 @@ +//Copyright (c) 2007-2016 ppy Pty Ltd . +//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; +using System.Diagnostics; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API +{ + internal class OAuth + { + private readonly string clientId; + private readonly string clientSecret; + private readonly string endpoint; + + public OAuthToken Token; + + internal OAuth(string clientId, string clientSecret, string endpoint) + { + Debug.Assert(clientId != null); + Debug.Assert(clientSecret != null); + Debug.Assert(endpoint != null); + + this.clientId = clientId; + this.clientSecret = clientSecret; + this.endpoint = endpoint; + } + + internal bool AuthenticateWithLogin(string username, string password) + { + var req = new AccessTokenRequestPassword(username, password) + { + Url = $@"{endpoint}/oauth/access_token", + Method = HttpMethod.POST, + ClientId = clientId, + ClientSecret = clientSecret + }; + + try + { + req.BlockingPerform(); + } + catch + { + return false; + } + + Token = req.ResponseObject; + return true; + } + + internal bool AuthenticateWithRefresh(string refresh) + { + try + { + var req = new AccessTokenRequestRefresh(refresh) + { + Url = $@"{endpoint}/oauth/access_token", + Method = HttpMethod.POST, + ClientId = clientId, + ClientSecret = clientSecret + }; + req.BlockingPerform(); + + Token = req.ResponseObject; + return true; + } + catch (Exception e) + { + //todo: potentially only kill the refresh token on certain exception types. + Token = null; + return false; + } + } + + /// + /// Should be run before any API request to make sure we have a valid key. + /// + private bool ensureAccessToken() + { + //todo: we need to mutex this to ensure only one authentication request is running at a time. + + //If we already have a valid access token, let's use it. + if (accessTokenValid) return true; + + //If not, let's try using our refresh token to request a new access token. + if (!string.IsNullOrEmpty(Token?.RefreshToken)) + AuthenticateWithRefresh(Token.RefreshToken); + + return accessTokenValid; + } + + private bool accessTokenValid => Token?.IsValid ?? false; + + internal bool HasValidAccessToken => RequestAccessToken() != null; + + internal string RequestAccessToken() + { + if (!ensureAccessToken()) return null; + + return Token.AccessToken; + } + + internal void Clear() + { + Token = null; + } + + private class AccessTokenRequestRefresh : AccessTokenRequest + { + internal readonly string RefreshToken; + + internal AccessTokenRequestRefresh(string refreshToken) + { + RefreshToken = refreshToken; + GrantType = @"refresh_token"; + } + + protected override void PrePerform() + { + Parameters[@"refresh_token"] = RefreshToken; + base.PrePerform(); + } + } + + private class AccessTokenRequestPassword : AccessTokenRequest + { + internal readonly string Username; + internal readonly string Password; + + internal AccessTokenRequestPassword(string username, string password) + { + Username = username; + Password = password; + GrantType = @"password"; + } + + protected override void PrePerform() + { + Parameters[@"username"] = Username; + Parameters[@"password"] = Password; + base.PrePerform(); + } + } + + private class AccessTokenRequest : JsonWebRequest + { + protected string GrantType; + + internal string ClientId; + internal string ClientSecret; + + protected override void PrePerform() + { + Parameters[@"grant_type"] = GrantType; + Parameters[@"client_id"] = ClientId; + Parameters[@"client_secret"] = ClientSecret; + base.PrePerform(); + } + } + } +} diff --git a/osu.Game/Online/API/OAuthToken.cs b/osu.Game/Online/API/OAuthToken.cs new file mode 100644 index 0000000000..aa45020a32 --- /dev/null +++ b/osu.Game/Online/API/OAuthToken.cs @@ -0,0 +1,65 @@ +//Copyright (c) 2007-2016 ppy Pty Ltd . +//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; +using System.Globalization; +using Newtonsoft.Json; + +namespace osu.Game.Online.API +{ + [Serializable] + internal class OAuthToken + { + /// + /// OAuth 2.0 access token. + /// + [JsonProperty(@"access_token")] + public string AccessToken; + + [JsonProperty(@"expires_in")] + public long ExpiresIn + { + get + { + return AccessTokenExpiry - DateTime.Now.ToUnixTimestamp(); + } + + set + { + AccessTokenExpiry = DateTime.Now.AddSeconds(value).ToUnixTimestamp(); + } + } + + public bool IsValid => !string.IsNullOrEmpty(AccessToken) && ExpiresIn > 30; + + public long AccessTokenExpiry; + + /// + /// OAuth 2.0 refresh token. + /// + [JsonProperty(@"refresh_token")] + public string RefreshToken; + + public override string ToString() => $@"{AccessToken}/{AccessTokenExpiry.ToString(NumberFormatInfo.InvariantInfo)}/{RefreshToken}"; + + public static OAuthToken Parse(string value) + { + try + { + string[] parts = value.Split('/'); + return new OAuthToken() + { + AccessToken = parts[0], + AccessTokenExpiry = long.Parse(parts[1], NumberFormatInfo.InvariantInfo), + RefreshToken = parts[2] + }; + } + catch + { + + } + + return null; + } + } +} diff --git a/osu.Game/Online/API/Requests/GetMessagesRequest.cs b/osu.Game/Online/API/Requests/GetMessagesRequest.cs new file mode 100644 index 0000000000..4de206e8c0 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetMessagesRequest.cs @@ -0,0 +1,37 @@ +//Copyright (c) 2007-2016 ppy Pty Ltd . +//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System.Collections.Generic; +using osu.Framework.IO.Network; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.API.Requests +{ + internal class GetMessagesRequest : APIRequest> + { + List channels; + long? since; + + public GetMessagesRequest(List channels, long? sinceId) + { + this.channels = channels; + this.since = sinceId; + } + + protected override WebRequest CreateWebRequest() + { + string channelString = string.Empty; + foreach (Channel c in channels) + channelString += c.Id + ","; + channelString = channelString.TrimEnd(','); + + var req = base.CreateWebRequest(); + req.AddParameter(@"channels", channelString); + if (since.HasValue) req.AddParameter(@"since", since.Value.ToString()); + + return req; + } + + protected override string Target => @"chat/messages"; + } +} \ No newline at end of file diff --git a/osu.Game/Online/API/Requests/ListChannels.cs b/osu.Game/Online/API/Requests/ListChannels.cs new file mode 100644 index 0000000000..5592f8aa04 --- /dev/null +++ b/osu.Game/Online/API/Requests/ListChannels.cs @@ -0,0 +1,13 @@ +//Copyright (c) 2007-2016 ppy Pty Ltd . +//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System.Collections.Generic; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.API.Requests +{ + internal class ListChannelsRequest : APIRequest> + { + protected override string Target => @"chat/channels"; + } +} diff --git a/osu.Game/Online/API/SecurePassword.cs b/osu.Game/Online/API/SecurePassword.cs new file mode 100644 index 0000000000..4e39ce1fff --- /dev/null +++ b/osu.Game/Online/API/SecurePassword.cs @@ -0,0 +1,52 @@ +//Copyright (c) 2007-2016 ppy Pty Ltd . +//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; +using System.Security; + +namespace osu.Game.Online.API +{ + internal class SecurePassword + { + private readonly SecureString storage = new SecureString(); + private readonly Representation representation; + + //todo: move this to a central constants file. + private const string password_entropy = @"cu24180ncjeiu0ci1nwui"; + + public SecurePassword(string input, bool encrypted = false) + { + //if (encrypted) + //{ + // string rep; + // input = DPAPI.Decrypt(input, password_entropy, out rep); + // Enum.TryParse(rep, out representation); + //} + //else + { + representation = Representation.Raw; + } + + foreach (char c in input) + storage.AppendChar(c); + storage.MakeReadOnly(); + } + + internal string Get(Representation request = Representation.Raw) + { + switch (request) + { + default: + return storage.UnsecureRepresentation(); + //case Representation.Encrypted: + // return DPAPI.Encrypt(DPAPI.KeyType.UserKey, storage.UnsecureRepresentation(), password_entropy, representation.ToString()); + } + } + } + + enum Representation + { + Raw, + Encrypted + } +} diff --git a/osu.Game/Online/Chat/Channel.cs b/osu.Game/Online/Chat/Channel.cs new file mode 100644 index 0000000000..7c0b31a18f --- /dev/null +++ b/osu.Game/Online/Chat/Channel.cs @@ -0,0 +1,32 @@ +//Copyright (c) 2007-2016 ppy Pty Ltd . +//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace osu.Game.Online.Chat +{ + public class Channel + { + [JsonProperty(@"name")] + public string Name; + + [JsonProperty(@"description")] + public string Topic; + + [JsonProperty(@"type")] + public string Type; + + [JsonProperty(@"channel_id")] + public int Id; + + public List Messages = new List(); + + internal bool Joined; + + [JsonConstructor] + public Channel() + { + } + } +} diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs new file mode 100644 index 0000000000..ecf91c481d --- /dev/null +++ b/osu.Game/Online/Chat/Message.cs @@ -0,0 +1,34 @@ +//Copyright (c) 2007-2016 ppy Pty Ltd . +//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; +using Newtonsoft.Json; + +namespace osu.Game.Online.Chat +{ + public class Message + { + [JsonProperty(@"message_id")] + public long Id; + + [JsonProperty(@"user_id")] + public string UserId; + + [JsonProperty(@"channel_id")] + public string ChannelId; + + [JsonProperty(@"timestamp")] + public DateTime Timestamp; + + [JsonProperty(@"content")] + internal string Content; + + [JsonProperty(@"sender")] + internal string User; + + [JsonConstructor] + public Message() + { + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 0bccbd02c3..355472c437 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1,12 +1,15 @@ //Copyright (c) 2007-2016 ppy Pty Ltd . //Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE +using System.Collections.Generic; using System.Drawing; using osu.Framework.Framework; using osu.Game.Configuration; using osu.Game.GameModes.Menu; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Processing; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; namespace osu.Game { @@ -16,6 +19,8 @@ namespace osu.Game protected override string MainResourceFile => @"osu.Game.Resources.dll"; + internal APIAccess API; + public override void Load() { base.Load(); @@ -23,6 +28,19 @@ namespace osu.Game Window.Size = new Size(Config.Get(OsuConfig.Width), Config.Get(OsuConfig.Height)); Window.OnSizeChanged += window_OnSizeChanged; + API = new APIAccess() + { + Username = Config.Get(OsuConfig.Username), + Password = Config.Get(OsuConfig.Password), + Token = Config.Get(OsuConfig.Token) + }; + + //var req = new ListChannelsRequest(); + //req.Success += content => + //{ + //}; + //API.Queue(req); + AddProcessingContainer(new RatioAdjust()); //Add(new FontTest()); @@ -31,10 +49,18 @@ namespace osu.Game Add(new CursorContainer()); } + protected override void Dispose(bool isDisposing) + { + //refresh token may have changed. + Config.Set(OsuConfig.Token, API.Token); + + base.Dispose(isDisposing); + } + private void window_OnSizeChanged() { - Config.Set(OsuConfig.Width, Window.Size.Width); - Config.Set(OsuConfig.Height, Window.Size.Height); + Config.Set(OsuConfig.Width, Window.Size.Width); + Config.Set(OsuConfig.Height, Window.Size.Height); } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index e6fed78800..0ce2220ef7 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -12,6 +12,8 @@ v4.5 512 + + true @@ -31,6 +33,10 @@ 4 + + ..\packages\Newtonsoft.Json.9.0.1\lib\net45\Newtonsoft.Json.dll + True + ..\packages\ppy.OpenTK.1.1.2225.2\lib\net20\OpenTK.dll True @@ -51,6 +57,15 @@ + + + + + + + + + diff --git a/osu.Game/packages.config b/osu.Game/packages.config index 81483329dd..5431778304 100644 --- a/osu.Game/packages.config +++ b/osu.Game/packages.config @@ -3,7 +3,7 @@ Copyright (c) 2007-2016 ppy Pty Ltd . Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE --> - + \ No newline at end of file