diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs index c645b1b01c..1660f93384 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs @@ -184,7 +184,11 @@ namespace osu.Game.Tests.Visual.Gameplay CreateTest(); AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); - AddStep("log back in", () => API.Login("username", "password")); + AddStep("log back in", () => + { + API.Login("username", "password"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + }); AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs index 0bc71924ce..5fc075ed99 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using System.Net; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -9,7 +10,9 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Overlays; +using osu.Game.Overlays.Login; using osu.Game.Users.Drawables; using osuTK.Input; @@ -18,6 +21,8 @@ namespace osu.Game.Tests.Visual.Menus [TestFixture] public partial class TestSceneLoginOverlay : OsuManualInputManagerTestScene { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + private LoginOverlay loginOverlay = null!; [BackgroundDependencyLoader] @@ -40,9 +45,69 @@ namespace osu.Game.Tests.Visual.Menus public void TestLoginSuccess() { AddStep("logout", () => API.Logout()); + assertAPIState(APIState.Offline); AddStep("enter password", () => loginOverlay.ChildrenOfType().First().Text = "password"); AddStep("submit", () => loginOverlay.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + + assertAPIState(APIState.RequiresSecondFactorAuth); + AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType().SingleOrDefault(), () => Is.Not.Null); + + AddStep("set up verification handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case VerifySessionRequest verifySessionRequest: + if (verifySessionRequest.VerificationKey == "88800088") + verifySessionRequest.TriggerSuccess(); + else + verifySessionRequest.TriggerFailure(new WebException()); + return true; + } + + return false; + }); + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "88800088"); + assertAPIState(APIState.Online); + AddStep("clear handler", () => dummyAPI.HandleRequest = null); + } + + private void assertAPIState(APIState expected) => + AddUntilStep($"login state is {expected}", () => API.State.Value, () => Is.EqualTo(expected)); + + [Test] + public void TestVerificationFailure() + { + bool verificationHandled = false; + AddStep("reset flag", () => verificationHandled = false); + AddStep("logout", () => API.Logout()); + assertAPIState(APIState.Offline); + + AddStep("enter password", () => loginOverlay.ChildrenOfType().First().Text = "password"); + AddStep("submit", () => loginOverlay.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + + assertAPIState(APIState.RequiresSecondFactorAuth); + AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType().SingleOrDefault(), () => Is.Not.Null); + + AddStep("set up verification handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case VerifySessionRequest verifySessionRequest: + if (verifySessionRequest.VerificationKey == "88800088") + verifySessionRequest.TriggerSuccess(); + else + verifySessionRequest.TriggerFailure(new WebException()); + verificationHandled = true; + return true; + } + + return false; + }); + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "abcdefgh"); + AddUntilStep("wait for verification handled", () => verificationHandled); + assertAPIState(APIState.RequiresSecondFactorAuth); + AddStep("clear handler", () => dummyAPI.HandleRequest = null); } [Test] @@ -78,6 +143,12 @@ namespace osu.Game.Tests.Visual.Menus AddStep("enter password", () => loginOverlay.ChildrenOfType().First().Text = "password"); AddStep("submit", () => loginOverlay.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + assertAPIState(APIState.RequiresSecondFactorAuth); + AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType().SingleOrDefault(), () => Is.Not.Null); + + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "88800088"); + assertAPIState(APIState.Online); + AddStep("click on flag", () => { InputManager.MoveMouseTo(loginOverlay.ChildrenOfType().First()); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs index 2bdfc8959d..f0506ed35c 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs @@ -16,6 +16,8 @@ namespace osu.Game.Tests.Visual.Menus [TestFixture] public partial class TestSceneToolbarUserButton : OsuManualInputManagerTestScene { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + public TestSceneToolbarUserButton() { Container mainContainer; @@ -69,18 +71,20 @@ namespace osu.Game.Tests.Visual.Menus [Test] public void TestLoginLogout() { - AddStep("Log out", () => ((DummyAPIAccess)API).Logout()); - AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang")); + AddStep("Log out", () => dummyAPI.Logout()); + AddStep("Log in", () => dummyAPI.Login("wang", "jang")); + AddStep("Authenticate via second factor", () => dummyAPI.AuthenticateSecondFactor("abcdefgh")); } [Test] public void TestStates() { - AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang")); + AddStep("Log in", () => dummyAPI.Login("wang", "jang")); + AddStep("Authenticate via second factor", () => dummyAPI.AuthenticateSecondFactor("abcdefgh")); foreach (var state in Enum.GetValues()) { - AddStep($"Change state to {state}", () => ((DummyAPIAccess)API).SetState(state)); + AddStep($"Change state to {state}", () => dummyAPI.SetState(state)); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs index 0f920643f0..79fb063ea9 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs @@ -10,6 +10,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.AccountCreation; @@ -59,7 +60,11 @@ namespace osu.Game.Tests.Visual.Online AddStep("click button", () => accountCreation.ChildrenOfType().Single().TriggerClick()); AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType().SingleOrDefault()?.IsPresent == true); - AddStep("log back in", () => API.Login("dummy", "password")); + AddStep("log back in", () => + { + API.Login("dummy", "password"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + }); AddUntilStep("overlay is hidden", () => accountCreation.State.Value == Visibility.Hidden); } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs index 10fdffb8e1..f47322b9e0 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs @@ -67,6 +67,7 @@ namespace osu.Game.Tests.Visual.Online Schedule(() => { API.Login("test", "test"); + dummyAPI.AuthenticateSecondFactor("abcdefgh"); Child = commentsContainer = new CommentsContainer(); }); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs b/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs index 3954fd5cff..0acf8336e3 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet.Buttons; using osuTK; @@ -34,14 +35,22 @@ namespace osu.Game.Tests.Visual.Online AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet { OnlineID = 88 }); AddStep("log out", () => API.Logout()); checkEnabled(false); - AddStep("log in", () => API.Login("test", "test")); + AddStep("log in", () => + { + API.Login("test", "test"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + }); checkEnabled(true); } [Test] public void TestBeatmapChange() { - AddStep("log in", () => API.Login("test", "test")); + AddStep("log in", () => + { + API.Login("test", "test"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + }); AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet { OnlineID = 88 }); checkEnabled(true); AddStep("set invalid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet()); diff --git a/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs index be819afa3e..3607b37c7e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs @@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.Online else { int userId = int.Parse(getUserRequest.Lookup); - string rulesetName = getUserRequest.Ruleset.ShortName; + string rulesetName = getUserRequest.Ruleset!.ShortName; var response = new APIUser { Id = userId, @@ -177,7 +177,11 @@ namespace osu.Game.Tests.Visual.Online AddWaitStep("wait a bit", 5); AddAssert("update not received", () => update == null); - AddStep("log in user", () => dummyAPI.Login("user", "password")); + AddStep("log in user", () => + { + dummyAPI.Login("user", "password"); + dummyAPI.AuthenticateSecondFactor("abcdefgh"); + }); } [Test] diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index 1375689075..bc8f75d4ce 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -52,7 +52,11 @@ namespace osu.Game.Tests.Visual.Online AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 })); AddToggleStep("toggle visibility", visible => profile.State.Value = visible ? Visibility.Visible : Visibility.Hidden); AddStep("log out", () => dummyAPI.Logout()); - AddStep("log back in", () => dummyAPI.Login("username", "password")); + AddStep("log back in", () => + { + dummyAPI.Login("username", "password"); + dummyAPI.AuthenticateSecondFactor("abcdefgh"); + }); } [Test] @@ -98,7 +102,11 @@ namespace osu.Game.Tests.Visual.Online }); AddStep("logout", () => dummyAPI.Logout()); AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 })); - AddStep("login", () => dummyAPI.Login("username", "password")); + AddStep("login", () => + { + dummyAPI.Login("username", "password"); + dummyAPI.AuthenticateSecondFactor("abcdefgh"); + }); AddWaitStep("wait some", 3); AddStep("complete request", () => pendingRequest.TriggerSuccess(TEST_USER)); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs index ce1a9ac6a7..488902c417 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Game.Overlays; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; namespace osu.Game.Tests.Visual.Online { @@ -72,7 +73,11 @@ namespace osu.Game.Tests.Visual.Online AddAssert("Login overlay is visible", () => login.State.Value == Visibility.Visible); } - private void logIn() => API.Login("localUser", "password"); + private void logIn() + { + API.Login("localUser", "password"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + } private Comment getUserComment() => new Comment { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs index b17024ae8f..e1d40882be 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs @@ -125,7 +125,11 @@ namespace osu.Game.Tests.Visual.UserInterface assertLoggedOutState(); // moving from logged out -> logged in - AddStep("log back in", () => dummyAPI.Login("username", "password")); + AddStep("log back in", () => + { + dummyAPI.Login("username", "password"); + dummyAPI.AuthenticateSecondFactor("abcdefgh"); + }); assertLoggedInState(); } diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 359c52553d..d3707fe74d 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -48,6 +48,8 @@ namespace osu.Game.Online.API public string ProvidedUsername { get; private set; } + public string SecondFactorCode { get; private set; } + private string password; public IBindable LocalUser => localUser; @@ -84,7 +86,7 @@ namespace osu.Game.Online.API APIEndpointUrl = endpointConfiguration.APIEndpointUrl; WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl; - NotificationsClient = new WebSocketNotificationsClientConnector(this); + NotificationsClient = setUpNotificationsClient(); authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl); log = Logger.GetLogger(LoggingTarget.Network); @@ -117,6 +119,30 @@ namespace osu.Game.Online.API 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); @@ -200,6 +226,7 @@ namespace osu.Game.Online.API /// /// /// 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) @@ -207,8 +234,6 @@ namespace osu.Game.Online.API /// Whether the connection attempt was successful. private void attemptConnect() { - state.Value = APIState.Connecting; - if (localUser.IsDefault) { // Show a placeholder user if saved credentials are available. @@ -226,6 +251,7 @@ namespace osu.Game.Online.API if (!authentication.HasValidAccessToken) { + state.Value = APIState.Connecting; LastLoginError = null; try @@ -243,40 +269,79 @@ namespace osu.Game.Online.API } } - var userReq = new GetUserRequest(); - userReq.Failure += ex => + switch (state.Value) { - if (ex is APIException) + case APIState.RequiresSecondFactorAuth: { - LastLoginError = ex; - log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!"); - Logout(); + 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; } - else if (ex is WebException webException && webException.Message == @"Unauthorized") + + default: { - log.Add(@"Login no longer valid"); - Logout(); + 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; } - else - { - state.Value = APIState.Failing; - } - }; - userReq.Success += user => - { - user.Status.Value = configStatus.Value ?? UserStatus.Online; - - setLocalUser(user); - - // we're connected! - state.Value = APIState.Online; - failureCount = 0; - }; - - if (!handleRequest(userReq)) - { - state.Value = APIState.Failing; - return; } var friendsReq = new GetFriendsRequest(); @@ -324,6 +389,13 @@ namespace osu.Game.Online.API 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); @@ -509,6 +581,7 @@ namespace osu.Game.Online.API 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 @@ -568,6 +641,11 @@ namespace osu.Game.Online.API /// Failing, + /// + /// Waiting on second factor authentication. + /// + RequiresSecondFactorAuth, + /// /// We are in the process of (re-)connecting. /// diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 3c60513495..4962838bd9 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Framework.Graphics; 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; @@ -61,6 +62,7 @@ namespace osu.Game.Online.API private bool shouldFailNextLogin; private bool stayConnectingNextLogin; + private bool requiredSecondFactorAuth = true; /// /// The current connectivity state of the API. @@ -121,13 +123,46 @@ namespace osu.Game.Online.API Id = DUMMY_USER_ID, }; + if (requiredSecondFactorAuth) + { + state.Value = APIState.RequiresSecondFactorAuth; + } + else + { + onSuccessfulLogin(); + requiredSecondFactorAuth = true; + } + } + + public void AuthenticateSecondFactor(string code) + { + var request = new VerifySessionRequest(code); + request.Failure += e => + { + state.Value = APIState.RequiresSecondFactorAuth; + LastLoginError = e; + }; + + state.Value = APIState.Connecting; + LastLoginError = null; + + // if no handler installed / handler can't handle verification, just assume that the server would verify for simplicity. + if (HandleRequest?.Invoke(request) != true) + onSuccessfulLogin(); + + // if a handler did handle this, make sure the verification actually passed. + if (request.CompletionState == APIRequestCompletionState.Completed) + onSuccessfulLogin(); + } + + private void onSuccessfulLogin() + { + state.Value = APIState.Online; Statistics.Value = new UserStatistics { GlobalRank = 1, CountryRank = 1 }; - - state.Value = APIState.Online; } public void Logout() @@ -163,6 +198,11 @@ namespace osu.Game.Online.API IBindable IAPIProvider.Activity => Activity; IBindable IAPIProvider.Statistics => Statistics; + /// + /// Skip 2FA requirement for next login. + /// + public void SkipSecondFactor() => requiredSecondFactorAuth = false; + /// /// During the next simulated login, the process will fail immediately. /// diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index a050b2dfae..66f124f7c3 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -112,6 +112,12 @@ namespace osu.Game.Online.API /// The user's password. void Login(string username, string password); + /// + /// Provide a second-factor authentication code for authentication. + /// + /// The 2FA code. + void AuthenticateSecondFactor(string code); + /// /// Log out the current user. /// diff --git a/osu.Game/Online/API/Requests/GetMeRequest.cs b/osu.Game/Online/API/Requests/GetMeRequest.cs new file mode 100644 index 0000000000..aab7d7b2f1 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetMeRequest.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; + +namespace osu.Game.Online.API.Requests +{ + public class GetMeRequest : APIRequest + { + public readonly IRulesetInfo? Ruleset; + + /// + /// Gets the currently logged-in user. + /// + /// The ruleset to get the user's info for. + public GetMeRequest(IRulesetInfo? ruleset = null) + { + Ruleset = ruleset; + } + + protected override string Target => $@"me/{Ruleset?.ShortName}"; + } +} diff --git a/osu.Game/Online/API/Requests/GetUserRequest.cs b/osu.Game/Online/API/Requests/GetUserRequest.cs index 7dcf75950e..90d3268e75 100644 --- a/osu.Game/Online/API/Requests/GetUserRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRequest.cs @@ -1,8 +1,6 @@ // 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 osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; @@ -11,24 +9,17 @@ namespace osu.Game.Online.API.Requests public class GetUserRequest : APIRequest { public readonly string Lookup; - public readonly IRulesetInfo Ruleset; + public readonly IRulesetInfo? Ruleset; private readonly LookupType lookupType; - /// - /// Gets the currently logged-in user. - /// - public GetUserRequest() - { - } - /// /// Gets a user from their ID. /// /// The user to get. /// The ruleset to get the user's info for. - public GetUserRequest(long? userId = null, IRulesetInfo ruleset = null) + public GetUserRequest(long? userId = null, IRulesetInfo? ruleset = null) { - Lookup = userId.ToString(); + Lookup = userId.ToString()!; lookupType = LookupType.Id; Ruleset = ruleset; } @@ -38,14 +29,14 @@ namespace osu.Game.Online.API.Requests /// /// The user to get. /// The ruleset to get the user's info for. - public GetUserRequest(string username = null, IRulesetInfo ruleset = null) + public GetUserRequest(string username, IRulesetInfo? ruleset = null) { Lookup = username; lookupType = LookupType.Username; Ruleset = ruleset; } - protected override string Target => Lookup != null ? $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLowerInvariant()}" : $@"me/{Ruleset?.ShortName}"; + protected override string Target => $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLowerInvariant()}"; private enum LookupType { diff --git a/osu.Game/Online/API/Requests/ReissueVerificationCodeRequest.cs b/osu.Game/Online/API/Requests/ReissueVerificationCodeRequest.cs new file mode 100644 index 0000000000..f2a3cf0a16 --- /dev/null +++ b/osu.Game/Online/API/Requests/ReissueVerificationCodeRequest.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class ReissueVerificationCodeRequest : APIRequest + { + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.Method = HttpMethod.Post; + + return req; + } + + protected override string Target => @"session/verify/reissue"; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIMe.cs b/osu.Game/Online/API/Requests/Responses/APIMe.cs new file mode 100644 index 0000000000..3cbddbe5e7 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APIMe.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APIMe : APIUser + { + [JsonProperty("session_verified")] + public bool SessionVerified { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/VerifySessionRequest.cs b/osu.Game/Online/API/Requests/VerifySessionRequest.cs new file mode 100644 index 0000000000..b39ec5b79a --- /dev/null +++ b/osu.Game/Online/API/Requests/VerifySessionRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class VerifySessionRequest : APIRequest + { + public readonly string VerificationKey; + + public VerifySessionRequest(string verificationKey) + { + VerificationKey = verificationKey; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.Method = HttpMethod.Post; + req.AddParameter(@"verification_key", VerificationKey); + + return req; + } + + protected override string Target => @"session/verify"; + } +} diff --git a/osu.Game/Online/OnlineStatusNotifier.cs b/osu.Game/Online/OnlineStatusNotifier.cs index c36e4ab894..dda430ce6f 100644 --- a/osu.Game/Online/OnlineStatusNotifier.cs +++ b/osu.Game/Online/OnlineStatusNotifier.cs @@ -11,6 +11,7 @@ using osu.Framework.Screens; using osu.Game.Online.API; using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Notifications.WebSocket; using osu.Game.Online.Spectator; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -25,6 +26,8 @@ namespace osu.Game.Online { private readonly Func getCurrentScreen; + private INotificationsClient notificationsClient = null!; + [Resolved] private MultiplayerClient multiplayerClient { get; set; } = null!; @@ -55,9 +58,11 @@ namespace osu.Game.Online private void load(IAPIProvider api) { apiState = api.State.GetBoundCopy(); + notificationsClient = api.NotificationsClient; multiplayerState = multiplayerClient.IsConnected.GetBoundCopy(); spectatorState = spectatorClient.IsConnected.GetBoundCopy(); + notificationsClient.MessageReceived += notifyAboutForcedDisconnection; multiplayerClient.Disconnecting += notifyAboutForcedDisconnection; spectatorClient.Disconnecting += notifyAboutForcedDisconnection; metadataClient.Disconnecting += notifyAboutForcedDisconnection; @@ -127,10 +132,27 @@ namespace osu.Game.Online }); } + private void notifyAboutForcedDisconnection(SocketMessage obj) + { + if (obj.Event != @"logout") return; + + if (userNotified) return; + + userNotified = true; + notificationOverlay?.Post(new SimpleErrorNotification + { + Icon = FontAwesome.Solid.ExclamationCircle, + Text = "You have been logged out due to a change to your account. Please log in again." + }); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + if (notificationsClient.IsNotNull()) + notificationsClient.MessageReceived += notifyAboutForcedDisconnection; + if (spectatorClient.IsNotNull()) spectatorClient.Disconnecting -= notifyAboutForcedDisconnection; diff --git a/osu.Game/Online/OnlineViewContainer.cs b/osu.Game/Online/OnlineViewContainer.cs index 46f64fbb61..824da152b2 100644 --- a/osu.Game/Online/OnlineViewContainer.cs +++ b/osu.Game/Online/OnlineViewContainer.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -79,10 +80,14 @@ namespace osu.Game.Online case APIState.Failing: case APIState.Connecting: + case APIState.RequiresSecondFactorAuth: PopContentOut(Content); LoadingSpinner.Show(); placeholder.FadeOut(transform_duration / 2, Easing.OutQuint); break; + + default: + throw new ArgumentOutOfRangeException(); } }); diff --git a/osu.Game/Overlays/AccountCreationOverlay.cs b/osu.Game/Overlays/AccountCreationOverlay.cs index ef2e055eae..576ee92b48 100644 --- a/osu.Game/Overlays/AccountCreationOverlay.cs +++ b/osu.Game/Overlays/AccountCreationOverlay.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -118,12 +119,16 @@ namespace osu.Game.Overlays break; case APIState.Connecting: + case APIState.RequiresSecondFactorAuth: break; case APIState.Online: scheduledHide?.Cancel(); scheduledHide = Schedule(Hide); break; + + default: + throw new ArgumentOutOfRangeException(); } } } diff --git a/osu.Game/Overlays/Login/LoginForm.cs b/osu.Game/Overlays/Login/LoginForm.cs index 0eef55162f..80dfca93d2 100644 --- a/osu.Game/Overlays/Login/LoginForm.cs +++ b/osu.Game/Overlays/Login/LoginForm.cs @@ -32,13 +32,7 @@ namespace osu.Game.Overlays.Login public Action? RequestHide; - private void performLogin() - { - if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text)) - api.Login(username.Text, password.Text); - else - shakeSignIn.Shake(); - } + public override bool AcceptsFocus => true; [BackgroundDependencyLoader(permitNulls: true)] private void load(OsuConfigManager config, AccountCreationOverlay accountCreation) @@ -144,7 +138,13 @@ namespace osu.Game.Overlays.Login } } - public override bool AcceptsFocus => true; + private void performLogin() + { + if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text)) + api.Login(username.Text, password.Text); + else + shakeSignIn.Shake(); + } protected override bool OnClick(ClickEvent e) => true; diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs index ea65656eb1..25bf612bc3 100644 --- a/osu.Game/Overlays/Login/LoginPanel.cs +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Login { private bool bounding = true; - private LoginForm? form; + private Drawable? form; [Resolved] private OsuColour colours { get; set; } = null!; @@ -81,6 +81,10 @@ namespace osu.Game.Overlays.Login }; break; + case APIState.RequiresSecondFactorAuth: + Child = form = new SecondFactorAuthForm(); + break; + case APIState.Failing: case APIState.Connecting: LinkFlowContainer linkFlow; diff --git a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs new file mode 100644 index 0000000000..dcd3119f33 --- /dev/null +++ b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs @@ -0,0 +1,147 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Framework.Logging; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Settings; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Overlays.Login +{ + public partial class SecondFactorAuthForm : Container + { + private OsuTextBox codeTextBox = null!; + private LinkFlowContainer explainText = null!; + private ErrorTextFlowContainer errorText = null!; + + private LoadingLayer loading = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, SettingsSection.ITEM_SPACING), + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, SettingsSection.ITEM_SPACING), + Children = new Drawable[] + { + new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = "An email has been sent to you with a verification code. Enter the code.", + }, + codeTextBox = new OsuTextBox + { + PlaceholderText = "Enter code", + RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + }, + explainText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + errorText = new ErrorTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Alpha = 0, + }, + }, + }, + new LinkFlowContainer + { + Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + } + }, + loading = new LoadingLayer(true) + { + Padding = new MarginPadding { Vertical = -SettingsSection.ITEM_SPACING }, + } + }; + + explainText.AddParagraph(UserVerificationStrings.BoxInfoCheckSpam); + // We can't support localisable strings with nested links yet. Not sure if we even can (probably need to allow markdown link formatting or something). + explainText.AddParagraph("If you can't access your email or have forgotten what you used, please follow the "); + explainText.AddLink(UserVerificationStrings.BoxInfoRecoverLink, $"{api.WebsiteRootUrl}/home/password-reset"); + explainText.AddText(". You can also "); + explainText.AddLink(UserVerificationStrings.BoxInfoReissueLink, () => + { + loading.Show(); + + var reissueRequest = new ReissueVerificationCodeRequest(); + reissueRequest.Failure += ex => + { + Logger.Error(ex, @"Failed to retrieve new verification code."); + loading.Hide(); + }; + reissueRequest.Success += () => + { + loading.Hide(); + }; + + Task.Run(() => api.Perform(reissueRequest)); + }); + explainText.AddText(" or "); + explainText.AddLink(UserVerificationStrings.BoxInfoLogoutLink, () => { api.Logout(); }); + explainText.AddText("."); + + codeTextBox.Current.BindValueChanged(code => + { + if (code.NewValue.Length == 8) + { + api.AuthenticateSecondFactor(code.NewValue); + codeTextBox.Current.Disabled = true; + } + }); + + if (api.LastLoginError?.Message is string error) + { + errorText.Alpha = 1; + errorText.AddErrors(new[] { error }); + } + } + + public override bool AcceptsFocus => true; + + protected override bool OnClick(ClickEvent e) => true; + + protected override void OnFocus(FocusEvent e) + { + Schedule(() => { GetContainingInputManager().ChangeFocus(codeTextBox); }); + } + } +} diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index 230974cd59..28521e3331 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -99,6 +99,7 @@ namespace osu.Game.Overlays.Toolbar switch (state.NewValue) { + case APIState.RequiresSecondFactorAuth: case APIState.Connecting: TooltipText = ToolbarStrings.Connecting; spinner.Show(); diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index 947305439e..6069fe4fb0 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -178,6 +178,7 @@ namespace osu.Game.Tests.Visual LocalConfig.SetValue(OsuSetting.ShowFirstRunSetup, false); API.Login("Rhythm Champion", "osu!"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); Dependencies.Get().SetValue(Static.MutedAudioNotificationShownOnce, true);