diff --git a/Refresh.Database/GameDatabaseContext.Registration.cs b/Refresh.Database/GameDatabaseContext.Registration.cs index ba44dfa2..919c647f 100644 --- a/Refresh.Database/GameDatabaseContext.Registration.cs +++ b/Refresh.Database/GameDatabaseContext.Registration.cs @@ -115,7 +115,7 @@ public bool IsEmailTaken(string emailAddress) { return this.GameUsers.Any(u => u.EmailAddress == emailAddress) || this.QueuedRegistrations.Any(r => r.EmailAddress == emailAddress) || - this.IsEmailDisallowed(emailAddress); + this.IsEmailAddressDisallowed(emailAddress); } public void AddRegistrationToQueue(string username, string emailAddress, string passwordBcrypt) @@ -253,34 +253,71 @@ public bool IsUserDisallowed(string username) return this.DisallowedUsers.FirstOrDefault(u => u.Username == username) != null; } - public bool DisallowEmail(string email) + public bool DisallowEmailAddress(string emailAddress) { - if (this.IsEmailDisallowed(email)) + if (this.IsEmailAddressDisallowed(emailAddress)) return false; - this.DisallowedEmails.Add(new() + this.DisallowedEmailAddresses.Add(new() { - Email = email, + Address = emailAddress, }); this.SaveChanges(); return true; } - public bool ReallowEmail(string email) + public bool ReallowEmailAddress(string emailAddress) { - DisallowedEmail? disallowedEmail = this.DisallowedEmails.FirstOrDefault(u => u.Email == email); - if (disallowedEmail == null) + DisallowedEmailAddress? DisallowedEmailAddress = this.DisallowedEmailAddresses.FirstOrDefault(u => u.Address == emailAddress); + if (DisallowedEmailAddress == null) return false; - this.DisallowedEmails.Remove(disallowedEmail); + this.DisallowedEmailAddresses.Remove(DisallowedEmailAddress); this.SaveChanges(); return true; } - public bool IsEmailDisallowed(string email) + public bool IsEmailAddressDisallowed(string emailAddress) { - return this.DisallowedEmails.Any(u => u.Email == email); + return this.DisallowedEmailAddresses.Any(u => u.Address == emailAddress); + } + + private string GetEmailDomainFromAddress(string emailAddress) + => emailAddress.Split('@').Last(); + + public bool DisallowEmailDomain(string emailAddress) + { + string emailDomain = this.GetEmailDomainFromAddress(emailAddress); + if (this.IsEmailDomainDisallowed(emailDomain)) + return false; + + this.DisallowedEmailDomains.Add(new() + { + Domain = emailDomain, + }); + this.SaveChanges(); + + return true; + } + + public bool ReallowEmailDomain(string emailAddress) + { + string emailDomain = this.GetEmailDomainFromAddress(emailAddress); + DisallowedEmailDomain? disallowedDomain = this.DisallowedEmailDomains.FirstOrDefault(u => u.Domain == emailDomain); + if (disallowedDomain == null) + return false; + + this.DisallowedEmailDomains.Remove(disallowedDomain); + this.SaveChanges(); + + return true; + } + + public bool IsEmailDomainDisallowed(string emailAddress) + { + string emailDomain = this.GetEmailDomainFromAddress(emailAddress); + return this.DisallowedEmailDomains.Any(u => u.Domain == emailDomain); } } \ No newline at end of file diff --git a/Refresh.Database/GameDatabaseContext.cs b/Refresh.Database/GameDatabaseContext.cs index 2acf5b11..ea851668 100644 --- a/Refresh.Database/GameDatabaseContext.cs +++ b/Refresh.Database/GameDatabaseContext.cs @@ -64,7 +64,8 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext internal DbSet AssetDependencyRelations { get; set; } internal DbSet GameReviews { get; set; } internal DbSet DisallowedUsers { get; set; } - internal DbSet DisallowedEmails { get; set; } + internal DbSet DisallowedEmailAddresses { get; set; } + internal DbSet DisallowedEmailDomains { get; set; } internal DbSet RateReviewRelations { get; set; } internal DbSet TagLevelRelations { get; set; } internal DbSet GamePlaylists { get; set; } diff --git a/Refresh.Database/Migrations/20260331143640_AddAbilityToDisallowEmailDomains.cs b/Refresh.Database/Migrations/20260331143640_AddAbilityToDisallowEmailDomains.cs new file mode 100644 index 00000000..52f69f3e --- /dev/null +++ b/Refresh.Database/Migrations/20260331143640_AddAbilityToDisallowEmailDomains.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Refresh.Database.Migrations +{ + /// + [DbContext(typeof(GameDatabaseContext))] + [Migration("20260331143640_AddAbilityToDisallowEmailDomains")] + public partial class AddAbilityToDisallowEmailDomains : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameTable(name: "DisallowedEmails", newName: "DisallowedEmailAddresses"); + migrationBuilder.DropPrimaryKey(name: "PK_DisallowedEmails", table: "DisallowedEmailAddresses"); + migrationBuilder.RenameColumn(name: "Email", table: "DisallowedEmailAddresses", newName: "Address"); + migrationBuilder.AddPrimaryKey(name: "PK_DisallowedEmailAddresses", table: "DisallowedEmailAddresses", column: "Address"); + + migrationBuilder.CreateTable( + name: "DisallowedEmailDomains", + columns: table => new + { + Domain = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DisallowedEmailDomains", x => x.Domain); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DisallowedEmailDomains"); + + migrationBuilder.RenameTable(name: "DisallowedEmailAddresses", newName: "DisallowedEmails"); + migrationBuilder.DropPrimaryKey(name: "PK_DisallowedEmailAddresses", table: "DisallowedEmails"); + migrationBuilder.RenameColumn(name: "Address", table: "DisallowedEmails", newName: "Email"); + migrationBuilder.AddPrimaryKey(name: "PK_DisallowedEmails", table: "DisallowedEmails", column: "Email"); + } + } +} diff --git a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs index 7d567b6c..2ff6b5f4 100644 --- a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs +++ b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs @@ -1510,14 +1510,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RequestStatistics"); }); - modelBuilder.Entity("Refresh.Database.Models.Users.DisallowedEmail", b => + modelBuilder.Entity("Refresh.Database.Models.Users.DisallowedEmailAddress", b => { - b.Property("Email") + b.Property("Address") .HasColumnType("text"); - b.HasKey("Email"); + b.HasKey("Address"); - b.ToTable("DisallowedEmails"); + b.ToTable("DisallowedEmailAddresses"); + }); + + modelBuilder.Entity("Refresh.Database.Models.Users.DisallowedEmailDomain", b => + { + b.Property("Domain") + .HasColumnType("text"); + + b.HasKey("Domain"); + + b.ToTable("DisallowedEmailDomains"); }); modelBuilder.Entity("Refresh.Database.Models.Users.DisallowedUser", b => diff --git a/Refresh.Database/Models/Users/DisallowedEmail.cs b/Refresh.Database/Models/Users/DisallowedEmail.cs deleted file mode 100644 index 27889601..00000000 --- a/Refresh.Database/Models/Users/DisallowedEmail.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Refresh.Database.Models.Users; - -#nullable disable - -public partial class DisallowedEmail -{ - [Key] - public string Email { get; set; } -} \ No newline at end of file diff --git a/Refresh.Database/Models/Users/DisallowedEmailAddress.cs b/Refresh.Database/Models/Users/DisallowedEmailAddress.cs new file mode 100644 index 00000000..2cbdb202 --- /dev/null +++ b/Refresh.Database/Models/Users/DisallowedEmailAddress.cs @@ -0,0 +1,9 @@ +namespace Refresh.Database.Models.Users; + +#nullable disable + +public partial class DisallowedEmailAddress +{ + [Key] + public string Address { get; set; } +} \ No newline at end of file diff --git a/Refresh.Database/Models/Users/DisallowedEmailDomain.cs b/Refresh.Database/Models/Users/DisallowedEmailDomain.cs new file mode 100644 index 00000000..d0b69039 --- /dev/null +++ b/Refresh.Database/Models/Users/DisallowedEmailDomain.cs @@ -0,0 +1,9 @@ +namespace Refresh.Database.Models.Users; + +#nullable disable + +public partial class DisallowedEmailDomain +{ + [Key] + public string Domain { get; set; } +} \ No newline at end of file diff --git a/Refresh.GameServer/CommandLineManager.cs b/Refresh.GameServer/CommandLineManager.cs index d8737340..bff16e94 100644 --- a/Refresh.GameServer/CommandLineManager.cs +++ b/Refresh.GameServer/CommandLineManager.cs @@ -60,11 +60,17 @@ private class Options [Option("reallow-user", HelpText = "Re-allow a user to register. Username option is required if this is set.")] public bool ReallowUser { get; set; } - [Option("disallow-email", HelpText = "Disallow the email from being used by anyone in the future. Email option is required if this is set.")] - public bool DisallowEmail { get; set; } + [Option("disallow-email", HelpText = "Disallow the email address from being used by anyone in the future. Email option is required if this is set.")] + public bool DisallowEmailAddress { get; set; } - [Option("reallow-email", HelpText = "Re-allow the email to be used by anyone. Email option is required if this is set")] - public bool ReallowEmail { get; set; } + [Option("reallow-email", HelpText = "Re-allow the email address to be used for account registration. Email option is required if this is set")] + public bool ReallowEmailAddress { get; set; } + + [Option("disallow-email-domain", HelpText = "Disallow the email domain from being used by anyone in the future. Email option is required if this is set. If a whole Email address is given, only the substring after the last @ will be used.")] + public bool DisallowEmailDomain { get; set; } + + [Option("reallow-email-domain", HelpText = "Re-allow the email domain to be used by anyone. Email option is required if this is set. If a whole Email address is given, only the substring after the last @ will be used.")] + public bool ReallowEmailDomain { get; set; } [Option("rename-user", HelpText = "Changes a user's username. (old) username or Email option is required if this is set.")] public string? RenameUser { get; set; } @@ -194,24 +200,42 @@ private void StartWithOptions(Options options) } else Fail("No username was provided"); } - else if (options.DisallowEmail) + else if (options.DisallowEmailAddress) { if (options.EmailAddress != null) { - if (!this._server.DisallowEmail(options.EmailAddress)) + if (!this._server.DisallowEmailAddress(options.EmailAddress)) Fail("Email address is already disallowed"); } else Fail("No email address was provided"); } - else if (options.ReallowEmail) + else if (options.ReallowEmailAddress) { if (options.EmailAddress != null) { - if (!this._server.ReallowEmail(options.EmailAddress)) + if (!this._server.ReallowEmailAddress(options.EmailAddress)) Fail("Email address is already allowed"); } else Fail("No email address was provided"); } + else if (options.DisallowEmailDomain) + { + if (options.EmailAddress != null) + { + if (!this._server.DisallowEmailDomain(options.EmailAddress)) + Fail("Email domain is already disallowed"); + } + else Fail("No email domain was provided"); + } + else if (options.ReallowEmailDomain) + { + if (options.EmailAddress != null) + { + if (!this._server.ReallowEmailDomain(options.EmailAddress)) + Fail("Email domain is already allowed"); + } + else Fail("No email domain was provided"); + } else if (options.RenameUser != null) { if(string.IsNullOrWhiteSpace(options.RenameUser)) diff --git a/Refresh.GameServer/RefreshGameServer.cs b/Refresh.GameServer/RefreshGameServer.cs index a78ba4cc..f012fafa 100644 --- a/Refresh.GameServer/RefreshGameServer.cs +++ b/Refresh.GameServer/RefreshGameServer.cs @@ -285,18 +285,32 @@ public bool ReallowUser(string username) return context.ReallowUser(username); } - public bool DisallowEmail(string email) + public bool DisallowEmailAddress(string address) { using GameDatabaseContext context = this.GetContext(); - - return context.DisallowEmail(email); + + return context.DisallowEmailAddress(address); } - public bool ReallowEmail(string email) + public bool ReallowEmailAddress(string address) { using GameDatabaseContext context = this.GetContext(); - - return context.ReallowEmail(email); + + return context.ReallowEmailAddress(address); + } + + public bool DisallowEmailDomain(string domain) + { + using GameDatabaseContext context = this.GetContext(); + + return context.DisallowEmailDomain(domain); + } + + public bool ReallowEmailDomain(string domain) + { + using GameDatabaseContext context = this.GetContext(); + + return context.ReallowEmailDomain(domain); } public void RenameUser(GameUser user, string newUsername, bool force = false) diff --git a/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs index c0eab2c0..52e14ade 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs @@ -347,7 +347,7 @@ public ApiResponse Register(RequestContext context, if (!smtpService.CheckEmailDomainValidity(body.EmailAddress)) return ApiValidationError.EmailDoesNotActuallyExistError; - if (database.IsUserDisallowed(body.Username) || database.IsEmailDisallowed(body.EmailAddress)) + if (database.IsUserDisallowed(body.Username) || database.IsEmailAddressDisallowed(body.EmailAddress) || database.IsEmailDomainDisallowed(body.EmailAddress)) return new ApiAuthenticationError("You aren't allowed to play on this instance."); if (!database.IsUsernameValid(body.Username)) diff --git a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs index fdcc370d..0bcbb159 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs @@ -47,12 +47,17 @@ public void RegisterAccount() } [Test] - public void CannotRegisterAccountWithDisallowedEmail() + public void CannotRegisterAccountWithDisallowedEmailAddress() { using TestContext context = this.GetServer(); const string email = "guy@lil.com"; - context.Database.DisallowEmail(email); + // Not somehow already disallowed + Assert.That(context.Database.IsEmailAddressDisallowed(email), Is.False); + context.Database.DisallowEmailAddress(email); + + context.Database.Refresh(); + Assert.That(context.Database.IsEmailAddressDisallowed(email), Is.True); ApiResponse? response = context.Http.PostData("/api/v3/register", new ApiRegisterRequest { @@ -66,6 +71,86 @@ public void CannotRegisterAccountWithDisallowedEmail() context.Database.Refresh(); Assert.That(context.Database.GetUserByEmailAddress(email), Is.Null); + + // Undo + context.Database.ReallowEmailAddress(email); + context.Database.Refresh(); + Assert.That(context.Database.IsEmailAddressDisallowed(email), Is.False); + } + + [Test] + public void CannotRegisterAccountsWithDisallowedEmailDomain() + { + using TestContext context = this.GetServer(); + + const string email = "guy@moron.com"; + // Not somehow already disallowed + Assert.That(context.Database.IsEmailDomainDisallowed(email), Is.False); + context.Database.DisallowEmailDomain(email); + + context.Database.Refresh(); + Assert.That(context.Database.IsEmailDomainDisallowed(email), Is.True); + + // Attempt 1 (block) + ApiResponse? response = context.Http.PostData("/api/v3/register", new ApiRegisterRequest + { + Username = "a_lil_guy", + EmailAddress = "pisser@moron.com", + PasswordSha512 = "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", + }, false, true); + Assert.That(response, Is.Not.Null); + Assert.That(response!.Error, Is.Not.Null); + Assert.That(response.Error!.Name, Is.EqualTo("ApiAuthenticationError")); + context.Database.Refresh(); + Assert.That(context.Database.GetUserByEmailAddress("pisser@moron.com"), Is.Null); + + // Attempt 2 (block) + response = context.Http.PostData("/api/v3/register", new ApiRegisterRequest + { + Username = "a_lil_guy", + EmailAddress = "shitter@moron.com", + PasswordSha512 = "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", + }, false, true); + Assert.That(response, Is.Not.Null); + Assert.That(response!.Error, Is.Not.Null); + Assert.That(response.Error!.Name, Is.EqualTo("ApiAuthenticationError")); + context.Database.Refresh(); + Assert.That(context.Database.GetUserByEmailAddress("shitter@moron.com"), Is.Null); + + // Attempt 3 (block) + response = context.Http.PostData("/api/v3/register", new ApiRegisterRequest + { + Username = "a_lil_guy", + EmailAddress = ".@moron.com", + PasswordSha512 = "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", + }, false, true); + Assert.That(response, Is.Not.Null); + Assert.That(response!.Error, Is.Not.Null); + Assert.That(response.Error!.Name, Is.EqualTo("ApiAuthenticationError")); + context.Database.Refresh(); + Assert.That(context.Database.GetUserByEmailAddress(".@moron.com"), Is.Null); + + // Attempt 4 (allow) + response = context.Http.PostData("/api/v3/register", new ApiRegisterRequest + { + Username = "a_lil_guy", + EmailAddress = "quacker@hi.com", + PasswordSha512 = "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", + }); + Assert.That(response, Is.Not.Null); + Assert.That(response!.Error, Is.Null); + Assert.That(response!.Data, Is.Not.Null); + context.Database.Refresh(); + + GameUser? quacker = context.Database.GetUserByEmailAddress("quacker@hi.com"); + Assert.That(quacker, Is.Not.Null); + Assert.That(quacker!.UserId.ToString(), Is.EqualTo(response.Data!.UserId)); + Assert.That(quacker!.Username, Is.EqualTo("a_lil_guy")); + + // Undo + context.Database.ReallowEmailDomain(email); + context.Database.Refresh(); + Assert.That(context.Database.IsEmailDomainDisallowed(email), Is.False); } [Test] diff --git a/RefreshTests.GameServer/Tests/Users/UserActionTests.cs b/RefreshTests.GameServer/Tests/Users/UserActionTests.cs index 20e3b747..22a9d6d7 100644 --- a/RefreshTests.GameServer/Tests/Users/UserActionTests.cs +++ b/RefreshTests.GameServer/Tests/Users/UserActionTests.cs @@ -88,13 +88,13 @@ public void DeletingUserDoesNotDisallowEmail() using TestContext context = this.GetServer(); GameUser publisher = context.CreateUser(); Assert.That(publisher.EmailAddress, Is.Not.Null); - Assert.That(context.Database.IsEmailDisallowed(publisher.EmailAddress!), Is.False); + Assert.That(context.Database.IsEmailAddressDisallowed(publisher.EmailAddress!), Is.False); string email = publisher.EmailAddress!; // Delete publisher context.Database.DeleteUser(publisher); context.Database.Refresh(); - Assert.That(context.Database.IsEmailDisallowed(email), Is.False); + Assert.That(context.Database.IsEmailAddressDisallowed(email), Is.False); } } \ No newline at end of file