1
0
mirror of https://github.com/ppy/osu.git synced 2026-06-03 03:20:16 +08:00

Merge pull request #35037 from bdach/totp

Add client-side support for TOTP authentication
This commit is contained in:
Dean Herbert
2025-09-18 23:50:07 +09:00
committed by GitHub
Unverified
8 changed files with 351 additions and 48 deletions
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().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<OsuTextBox>().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<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().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<OsuSpriteText>().Single(t => t.Text.ToString().Contains("email", StringComparison.InvariantCultureIgnoreCase)));
InputManager.Click(MouseButton.Left);
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().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<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().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<OsuTextBox>().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<OsuTextBox>().First().Text = "deadbeef");
assertAPIState(APIState.Online);
assertDropdownState(UserAction.Online);
}
private void assertDropdownState(UserAction state)
{
AddAssert($"dropdown state is {state}", () => loginOverlay.ChildrenOfType<UserDropdown>().First().Current.Value, () => Is.EqualTo(state));
+16 -2
View File
@@ -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;
};
+16 -5
View File
@@ -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;
/// <summary>
/// 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
/// <summary>
/// Skip 2FA requirement for next login.
/// </summary>
public void SkipSecondFactor() => requiredSecondFactorAuth = false;
public void SkipSecondFactor() => SessionVerificationMethod = null;
/// <summary>
/// During the next simulated login, the process will fail immediately.
+6 -1
View File
@@ -107,10 +107,15 @@ namespace osu.Game.Online.API
/// <param name="password">The user's password.</param>
void Login(string username, string password);
/// <summary>
/// The <see cref="SessionVerificationMethod"/> requested by the server to complete verification.
/// </summary>
SessionVerificationMethod? SessionVerificationMethod { get; }
/// <summary>
/// Provide a second-factor authentication code for authentication.
/// </summary>
/// <param name="code">The 2FA code.</param>
/// <paramref name="code">The 2FA code.</paramref>
void AuthenticateSecondFactor(string code);
/// <summary>
@@ -1,13 +1,26 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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,
}
}
@@ -0,0 +1,20 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
}
}
}
@@ -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<VerificationFailureResponse>(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; }
}
}
}
+108 -37
View File
@@ -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;