diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs index 42b1d1b7..2a519d84 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs @@ -129,9 +129,9 @@ along with this program. If not, see ."; if (message.StartsWith("/setemail ") && ServerConfiguration.Instance.Mail.MailEnabled) { string email = message[(message.IndexOf(" ", StringComparison.Ordinal)+1)..]; - if (!SanitizationHelper.IsValidEmail(email)) return this.Ok(); - if (await this.database.Users.AnyAsync(u => u.EmailAddress == email)) return this.Ok(); + // Return a bad request on invalid email address + if (!SMTPHelper.IsValidEmail(this.database, email)) return this.BadRequest(); UserEntity? user = await this.database.UserFromGameToken(token); if (user == null || user.EmailAddressVerified) return this.Ok(); diff --git a/ProjectLighthouse.Servers.Website/Pages/Email/SetEmailForm.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/Email/SetEmailForm.cshtml.cs index b760f03e..7cca3b9c 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Email/SetEmailForm.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/Email/SetEmailForm.cshtml.cs @@ -39,7 +39,7 @@ public class SetEmailForm : BaseLayout UserEntity? user = await this.Database.Users.FirstOrDefaultAsync(u => u.UserId == token.UserId); if (user == null) return this.Redirect("~/login"); - if (!SanitizationHelper.IsValidEmail(emailAddress)) + if (!SMTPHelper.IsValidEmail(this.Database, emailAddress)) { this.Error = this.Translate(ErrorStrings.EmailInvalid); return this.Page(); diff --git a/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs index 9d9aeff7..c4bb84da 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs @@ -38,9 +38,9 @@ public class PasswordResetRequestForm : BaseLayout return this.Page(); } - if (!SanitizationHelper.IsValidEmail(email)) + if (!SMTPHelper.IsValidEmail(this.Database, email)) { - this.Error = "This email is in an invalid format"; + this.Error = "This email is invalid"; return this.Page(); } diff --git a/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs index 55375a42..d1a9c0a8 100644 --- a/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs @@ -65,17 +65,13 @@ public class UserSettingsPage : BaseLayout } if (ServerConfiguration.Instance.Mail.MailEnabled && - SanitizationHelper.IsValidEmail(email) && + SMTPHelper.IsValidEmail(this.Database, email) && (this.User == this.ProfileUser || this.User.IsAdmin)) { - // if email hasn't already been used - if (!await this.Database.Users.AnyAsync(u => u.EmailAddress != null && u.EmailAddress.ToLower() == email!.ToLower())) + if (this.ProfileUser.EmailAddress != email) { - if (this.ProfileUser.EmailAddress != email) - { - this.ProfileUser.EmailAddress = email; - this.ProfileUser.EmailAddressVerified = false; - } + this.ProfileUser.EmailAddress = email; + this.ProfileUser.EmailAddressVerified = false; } } diff --git a/ProjectLighthouse/Configuration/EnforceEmailConfiguration.cs b/ProjectLighthouse/Configuration/EnforceEmailConfiguration.cs new file mode 100644 index 00000000..c3354fdb --- /dev/null +++ b/ProjectLighthouse/Configuration/EnforceEmailConfiguration.cs @@ -0,0 +1,25 @@ +#nullable enable +using System.Collections.Generic; +using System.IO; +using YamlDotNet.Serialization; + +namespace LBPUnion.ProjectLighthouse.Configuration; + +public class EnforceEmailConfiguration : ConfigurationBase +{ + public override int ConfigVersion { get; set; } = 2; + + public override string ConfigName { get; set; } = "enforce-email.yml"; + + public override bool NeedsConfiguration { get; set; } = false; + + public bool EnableEmailEnforcement { get; set; } = false; + public bool EnableEmailBlacklist { get; set; } = false; + + // No blacklist by default, add path to blacklist + public string BlacklistFilePath { get; set; } = ""; + + public override ConfigurationBase Deserialize + (IDeserializer deserializer, string text) => + deserializer.Deserialize(text); +} \ No newline at end of file diff --git a/ProjectLighthouse/Helpers/EmailHelper.cs b/ProjectLighthouse/Helpers/EmailHelper.cs index 52c69f9d..bb780a3f 100644 --- a/ProjectLighthouse/Helpers/EmailHelper.cs +++ b/ProjectLighthouse/Helpers/EmailHelper.cs @@ -2,9 +2,11 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; using System.Linq; using System.Threading.Tasks; -using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Types.Entities.Profile; @@ -20,6 +22,13 @@ public static class SMTPHelper private static readonly ConcurrentDictionary recentlySentMail = new(); private const long emailCooldown = 1000 * 30; + + // To prevent ReadAllLines() exception when BlacklistFilePath is empty + private static readonly string[] blacklistFile = + !string.IsNullOrWhiteSpace(EnforceEmailConfiguration.Instance.BlacklistFilePath) + ? File.ReadAllLines(EnforceEmailConfiguration.Instance.BlacklistFilePath) : []; + + private static readonly HashSet blacklistedDomains = new(blacklistFile); private static bool CanSendMail(UserEntity user) { @@ -68,6 +77,33 @@ public static class SMTPHelper recentlySentMail.TryAdd(user.UserId, TimeHelper.TimestampMillis + emailCooldown); } + + // Accumulate checks to determine email validity + public static bool IsValidEmail(DatabaseContext database, string email) + { + // Email should not be empty, should be an actual email, and shouldn't already be used by an account + if (!string.IsNullOrWhiteSpace(email) && new EmailAddressAttribute().IsValid(email) && !EmailIsUsed(database, email).Result) + { + // Get domain after '@' character + string domain = email.Split('@')[1]; + + // Don't even bother if there are no domains in blacklist (AKA file path is empty/invalid, or file itself is empty) + if (EnforceEmailConfiguration.Instance.EnableEmailBlacklist && blacklistedDomains.Count > 0) return !DomainIsInBlacklist(domain); + + return true; + } + + return false; + } + + // Check if email is already in use by an account + private static async Task EmailIsUsed(DatabaseContext database, string email) + { + return await database.Users.AnyAsync(u => u.EmailAddress != null && u.EmailAddress.ToLower() == email.ToLower()); + } + + // Check if domain blacklist contains input domain + private static bool DomainIsInBlacklist(string domain) => blacklistedDomains.Contains(domain); public static void SendRegistrationEmail(IMailService mail, UserEntity user) { diff --git a/ProjectLighthouse/Helpers/SanitizationHelper.cs b/ProjectLighthouse/Helpers/SanitizationHelper.cs deleted file mode 100644 index 0065bc9f..00000000 --- a/ProjectLighthouse/Helpers/SanitizationHelper.cs +++ /dev/null @@ -1,9 +0,0 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; - -namespace LBPUnion.ProjectLighthouse.Helpers; - -public static class SanitizationHelper -{ - public static bool IsValidEmail(string? email) => !string.IsNullOrWhiteSpace(email) && new EmailAddressAttribute().IsValid(email); -} \ No newline at end of file