From 37f58e5c802b06a7ea7f1e8a597e723a6a1c29a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sat, 13 Sep 2025 13:19:00 +0900 Subject: [PATCH] Add client-side support for TOTP authentication Closes https://github.com/ppy/osu/issues/34972. --- .../Visual/Menus/TestSceneLoginOverlay.cs | 151 +++++++++++++++++- osu.Game/Online/API/APIAccess.cs | 18 ++- osu.Game/Online/API/DummyAPIAccess.cs | 21 ++- osu.Game/Online/API/IAPIProvider.cs | 7 +- .../Online/API/Requests/Responses/APIMe.cs | 17 +- .../VerificationMailFallbackRequest.cs | 20 +++ .../API/Requests/VerifySessionRequest.cs | 20 +++ .../Overlays/Login/SecondFactorAuthForm.cs | 145 ++++++++++++----- 8 files changed, 351 insertions(+), 48 deletions(-) create mode 100644 osu.Game/Online/API/Requests/VerificationMailFallbackRequest.cs diff --git a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs index 3c97b291ee..0dfe055040 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using System.Net; using NUnit.Framework; @@ -9,10 +10,12 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Configuration; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Login; using osu.Game.Overlays.Settings; @@ -54,7 +57,7 @@ namespace osu.Game.Tests.Visual.Menus } [Test] - public void TestLoginSuccess() + public void TestLoginSuccess_EmailVerification() { AddStep("logout", () => API.Logout()); assertAPIState(APIState.Offline); @@ -94,6 +97,152 @@ namespace osu.Game.Tests.Visual.Menus assertDropdownState(UserAction.DoNotDisturb); } + [Test] + public void TestLoginSuccess_TOTPVerification() + { + AddStep("logout", () => API.Logout()); + assertAPIState(APIState.Offline); + AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword); + + 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 == "012345") + verifySessionRequest.TriggerSuccess(); + else + verifySessionRequest.TriggerFailure(new WebException()); + return true; + } + + return false; + }); + + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "012345"); + assertAPIState(APIState.Online); + assertDropdownState(UserAction.Online); + + AddStep("set failing", () => { dummyAPI.SetState(APIState.Failing); }); + AddStep("return to online", () => { dummyAPI.SetState(APIState.Online); }); + + AddStep("clear handler", () => dummyAPI.HandleRequest = null); + + assertDropdownState(UserAction.Online); + AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb)); + assertDropdownState(UserAction.DoNotDisturb); + } + + [Test] + public void TestLoginSuccess_TOTPVerification_FallbackToEmail() + { + AddStep("logout", () => API.Logout()); + assertAPIState(APIState.Offline); + AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword); + + 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 == "deadbeef") + verifySessionRequest.TriggerSuccess(); + else + verifySessionRequest.TriggerFailure(new WebException()); + return true; + + case VerificationMailFallbackRequest verificationMailFallbackRequest: + verificationMailFallbackRequest.TriggerSuccess(); + return true; + } + + return false; + }); + + AddStep("request fallback to email", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single(t => t.Text.ToString().Contains("email", StringComparison.InvariantCultureIgnoreCase))); + InputManager.Click(MouseButton.Left); + }); + + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "deadbeef"); + assertAPIState(APIState.Online); + assertDropdownState(UserAction.Online); + + AddStep("set failing", () => { dummyAPI.SetState(APIState.Failing); }); + AddStep("return to online", () => { dummyAPI.SetState(APIState.Online); }); + + AddStep("clear handler", () => dummyAPI.HandleRequest = null); + + assertDropdownState(UserAction.Online); + AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb)); + assertDropdownState(UserAction.DoNotDisturb); + } + + [Test] + public void TestLoginSuccess_TOTPVerification_TurnedOffMidwayThrough() + { + bool firstAttemptHandled = false; + + AddStep("logout", () => API.Logout()); + assertAPIState(APIState.Offline); + AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword); + + 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: + verifySessionRequest.RequiredVerificationMethod = SessionVerificationMethod.EmailMessage; + verifySessionRequest.TriggerFailure(new WebException()); + firstAttemptHandled = true; + return true; + } + + return false; + }); + + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "123456"); + AddUntilStep("first verification attempt handled", () => firstAttemptHandled); + assertAPIState(APIState.RequiresSecondFactorAuth); + + AddStep("set up verification handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case VerifySessionRequest verifySessionRequest: + if (verifySessionRequest.VerificationKey == "deadbeef") + verifySessionRequest.TriggerSuccess(); + else + verifySessionRequest.TriggerFailure(new WebException()); + return true; + } + + return false; + }); + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "deadbeef"); + assertAPIState(APIState.Online); + assertDropdownState(UserAction.Online); + } + private void assertDropdownState(UserAction state) { AddAssert($"dropdown state is {state}", () => loginOverlay.ChildrenOfType().First().Current.Value, () => Is.EqualTo(state)); diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 54eed58c13..58171a2f8a 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -15,6 +15,7 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; using osu.Framework.Bindables; using osu.Framework.Development; +using osu.Framework.Extensions; using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -52,6 +53,8 @@ namespace osu.Game.Online.API public string ProvidedUsername { get; private set; } + public SessionVerificationMethod? SessionVerificationMethod { get; set; } + public string SecondFactorCode { get; private set; } private string password; @@ -292,7 +295,17 @@ namespace osu.Game.Online.API verificationRequest.Failure += ex => { state.Value = APIState.RequiresSecondFactorAuth; - LastLoginError = ex; + + if (verificationRequest.RequiredVerificationMethod != null) + { + SessionVerificationMethod = verificationRequest.RequiredVerificationMethod; + LastLoginError = new APIException($"Must use {SessionVerificationMethod.GetDescription().ToLowerInvariant()} to complete verification.", ex); + } + else + { + LastLoginError = ex; + } + SecondFactorCode = null; }; @@ -337,7 +350,8 @@ namespace osu.Game.Online.API localUser.Value = me; configSupporter.Value = me.IsSupporter; - state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth; + SessionVerificationMethod = me.SessionVerificationMethod; + state.Value = SessionVerificationMethod == null ? APIState.Online : APIState.RequiresSecondFactorAuth; failureCount = 0; }; diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 74e0ca2873..9750fccb74 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -5,6 +5,7 @@ using System; using System.Threading; using System.Threading.Tasks; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Game.Localisation; using osu.Game.Online.API.Requests; @@ -62,7 +63,8 @@ namespace osu.Game.Online.API private bool shouldFailNextLogin; private bool stayConnectingNextLogin; - private bool requiredSecondFactorAuth = true; + + public SessionVerificationMethod? SessionVerificationMethod { get; set; } = Requests.Responses.SessionVerificationMethod.EmailMessage; /// /// The current connectivity state of the API. @@ -130,14 +132,14 @@ namespace osu.Game.Online.API Id = DUMMY_USER_ID, }; - if (requiredSecondFactorAuth) + if (SessionVerificationMethod != null) { state.Value = APIState.RequiresSecondFactorAuth; } else { onSuccessfulLogin(); - requiredSecondFactorAuth = true; + SessionVerificationMethod = null; } } @@ -147,7 +149,16 @@ namespace osu.Game.Online.API request.Failure += e => { state.Value = APIState.RequiresSecondFactorAuth; - LastLoginError = e; + + if (request.RequiredVerificationMethod != null) + { + SessionVerificationMethod = request.RequiredVerificationMethod; + LastLoginError = new APIException($"Must use {SessionVerificationMethod.GetDescription().ToLowerInvariant()} to complete verification.", e); + } + else + { + LastLoginError = e; + } }; state.Value = APIState.Connecting; @@ -204,7 +215,7 @@ namespace osu.Game.Online.API /// /// Skip 2FA requirement for next login. /// - public void SkipSecondFactor() => requiredSecondFactorAuth = false; + public void SkipSecondFactor() => SessionVerificationMethod = null; /// /// 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 2634ea137f..f3ced9b1ce 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -107,10 +107,15 @@ namespace osu.Game.Online.API /// The user's password. void Login(string username, string password); + /// + /// The requested by the server to complete verification. + /// + SessionVerificationMethod? SessionVerificationMethod { get; } + /// /// Provide a second-factor authentication code for authentication. /// - /// The 2FA code. + /// The 2FA code. void AuthenticateSecondFactor(string code); /// diff --git a/osu.Game/Online/API/Requests/Responses/APIMe.cs b/osu.Game/Online/API/Requests/Responses/APIMe.cs index 3cbddbe5e7..f1fa9d5f2b 100644 --- a/osu.Game/Online/API/Requests/Responses/APIMe.cs +++ b/osu.Game/Online/API/Requests/Responses/APIMe.cs @@ -1,13 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.ComponentModel; +using System.Runtime.Serialization; using Newtonsoft.Json; namespace osu.Game.Online.API.Requests.Responses { public class APIMe : APIUser { - [JsonProperty("session_verified")] - public bool SessionVerified { get; set; } + [JsonProperty("session_verification_method")] + public SessionVerificationMethod? SessionVerificationMethod { get; set; } + } + + public enum SessionVerificationMethod + { + [Description("Timed one-time password")] + [EnumMember(Value = "totp")] + TimedOneTimePassword, + + [Description("E-mail")] + [EnumMember(Value = "mail")] + EmailMessage, } } diff --git a/osu.Game/Online/API/Requests/VerificationMailFallbackRequest.cs b/osu.Game/Online/API/Requests/VerificationMailFallbackRequest.cs new file mode 100644 index 0000000000..6ea652d647 --- /dev/null +++ b/osu.Game/Online/API/Requests/VerificationMailFallbackRequest.cs @@ -0,0 +1,20 @@ +// 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 VerificationMailFallbackRequest : APIRequest + { + protected override string Target => @"session/verify/mail-fallback"; + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + return req; + } + } +} diff --git a/osu.Game/Online/API/Requests/VerifySessionRequest.cs b/osu.Game/Online/API/Requests/VerifySessionRequest.cs index b39ec5b79a..d8f622348b 100644 --- a/osu.Game/Online/API/Requests/VerifySessionRequest.cs +++ b/osu.Game/Online/API/Requests/VerifySessionRequest.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System.Net.Http; +using Newtonsoft.Json; using osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests { @@ -13,6 +15,16 @@ namespace osu.Game.Online.API.Requests public VerifySessionRequest(string verificationKey) { VerificationKey = verificationKey; + + Failure += _ => + { + string? response = WebRequest?.GetResponseString(); + if (string.IsNullOrEmpty(response)) + return; + + var responseObject = JsonConvert.DeserializeObject(response); + RequiredVerificationMethod = responseObject?.RequiredSessionVerificationMethod; + }; } protected override WebRequest CreateWebRequest() @@ -26,5 +38,13 @@ namespace osu.Game.Online.API.Requests } protected override string Target => @"session/verify"; + + public SessionVerificationMethod? RequiredVerificationMethod { get; internal set; } + + private class VerificationFailureResponse + { + [JsonProperty("method")] + public SessionVerificationMethod RequiredSessionVerificationMethod { get; set; } + } } } diff --git a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs index 74db58e225..2cdc4bf6a6 100644 --- a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs +++ b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Settings; using osu.Game.Resources.Localisation.Web; using osuTK; @@ -21,11 +22,11 @@ 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!; + private FillFlowContainer contentFlow = null!; + private OsuTextBox codeTextBox = null!; [Resolved] private IAPIProvider api { get; set; } = null!; @@ -36,6 +37,8 @@ namespace osu.Game.Overlays.Login RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }; + Children = new Drawable[] { new FillFlowContainer @@ -46,46 +49,18 @@ namespace osu.Game.Overlays.Login Spacing = new Vector2(0, SettingsSection.ITEM_SPACING), Children = new Drawable[] { - new FillFlowContainer + contentFlow = 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 - { - InputProperties = new TextInputProperties(TextInputType.Code), - 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 + errorText = new ErrorTextFlowContainer { - Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Alpha = 0, }, } }, @@ -95,6 +70,56 @@ namespace osu.Game.Overlays.Login } }; + if (api.LastLoginError?.Message is string error) + { + errorText.Alpha = 1; + errorText.AddErrors(new[] { error }); + } + + showContent(api.SessionVerificationMethod!.Value); + } + + private void showContent(SessionVerificationMethod sessionVerificationMethod) + { + switch (sessionVerificationMethod) + { + case SessionVerificationMethod.EmailMessage: + showEmailVerification(); + break; + + case SessionVerificationMethod.TimedOneTimePassword: + showTotpVerification(); + break; + } + } + + private void showEmailVerification() + { + LinkFlowContainer explainText; + + contentFlow.Clear(); + contentFlow.AddRange(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 + { + InputProperties = new TextInputProperties(TextInputType.Code), + 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, + }, + }); + 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 "); @@ -131,12 +156,58 @@ namespace osu.Game.Overlays.Login codeTextBox.Current.Disabled = true; } }); + } - if (api.LastLoginError?.Message is string error) + private void showTotpVerification() + { + LinkFlowContainer explainText; + + contentFlow.Clear(); + contentFlow.AddRange(new Drawable[] { - errorText.Alpha = 1; - errorText.AddErrors(new[] { error }); - } + new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = "Please enter the code from your authenticator app.", + }, + codeTextBox = new OsuNumberBox + { + InputProperties = new TextInputProperties(TextInputType.NumericalPassword), + 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, + }, + }); + + // 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 app, "); + explainText.AddLink("you can verify using email instead", () => + { + var fallbackRequest = new VerificationMailFallbackRequest(); + fallbackRequest.Success += showEmailVerification; + fallbackRequest.Failure += ex => errorText.Text = ex.Message; + Task.Run(() => api.Perform(fallbackRequest)); + }); + explainText.AddText(". You can also "); + explainText.AddLink(UserVerificationStrings.BoxInfoLogoutLink, () => { api.Logout(); }); + explainText.AddText("."); + + codeTextBox.Current.BindValueChanged(code => + { + string trimmedCode = code.NewValue.Trim(); + + if (trimmedCode.Length == 6) + { + api.AuthenticateSecondFactor(trimmedCode); + codeTextBox.Current.Disabled = true; + } + }); } public override bool AcceptsFocus => true;