Add email blacklist and refactor email validity checks

This commit is contained in:
FeTetra 2024-12-16 19:42:05 -05:00
commit 980a2af3c6
7 changed files with 71 additions and 23 deletions

View file

@ -129,9 +129,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.";
if (message.StartsWith("/setemail ") && ServerConfiguration.Instance.Mail.MailEnabled) if (message.StartsWith("/setemail ") && ServerConfiguration.Instance.Mail.MailEnabled)
{ {
string email = message[(message.IndexOf(" ", StringComparison.Ordinal)+1)..]; 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); UserEntity? user = await this.database.UserFromGameToken(token);
if (user == null || user.EmailAddressVerified) return this.Ok(); if (user == null || user.EmailAddressVerified) return this.Ok();

View file

@ -39,7 +39,7 @@ public class SetEmailForm : BaseLayout
UserEntity? user = await this.Database.Users.FirstOrDefaultAsync(u => u.UserId == token.UserId); UserEntity? user = await this.Database.Users.FirstOrDefaultAsync(u => u.UserId == token.UserId);
if (user == null) return this.Redirect("~/login"); if (user == null) return this.Redirect("~/login");
if (!SanitizationHelper.IsValidEmail(emailAddress)) if (!SMTPHelper.IsValidEmail(this.Database, emailAddress))
{ {
this.Error = this.Translate(ErrorStrings.EmailInvalid); this.Error = this.Translate(ErrorStrings.EmailInvalid);
return this.Page(); return this.Page();

View file

@ -38,9 +38,9 @@ public class PasswordResetRequestForm : BaseLayout
return this.Page(); 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(); return this.Page();
} }

View file

@ -65,17 +65,13 @@ public class UserSettingsPage : BaseLayout
} }
if (ServerConfiguration.Instance.Mail.MailEnabled && if (ServerConfiguration.Instance.Mail.MailEnabled &&
SanitizationHelper.IsValidEmail(email) && SMTPHelper.IsValidEmail(this.Database, email) &&
(this.User == this.ProfileUser || this.User.IsAdmin)) (this.User == this.ProfileUser || this.User.IsAdmin))
{ {
// if email hasn't already been used if (this.ProfileUser.EmailAddress != email)
if (!await this.Database.Users.AnyAsync(u => u.EmailAddress != null && u.EmailAddress.ToLower() == email!.ToLower()))
{ {
if (this.ProfileUser.EmailAddress != email) this.ProfileUser.EmailAddress = email;
{ this.ProfileUser.EmailAddressVerified = false;
this.ProfileUser.EmailAddress = email;
this.ProfileUser.EmailAddressVerified = false;
}
} }
} }

View file

@ -0,0 +1,25 @@
#nullable enable
using System.Collections.Generic;
using System.IO;
using YamlDotNet.Serialization;
namespace LBPUnion.ProjectLighthouse.Configuration;
public class EnforceEmailConfiguration : ConfigurationBase<EnforceEmailConfiguration>
{
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<EnforceEmailConfiguration> Deserialize
(IDeserializer deserializer, string text) =>
deserializer.Deserialize<EnforceEmailConfiguration>(text);
}

View file

@ -2,6 +2,8 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Configuration;
@ -21,6 +23,13 @@ public static class SMTPHelper
private const long emailCooldown = 1000 * 30; 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<string> blacklistedDomains = new(blacklistFile);
private static bool CanSendMail(UserEntity user) private static bool CanSendMail(UserEntity user)
{ {
// Remove expired entries // Remove expired entries
@ -69,6 +78,33 @@ public static class SMTPHelper
recentlySentMail.TryAdd(user.UserId, TimeHelper.TimestampMillis + emailCooldown); 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<bool> 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) public static void SendRegistrationEmail(IMailService mail, UserEntity user)
{ {
// There is intentionally no cooldown here because this is only used for registration // There is intentionally no cooldown here because this is only used for registration

View file

@ -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);
}