using System.Reflection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.Admin; using RadicadeServer.Auth; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Accounts.Local; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Auth.Cached; using RadicadeServer.RecNet.Auth.Connection; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Enums.Players; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Responses; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; public class AccountLockTests { private const string Secret = "test-bot-secret"; private const string JwtKey = "account-lock-test-signing-key-with-sufficient-length"; private const string ExactMessage = "@GreatEagle: This account has been locked for security reasons. Contact us at https://support.radicade.app."; private const string ExactReason = "Moderator (Account lock. Please submit a ticket with us to discuss. https://support.radicade.app/new/account-support/)"; private static RecDBContext NewDb(DbContextOptions? options = null) => new(options ?? TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static async Task SeedAccountAsync(RecDBContext db, long id, string username, string password, string deviceId = "device-1") { db.Profiles.Add(new Profile { accountId = id, username = username, displayName = username, profileImage = "default" }); var player = new PlayerData { AccountId = id, DeviceId = deviceId, DeviceClass = DeviceClass.Screen, AppVersion = "20230831", Email = "" }; player.Hashword = new PasswordHasher().HashPassword(player, password); db.Players.Add(player); db.Presences.Add(new RealPresence { playerId = id, deviceClass = DeviceClass.Screen }); await db.SaveChangesAsync(); } private static AuthAPI CreateApi(RecDBContext db) { var context = new DefaultHttpContext { User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity()) }; context.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; return new AuthAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } }; } private static TokenConnectData PasswordRequest(string username, string password) => new() { grant_type = "password", platform = Platforms.Standalone, device_id = "device-1", client_id = "recroom", ver = "20230831", device_class = DeviceClass.Screen, username = username, password = password }; private static TokenConnectData LinkedRefreshRequest() => new() { grant_type = "refresh_token", platform = Platforms.Steam, platform_id = "76561198000000000", device_id = "device-1", client_id = "recroom", ver = "20230831", device_class = DeviceClass.Screen, refresh_token = GuidFromAccount(17) }; private static string GuidFromAccount(long accountId) { var bytes = new byte[16]; Array.Copy(BitConverter.GetBytes(accountId), 0, bytes, 0, 8); return new Guid(bytes).ToString(); } private static async Task PanelAction(RecDBContext db, long playerId, string kind) { var context = new DefaultHttpContext(); var api = new AdminPanelAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } }; var original = DBConfig.AdminApiKey; DBConfig.AdminApiKey = Secret; try { return await api.PlayerPanelAction(playerId, kind, Secret); } finally { DBConfig.AdminApiKey = original; } } [Fact] public void TheLoginErrorIsExactlyTheSpecifiedMessageIncludingTheAtSign() { Assert.Equal(ExactMessage, AccountLock.LoginErrorMessage("GreatEagle")); Assert.Equal(ExactMessage, AccountLock.LoginErrorMessage("@GreatEagle")); Assert.Equal(AccountLock.ErrorCode, "ACCOUNT_PERMALOCKED"); Assert.Equal("https://support.radicade.app", AccountLock.SupportUrl); } [Fact] public void TheLoginErrorHasNoTitleOrPrefixAndNeverShowsTheInternalBanReason() { var message = AccountLock.LoginErrorMessage("GreatEagle"); Assert.DoesNotContain("Login", message); Assert.DoesNotContain("Error:", message); Assert.DoesNotContain("Login:", message); Assert.DoesNotContain("Account lock.", message); Assert.DoesNotContain("Please submit a ticket", message); Assert.StartsWith("@GreatEagle: This account has been locked", message); Assert.EndsWith("Contact us at https://support.radicade.app.", message); } [Fact] public void TheLoginErrorFallsBackSecurelyWhenTheUsernameCannotBeResolved() { foreach (var blank in new[] { null, "", " ", "@" }) { var message = AccountLock.LoginErrorMessage(blank); Assert.Equal( "@Account: This account has been locked for security reasons. Contact us at https://support.radicade.app.", message); } } [Fact] public void TheBanReasonIsTheExactModeratorWrappedStringAndIsNotTheLoginError() { Assert.StartsWith("Moderator (", AccountLock.Reason); Assert.EndsWith("https://support.radicade.app/new/account-support/)", AccountLock.Reason); Assert.Equal(ExactReason, AccountLock.Reason); var login = AccountLock.LoginErrorMessage("GreatEagle"); Assert.DoesNotContain(AccountLock.Reason, login); Assert.DoesNotContain("Moderator", login); Assert.False(login.Contains(AccountLock.Reason, StringComparison.Ordinal)); } [Fact] public async Task ApplyingALockPersistsThePermanentModeratorBan() { await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); var result = await AccountLock.ApplyAsync(db, 17, "StaffOne", "RAD-42"); Assert.True(result.Success); Assert.True(result.Changed); var ban = await db.Moderation.SingleAsync(x => x.PlayerId == 17); Assert.True(ban.IsBan); Assert.Equal(ReportCategory.Moderator, ban.Category); Assert.Equal(ExactReason, ban.Message); Assert.Equal(AccountLock.BanMethod, ban.BanMethod); Assert.Equal(DateTime.MaxValue, ban.ExpireDuration); Assert.Equal("RAD-42", ban.ModeratorNote); Assert.True(await AccountLock.IsLockedAsync(db, 17)); } [Fact] public async Task ApplyingALockTwiceIsIdempotent() { await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); await AccountLock.ApplyAsync(db, 17, "StaffOne", null); var second = await AccountLock.ApplyAsync(db, 17, "StaffTwo", null); Assert.True(second.Success); Assert.False(second.Changed); Assert.Single(await db.Moderation.Where(x => x.PlayerId == 17).ToListAsync()); Assert.True(await AccountLock.IsLockedAsync(db, 17)); } [Fact] public async Task UnlockingRemovesOnlyTheAccountLock() { await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); await AccountLock.ApplyAsync(db, 17, "StaffOne", null); var result = await AccountLock.RemoveAsync(db, 17, "StaffOne", "RAD-42"); Assert.True(result.Success); Assert.Empty(await db.Moderation.Where(x => x.PlayerId == 17).ToListAsync()); Assert.False(await AccountLock.IsLockedAsync(db, 17)); } [Fact] public async Task UnlockingRefusesToRemoveAnUnrelatedBan() { await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); db.Moderation.Add(new ModerationDB { PlayerId = 17, IsBan = true, Category = ReportCategory.CoC_Trolling, Message = "trolling", BanMethod = "Admin Panel", CreatedAt = DateTime.UtcNow, ExpireDuration = DateTime.UtcNow.AddDays(3) }); await db.SaveChangesAsync(); var result = await AccountLock.RemoveAsync(db, 17, "StaffOne", null); Assert.False(result.Success); var ban = await db.Moderation.SingleAsync(x => x.PlayerId == 17); Assert.Equal("Admin Panel", ban.BanMethod); Assert.Equal("trolling", ban.Message); } [Fact] public async Task LockStateFailsClosedWhenItCannotBeRead() { var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); await db.DisposeAsync(); Assert.True(await AccountLock.IsLockedAsync(db, 17)); } [Fact] public async Task ANormalAccountCanStillLogIn() { var oldKey = DBConfig.JwtKey; DBConfig.JwtKey = JwtKey; try { AuthSecurityMonitor.Reset(); await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); var response = Assert.IsType(Assert.IsType( (await CreateApi(db).ConnectWithToken(PasswordRequest("GreatEagle", "pw123456"))).Result).Value); Assert.True(string.IsNullOrEmpty(response.error)); Assert.False(string.IsNullOrWhiteSpace(response.access_token)); } finally { DBConfig.JwtKey = oldKey; } } [Fact] public async Task ALockedAccountCannotLogInWithCorrectCredentialsAndNoSessionIsIssued() { var oldKey = DBConfig.JwtKey; DBConfig.JwtKey = JwtKey; try { AuthSecurityMonitor.Reset(); await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); await AccountLock.ApplyAsync(db, 17, "StaffOne", null); var response = Assert.IsType(Assert.IsType( (await CreateApi(db).ConnectWithToken(PasswordRequest("GreatEagle", "pw123456"))).Result).Value); Assert.Equal("ACCOUNT_PERMALOCKED", response.error); Assert.Equal(ExactMessage, response.error_description); Assert.True(string.IsNullOrEmpty(response.access_token)); Assert.DoesNotContain(await db.CachedLogins.ToListAsync(), x => x.accountId == 17); } finally { DBConfig.JwtKey = oldKey; } } [Fact] public async Task ALockedAccountCanStillSignInThroughAnIdentityThatIsAlreadyLinked() { var oldKey = DBConfig.JwtKey; DBConfig.JwtKey = JwtKey; try { AuthSecurityMonitor.Reset(); await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); db.CachedLogins.Add(new CachedLogin { accountId = 17, platform = Platforms.Steam, platformId = "76561198000000000", lastLoginTime = DateTime.UtcNow }); await db.SaveChangesAsync(); await AccountLock.ApplyAsync(db, 17, "StaffOne", null); var response = Assert.IsType(Assert.IsType( (await CreateApi(db).ConnectWithToken(LinkedRefreshRequest())).Result).Value); Assert.True(string.IsNullOrEmpty(response.error), response.error); Assert.False(string.IsNullOrWhiteSpace(response.access_token)); } finally { DBConfig.JwtKey = oldKey; } } [Fact] public async Task ALockedAccountIsRefusedAgainOnceTheLinkedIdentityIsDeleted() { var oldKey = DBConfig.JwtKey; DBConfig.JwtKey = JwtKey; try { AuthSecurityMonitor.Reset(); await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); db.CachedLogins.Add(new CachedLogin { accountId = 17, platform = Platforms.Steam, platformId = "76561198000000000", lastLoginTime = DateTime.UtcNow }); await db.SaveChangesAsync(); await AccountLock.ApplyAsync(db, 17, "StaffOne", null); db.CachedLogins.RemoveRange(db.CachedLogins.Where(x => x.accountId == 17)); await db.SaveChangesAsync(); for (var attempt = 0; attempt < 2; attempt++) { var response = Assert.IsType(Assert.IsType( (await CreateApi(db).ConnectWithToken(LinkedRefreshRequest())).Result).Value); Assert.Equal("ACCOUNT_PERMALOCKED", response.error); Assert.Equal(ExactMessage, response.error_description); Assert.True(string.IsNullOrEmpty(response.access_token)); } } finally { DBConfig.JwtKey = oldKey; } } [Fact] public async Task ALockedAccountCanStillUseItsPasswordOnALinkedIdentityButNotAFreshOne() { var oldKey = DBConfig.JwtKey; DBConfig.JwtKey = JwtKey; try { AuthSecurityMonitor.Reset(); await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); db.CachedLogins.Add(new CachedLogin { accountId = 17, platform = Platforms.Standalone, platformId = "radicade-quest:device-1", lastLoginTime = DateTime.UtcNow }); await db.SaveChangesAsync(); await AccountLock.ApplyAsync(db, 17, "StaffOne", null); var linked = Assert.IsType(Assert.IsType( (await CreateApi(db).ConnectWithToken(PasswordRequest("GreatEagle", "pw123456"))).Result).Value); Assert.True(string.IsNullOrEmpty(linked.error), linked.error); Assert.False(string.IsNullOrWhiteSpace(linked.access_token)); var fresh = PasswordRequest("GreatEagle", "pw123456"); fresh.device_id = "device-2"; var refused = Assert.IsType(Assert.IsType( (await CreateApi(db).ConnectWithToken(fresh)).Result).Value); Assert.Equal("ACCOUNT_PERMALOCKED", refused.error); Assert.Equal(ExactMessage, refused.error_description); Assert.True(string.IsNullOrEmpty(refused.access_token)); Assert.DoesNotContain(await db.CachedLogins.ToListAsync(), x => x.platformId == "radicade-quest:device-2"); } finally { DBConfig.JwtKey = oldKey; } } [Fact] public async Task TheLockSurvivesAServerRestart() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using (var first = NewDb(options)) { await SeedAccountAsync(first, 17, "GreatEagle", "pw123456"); await AccountLock.ApplyAsync(first, 17, "StaffOne", null); } await using var restarted = NewDb(options); Assert.True(await AccountLock.IsLockedAsync(restarted, 17)); var lockBan = await AccountLock.FindActiveLockAsync(restarted, 17); Assert.NotNull(lockBan); Assert.Equal(ReportCategory.Moderator, lockBan!.Category); Assert.Equal(ExactReason, lockBan.Message); Assert.Equal(DateTime.MaxValue, lockBan.ExpireDuration); } [Fact] public async Task GenerateTokenNeverMintsATokenForALockedAccount() { var oldKey = DBConfig.JwtKey; DBConfig.JwtKey = JwtKey; try { await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); await AccountLock.ApplyAsync(db, 17, "StaffOne", null); var generate = typeof(AuthAPI).GetMethod("GenerateToken", BindingFlags.NonPublic | BindingFlags.Static)!; var result = await (Task)generate.Invoke(null, new object[] { db, 17L, "20230831", DeviceClass.Screen, Platforms.Steam, "studio:17" })!; Assert.False(result.success); Assert.Equal("ACCOUNT_PERMALOCKED", result.error); Assert.Null(result.value); } finally { DBConfig.JwtKey = oldKey; } } [Fact] public async Task TheLockIsARealBanForTheInGameGatesAndCarriesTheExactReason() { await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); await AccountLock.ApplyAsync(db, 17, "StaffOne", null); var lockBan = await db.Moderation.SingleAsync(x => x.PlayerId == 17); Assert.True(lockBan.IsBan); Assert.Equal(ReportCategory.Moderator, lockBan.Category); Assert.Equal(ExactReason, lockBan.Message); Assert.Equal(DateTime.MaxValue, lockBan.ExpireDuration); Assert.NotNull(await AccountLock.FindActiveLockAsync(db, 17)); Assert.True(await db.IsBanned(17)); var ban = await db.ResolveBanAsync(17); Assert.NotNull(ban); Assert.Equal(ReportCategory.Moderator, ban!.Category); Assert.Equal(ExactReason, ban.Reason); Assert.True(ban.IsPermanent); var details = PlayerUtils.BuildBlockDetails(true, ban.Category, ban.Reason, ban.ExpiryUtc, startedAtUtc: ban.StartedAtUtc); Assert.Equal(ReportCategory.Moderator, details.ReportCategory); Assert.Equal(ExactReason, details.Message); Assert.Equal("Moderation", details.BanType); Assert.DoesNotContain("locked for security reasons", details.Message); } [Fact] public async Task LockingAnOnlinePlayerLandsAsABanEvenIfTheDormMoveCannotRun() { await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); var presence = await db.Presences.SingleAsync(x => x.playerId == 17); presence.isOnline = true; presence.roomInstance = 999999; await db.SaveChangesAsync(); var result = await AccountLock.ApplyAsync(db, 17, "StaffOne", null); Assert.True(result.Success); Assert.True(await AccountLock.IsLockedAsync(db, 17)); Assert.Single(await db.Moderation.Where(x => x.PlayerId == 17).ToListAsync()); Assert.True(await db.IsBanned(17)); var ban = await db.ResolveBanAsync(17); Assert.NotNull(ban); Assert.Equal(ReportCategory.Moderator, ban!.Category); Assert.Equal(ExactReason, ban.Reason); Assert.True(ban.IsPermanent); } [Fact] public async Task ALockCannotBeAppealedThroughTheAppealEndpoints() { await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); await AccountLock.ApplyAsync(db, 17, "StaffOne", null); var context = new DefaultHttpContext { User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity( new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "17") }, "test")) }; var appeal = new RadicadeServer.API.Moderation.BanAppealAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } }; var submitApi = new RadicadeServer.API.Moderation.AppealAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } }; var generated = await appeal.GenerateCode(); var payload = Assert.IsAssignableFrom(generated.Result).Value!; var payloadType = payload.GetType(); Assert.False((bool)payloadType.GetProperty("success")!.GetValue(payload)!); Assert.Equal(AccountLock.NotAppealableMessage, payloadType.GetProperty("error")!.GetValue(payload) as string); Assert.Null((await db.Moderation.SingleAsync(x => x.PlayerId == 17)).AppealCode); var lockBan = await db.Moderation.SingleAsync(x => x.PlayerId == 17); lockBan.AppealCode = "LOCKCODE"; await db.SaveChangesAsync(); var submitted = await submitApi.Submit(new RadicadeServer.API.Moderation.SubmitAppealRequest { Code = "LOCKCODE", Text = "please let me back in, this is a genuine appeal" }); var refused = Assert.IsType(submitted.Result); Assert.Equal(AccountLock.NotAppealableMessage, Assert.IsType(refused.Value).Message); Assert.Null((await db.Moderation.SingleAsync(x => x.PlayerId == 17)).AppealText); } [Fact] public void TheAppealCooldownTextIsTheAgreedWording() { const string expectedPrefix = "You cannot file another ban appeal for: "; Assert.Equal(expectedPrefix, PlayerUtils.AppealCooldownPrefix); Assert.Equal(expectedPrefix + "29 days, 3 hours", PlayerUtils.AppealCooldownPrefix + PlayerUtils.FormatAppealCooldown(TimeSpan.FromDays(29) + TimeSpan.FromHours(3))); } [Fact] public void RepeatedFailuresAlertAndSlowButNeverImposeMoreThanTheCap() { AuthSecurityMonitor.Reset(); var count = 0; for (var i = 0; i < AuthSecurityMonitor.AccountFailureAlertThreshold; i++) count = AuthSecurityMonitor.RecordFailedLogin(7, "203.0.113.9"); Assert.Equal(AuthSecurityMonitor.AccountFailureAlertThreshold, count); Assert.Equal(0, AuthSecurityMonitor.ProgressiveDelayMs(3)); Assert.True(AuthSecurityMonitor.ProgressiveDelayMs(count) > 0); Assert.True(AuthSecurityMonitor.ProgressiveDelayMs(10_000) <= AuthSecurityMonitor.MaxProgressiveDelayMs); } [Fact] public void SprayingAcrossManyAccountsFromOneSourceIsDetected() { AuthSecurityMonitor.Reset(); for (long account = 100; account < 100 + AuthSecurityMonitor.SourceSprayAccountThreshold; account++) AuthSecurityMonitor.RecordFailedLogin(account, "198.51.100.4"); Assert.True(AuthSecurityMonitor.IsSourceThrottled("198.51.100.4")); } [Fact] public void RateLimitingCannotBeAbusedToLockOutAnotherPlayer() { AuthSecurityMonitor.Reset(); for (var i = 0; i < 60; i++) AuthSecurityMonitor.RecordFailedLogin(42, "198.51.100.7"); Assert.False(AuthSecurityMonitor.IsSourceThrottled("198.51.100.7")); } [Fact] public async Task SecuritySignalsAloneNeverCreateALock() { await using var db = NewDb(); await SeedAccountAsync(db, 17, "GreatEagle", "pw123456"); AuthSecurityMonitor.Reset(); for (var i = 0; i < 50; i++) AuthSecurityMonitor.RecordFailedLogin(17, "198.51.100.7"); AuthSecurityMonitor.RecordSuccessfulLogin(17, "203.0.113.55", newDevice: true, newIp: true); AuthSecurityMonitor.NoteCredentialChange(17, "password changed"); Assert.Empty(await db.Moderation.ToListAsync()); Assert.False(await AccountLock.IsLockedAsync(db, 17)); } [Fact] public async Task StaffCanLockAndUnlockThroughThePanel() { await using var db = NewDb(); await SeedAccountAsync(db, 2, "GreatEagle", "pw123456"); Assert.IsType(await PanelAction(db, 2, "lock")); var ban = await db.Moderation.SingleAsync(x => x.PlayerId == 2); Assert.Equal(ReportCategory.Moderator, ban.Category); Assert.Equal(AccountLock.BanMethod, ban.BanMethod); Assert.Equal(ExactReason, ban.Message); Assert.Equal(DateTime.MaxValue, ban.ExpireDuration); Assert.IsType(await PanelAction(db, 2, "unlock")); Assert.Empty(await db.Moderation.Where(x => x.PlayerId == 2).ToListAsync()); } [Fact] public async Task UnlockingThroughThePanelRefusesAndPreservesAnUnrelatedBan() { await using var db = NewDb(); await SeedAccountAsync(db, 2, "GreatEagle", "pw123456"); db.Moderation.Add(new ModerationDB { PlayerId = 2, IsBan = true, Category = ReportCategory.CoC_Trolling, Message = "trolling", BanMethod = "Admin Panel", CreatedAt = DateTime.UtcNow, ExpireDuration = DateTime.UtcNow.AddDays(3) }); await db.SaveChangesAsync(); Assert.IsType(await PanelAction(db, 2, "unlock")); Assert.Equal("Admin Panel", (await db.Moderation.SingleAsync(x => x.PlayerId == 2)).BanMethod); } [Fact] public async Task UnauthorizedStaffCannotLock() { await using var db = NewDb(); await SeedAccountAsync(db, 2, "GreatEagle", "pw123456"); var context = new DefaultHttpContext(); var api = new AdminPanelAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } }; var original = DBConfig.AdminApiKey; DBConfig.AdminApiKey = Secret; try { var result = await api.PlayerPanelAction(2, "lock", "not-the-key"); Assert.IsNotType(result); Assert.Empty(await db.Moderation.ToListAsync()); } finally { DBConfig.AdminApiKey = original; } } }