mirror of
https://github.com/ppy/osu.git
synced 2024-11-11 11:37:28 +08:00
Merge pull request #25480 from peppy/2fa
Add two factor authentication flow
This commit is contained in:
commit
cbbe2f9dc0
@ -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()));
|
||||
|
||||
|
@ -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<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 == "88800088")
|
||||
verifySessionRequest.TriggerSuccess();
|
||||
else
|
||||
verifySessionRequest.TriggerFailure(new WebException());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().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<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 == "88800088")
|
||||
verifySessionRequest.TriggerSuccess();
|
||||
else
|
||||
verifySessionRequest.TriggerFailure(new WebException());
|
||||
verificationHandled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().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<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("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "88800088");
|
||||
assertAPIState(APIState.Online);
|
||||
|
||||
AddStep("click on flag", () =>
|
||||
{
|
||||
InputManager.MoveMouseTo(loginOverlay.ChildrenOfType<UpdateableFlag>().First());
|
||||
|
@ -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<APIState>())
|
||||
{
|
||||
AddStep($"Change state to {state}", () => ((DummyAPIAccess)API).SetState(state));
|
||||
AddStep($"Change state to {state}", () => dummyAPI.SetState(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<SettingsButton>().Single().TriggerClick());
|
||||
AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType<ScreenWarning>().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);
|
||||
}
|
||||
}
|
||||
|
@ -67,6 +67,7 @@ namespace osu.Game.Tests.Visual.Online
|
||||
Schedule(() =>
|
||||
{
|
||||
API.Login("test", "test");
|
||||
dummyAPI.AuthenticateSecondFactor("abcdefgh");
|
||||
Child = commentsContainer = new CommentsContainer();
|
||||
});
|
||||
}
|
||||
|
@ -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());
|
||||
|
@ -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]
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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<APIUser> 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<OAuthToken> e) => config.SetValue(OsuSetting.Token, config.Get<bool>(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty);
|
||||
|
||||
internal new void Schedule(Action action) => base.Schedule(action);
|
||||
@ -200,6 +226,7 @@ namespace osu.Game.Online.API
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method takes control of <see cref="state"/> and transitions from <see cref="APIState.Connecting"/> to either
|
||||
/// - <see cref="APIState.RequiresSecondFactorAuth"/> (pending 2fa)
|
||||
/// - <see cref="APIState.Online"/> (successful connection)
|
||||
/// - <see cref="APIState.Failing"/> (failed connection but retrying)
|
||||
/// - <see cref="APIState.Offline"/> (failed and can't retry, clear credentials and require user interaction)
|
||||
@ -207,8 +234,6 @@ namespace osu.Game.Online.API
|
||||
/// <returns>Whether the connection attempt was successful.</returns>
|
||||
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
|
||||
/// </summary>
|
||||
Failing,
|
||||
|
||||
/// <summary>
|
||||
/// Waiting on second factor authentication.
|
||||
/// </summary>
|
||||
RequiresSecondFactorAuth,
|
||||
|
||||
/// <summary>
|
||||
/// We are in the process of (re-)connecting.
|
||||
/// </summary>
|
||||
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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<UserActivity> IAPIProvider.Activity => Activity;
|
||||
IBindable<UserStatistics?> IAPIProvider.Statistics => Statistics;
|
||||
|
||||
/// <summary>
|
||||
/// Skip 2FA requirement for next login.
|
||||
/// </summary>
|
||||
public void SkipSecondFactor() => requiredSecondFactorAuth = false;
|
||||
|
||||
/// <summary>
|
||||
/// During the next simulated login, the process will fail immediately.
|
||||
/// </summary>
|
||||
|
@ -112,6 +112,12 @@ namespace osu.Game.Online.API
|
||||
/// <param name="password">The user's password.</param>
|
||||
void Login(string username, string password);
|
||||
|
||||
/// <summary>
|
||||
/// Provide a second-factor authentication code for authentication.
|
||||
/// </summary>
|
||||
/// <param name="code">The 2FA code.</param>
|
||||
void AuthenticateSecondFactor(string code);
|
||||
|
||||
/// <summary>
|
||||
/// Log out the current user.
|
||||
/// </summary>
|
||||
|
24
osu.Game/Online/API/Requests/GetMeRequest.cs
Normal file
24
osu.Game/Online/API/Requests/GetMeRequest.cs
Normal file
@ -0,0 +1,24 @@
|
||||
// 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 osu.Game.Online.API.Requests.Responses;
|
||||
using osu.Game.Rulesets;
|
||||
|
||||
namespace osu.Game.Online.API.Requests
|
||||
{
|
||||
public class GetMeRequest : APIRequest<APIMe>
|
||||
{
|
||||
public readonly IRulesetInfo? Ruleset;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently logged-in user.
|
||||
/// </summary>
|
||||
/// <param name="ruleset">The ruleset to get the user's info for.</param>
|
||||
public GetMeRequest(IRulesetInfo? ruleset = null)
|
||||
{
|
||||
Ruleset = ruleset;
|
||||
}
|
||||
|
||||
protected override string Target => $@"me/{Ruleset?.ShortName}";
|
||||
}
|
||||
}
|
@ -1,8 +1,6 @@
|
||||
// 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.
|
||||
|
||||
#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<APIUser>
|
||||
{
|
||||
public readonly string Lookup;
|
||||
public readonly IRulesetInfo Ruleset;
|
||||
public readonly IRulesetInfo? Ruleset;
|
||||
private readonly LookupType lookupType;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently logged-in user.
|
||||
/// </summary>
|
||||
public GetUserRequest()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a user from their ID.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user to get.</param>
|
||||
/// <param name="ruleset">The ruleset to get the user's info for.</param>
|
||||
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
|
||||
/// </summary>
|
||||
/// <param name="username">The user to get.</param>
|
||||
/// <param name="ruleset">The ruleset to get the user's info for.</param>
|
||||
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
|
||||
{
|
||||
|
@ -0,0 +1,22 @@
|
||||
// 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 ReissueVerificationCodeRequest : APIRequest
|
||||
{
|
||||
protected override WebRequest CreateWebRequest()
|
||||
{
|
||||
var req = base.CreateWebRequest();
|
||||
|
||||
req.Method = HttpMethod.Post;
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
protected override string Target => @"session/verify/reissue";
|
||||
}
|
||||
}
|
13
osu.Game/Online/API/Requests/Responses/APIMe.cs
Normal file
13
osu.Game/Online/API/Requests/Responses/APIMe.cs
Normal file
@ -0,0 +1,13 @@
|
||||
// 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 Newtonsoft.Json;
|
||||
|
||||
namespace osu.Game.Online.API.Requests.Responses
|
||||
{
|
||||
public class APIMe : APIUser
|
||||
{
|
||||
[JsonProperty("session_verified")]
|
||||
public bool SessionVerified { get; set; }
|
||||
}
|
||||
}
|
30
osu.Game/Online/API/Requests/VerifySessionRequest.cs
Normal file
30
osu.Game/Online/API/Requests/VerifySessionRequest.cs
Normal file
@ -0,0 +1,30 @@
|
||||
// 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 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";
|
||||
}
|
||||
}
|
@ -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<IScreen> 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;
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
147
osu.Game/Overlays/Login/SecondFactorAuthForm.cs
Normal file
147
osu.Game/Overlays/Login/SecondFactorAuthForm.cs
Normal file
@ -0,0 +1,147 @@
|
||||
// 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.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); });
|
||||
}
|
||||
}
|
||||
}
|
@ -99,6 +99,7 @@ namespace osu.Game.Overlays.Toolbar
|
||||
|
||||
switch (state.NewValue)
|
||||
{
|
||||
case APIState.RequiresSecondFactorAuth:
|
||||
case APIState.Connecting:
|
||||
TooltipText = ToolbarStrings.Connecting;
|
||||
spinner.Show();
|
||||
|
@ -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<SessionStatics>().SetValue(Static.MutedAudioNotificationShownOnce, true);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user