diff --git a/UT4MasterServer.Common/Enums/AccountFlags.cs b/UT4MasterServer.Common/Enums/AccountFlags.cs index e756d570..d80f6ee7 100644 --- a/UT4MasterServer.Common/Enums/AccountFlags.cs +++ b/UT4MasterServer.Common/Enums/AccountFlags.cs @@ -72,5 +72,10 @@ public enum AccountFlags /// ACL_Maintenance = 0x1000, + /// + /// Flag to determine if email was verified + /// + EmailVerified = 0x2000, + /// NOTE: if you add more flags, make sure to update checks. } diff --git a/UT4MasterServer.Common/Exceptions/AwsSesClientException.cs b/UT4MasterServer.Common/Exceptions/AwsSesClientException.cs new file mode 100644 index 00000000..322d0147 --- /dev/null +++ b/UT4MasterServer.Common/Exceptions/AwsSesClientException.cs @@ -0,0 +1,13 @@ +namespace UT4MasterServer.Common.Exceptions; + +[Serializable] +public sealed class AwsSesClientException : Exception +{ + public AwsSesClientException(string message) : base(message) + { + } + + public AwsSesClientException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/UT4MasterServer.Common/Exceptions/EmailVerificationException.cs b/UT4MasterServer.Common/Exceptions/EmailVerificationException.cs new file mode 100644 index 00000000..65bd3ccb --- /dev/null +++ b/UT4MasterServer.Common/Exceptions/EmailVerificationException.cs @@ -0,0 +1,14 @@ +namespace UT4MasterServer.Common.Exceptions; + +[Serializable] +public sealed class EmailVerificationException : Exception +{ + public EmailVerificationException(string message) : base(message) + { + } + + public EmailVerificationException(string message, Exception innerException) : base(message, innerException) + { + } +} + diff --git a/UT4MasterServer.Common/Exceptions/NotFoundException.cs b/UT4MasterServer.Common/Exceptions/NotFoundException.cs new file mode 100644 index 00000000..329eccb1 --- /dev/null +++ b/UT4MasterServer.Common/Exceptions/NotFoundException.cs @@ -0,0 +1,13 @@ +namespace UT4MasterServer.Common.Exceptions; + +[Serializable] +public sealed class NotFoundException : Exception +{ + public NotFoundException(string message) : base(message) + { + } + + public NotFoundException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/UT4MasterServer.Common/Exceptions/RateLimitExceededException.cs b/UT4MasterServer.Common/Exceptions/RateLimitExceededException.cs new file mode 100644 index 00000000..0f325365 --- /dev/null +++ b/UT4MasterServer.Common/Exceptions/RateLimitExceededException.cs @@ -0,0 +1,13 @@ +namespace UT4MasterServer.Common.Exceptions; + +[Serializable] +public sealed class RateLimitExceededException : Exception +{ + public RateLimitExceededException(string message) : base(message) + { + } + + public RateLimitExceededException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/UT4MasterServer.Common/Helpers/AccountFlagsHelper.cs b/UT4MasterServer.Common/Helpers/AccountFlagsHelper.cs index d1d06507..f4d3079b 100644 --- a/UT4MasterServer.Common/Helpers/AccountFlagsHelper.cs +++ b/UT4MasterServer.Common/Helpers/AccountFlagsHelper.cs @@ -16,6 +16,7 @@ public static bool IsACLFlag(AccountFlags flag) AccountFlags.ACL_CloudStorageAnnouncements | AccountFlags.ACL_CloudStorageRulesets | AccountFlags.ACL_CloudStorageChallenges | - AccountFlags.ACL_Maintenance); + AccountFlags.ACL_Maintenance | + AccountFlags.EmailVerified); } } diff --git a/UT4MasterServer.Common/Helpers/ValidationHelper.cs b/UT4MasterServer.Common/Helpers/ValidationHelper.cs index c6b19b9a..6f716840 100644 --- a/UT4MasterServer.Common/Helpers/ValidationHelper.cs +++ b/UT4MasterServer.Common/Helpers/ValidationHelper.cs @@ -6,6 +6,7 @@ public static class ValidationHelper { private static readonly Regex regexEmail; private static readonly List disallowedUsernameWords; + private static readonly List disallowedEmailDomains; static ValidationHelper() { @@ -17,6 +18,99 @@ static ValidationHelper() "shit", "fuck", "bitch", "slut", "sex", "cum", "nigger", "hitler", "nazi" }; + disallowedEmailDomains = new List + { + "yopmail.com", + "maildrop.cc", + "dispostable.com", + "guerrillamail.com", + "mailinator.com", + "tempr.email", + "discard.email", + "discardmail.com", + "discardmail.de", + "spambog.com", + "spambog.de", + "spambog.ru", + "0815.ru", + "knol-power.nl", + "freundin.ru", + "smashmail.de", + "s0ny.net", + "1mail.x24hr.com", + "from.onmypc.info", + "now.mefound.com", + "mowgli.jungleheart.com", + "cr.cloudns.asia", + "tls.cloudns.asia", + "msft.cloudns.asia", + "b.cr.cloudns.asia", + "ssl.tls.cloudns.asia", + "sweetxxx.de", + "dvd.dns-cloud.net", + "dvd.dnsabr.com", + "bd.dns-cloud.net", + "yx.dns-cloud.net", + "shit.dns-cloud.net", + "shit.dnsabr.com", + "eu.dns-cloud.net", + "eu.dnsabr.com", + "asia.dnsabr.com", + "8.dnsabr.com", + "pw.8.dnsabr.com", + "mm.8.dnsabr.com", + "23.8.dnsabr.com", + "pw.epac.to", + "postheo.de", + "sexy.camdvr.org", + "888.dns-cloud.net", + "adult-work.info", + "trap-mail.de", + "m.cloudns.cl", + "t.woeishyang.com", + "pflege-schoene-haut.de", + "streamboost.xyz", + "okmail.p-e.kr", + "hotbird.giize.com", + "as10.dnsfree.com", + "mehr-bitcoin.de", + "a1b2.cloudns.ph", + "wacamole.soynashi.tk", + "temp69.email", + "secure.okay.email.safeds.tk", + "tajba.com", + "web.run.place", + "tempr-mail.line.pm", + "spam.ceo", + "healthydevelopimmune.com", + "infobisnisdigital.com", + "winayabeauty.com", + "nxyl.eu", + "edukansassu12a.cf", + "mail.checkermaker.me", + "mailcatch.com", + "emailondeck.com", + "mailnesia.com", + "superrito.com", + "armyspy.com", + "cuvox.de", + "dayrep.com", + "einrot.com", + "fleckens.hu", + "gustr.com", + "jourrapide.com", + "rhyta.com", + "superrito.com", + "teleworm.us", + "mintemail.com", + "bbitq.com", + "iucake.com", + "gufum.com", + "boxmail.lol", + "nfstripss.com", + "dropjar.com", + "33mail.com", + }; } public static bool ValidateEmail(string email) @@ -26,7 +120,14 @@ public static bool ValidateEmail(string email) return false; } - return regexEmail.IsMatch(email); + if (!regexEmail.IsMatch(email)) + return false; + + var emailDomain = email.ToLower().Split('@')[1]; + if (disallowedEmailDomains.Contains(emailDomain)) + return false; + + return true; } public static bool ValidateUsername(string username) diff --git a/UT4MasterServer.Models/DTO/Request/SendEmailRequest.cs b/UT4MasterServer.Models/DTO/Request/SendEmailRequest.cs new file mode 100644 index 00000000..a6542e31 --- /dev/null +++ b/UT4MasterServer.Models/DTO/Request/SendEmailRequest.cs @@ -0,0 +1,9 @@ +namespace UT4MasterServer.Models.DTO.Request; + +public sealed class SendEmailRequest +{ + public string From { get; set; } = string.Empty; + public List To { get; set; } = new(); + public string Subject { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; +} diff --git a/UT4MasterServer.Models/Database/Account.cs b/UT4MasterServer.Models/Database/Account.cs index 09b6d13b..d9da3ced 100644 --- a/UT4MasterServer.Models/Database/Account.cs +++ b/UT4MasterServer.Models/Database/Account.cs @@ -69,6 +69,18 @@ public class Account [BsonElement("Flags")] public AccountFlags Flags { get; set; } = 0; + [BsonIgnoreIfNull] + public string? VerificationLinkGUID { get; set; } + + [BsonIgnoreIfNull] + public DateTime? VerificationLinkExpiration { get; set; } + + [BsonIgnoreIfNull] + public string? ResetLinkGUID { get; set; } + + [BsonIgnoreIfNull] + public DateTime? ResetLinkExpiration { get; set; } + [BsonIgnore] public float Level { diff --git a/UT4MasterServer.Models/Settings/AWSSettings.cs b/UT4MasterServer.Models/Settings/AWSSettings.cs new file mode 100644 index 00000000..7835cbd7 --- /dev/null +++ b/UT4MasterServer.Models/Settings/AWSSettings.cs @@ -0,0 +1,8 @@ +namespace UT4MasterServer.Models.Settings; + +public sealed class AWSSettings +{ + public string AccessKey { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; + public string RegionName { get; set; } = string.Empty; +} diff --git a/UT4MasterServer.Models/Settings/ApplicationSettings.cs b/UT4MasterServer.Models/Settings/ApplicationSettings.cs index d28b7143..467727d4 100644 --- a/UT4MasterServer.Models/Settings/ApplicationSettings.cs +++ b/UT4MasterServer.Models/Settings/ApplicationSettings.cs @@ -1,4 +1,4 @@ -namespace UT4MasterServer.Models.Settings; +namespace UT4MasterServer.Models.Settings; public sealed class ApplicationSettings { @@ -11,10 +11,22 @@ public sealed class ApplicationSettings public bool AllowPasswordGrantType { get; set; } = false; /// - /// Used just to redirect users to correct domain when UT4UU is being used. + /// Used for URL generation when sending activation link, reset links, etc. + /// + public string WebsiteScheme { get; set; } = string.Empty; + + /// + /// Used for + /// - email verification links + /// - reset password links /// public string WebsiteDomain { get; set; } = string.Empty; + /// + /// Used for URL generation when sending activation link, reset links, etc. + /// + public int WebsitePort { get; set; } = -1; + /// /// File containing a list of trusted proxy servers (one per line). /// This file is loaded only once when program starts and it add values to . @@ -30,4 +42,10 @@ public sealed class ApplicationSettings /// IP addresses of trusted proxy servers. /// public List ProxyServers { get; set; } = new List(); + + /// + /// No-reply email that will be used for activation links, reset password links, etc + /// + /// no-reply@example.com + public string NoReplyEmail { get; set; } = string.Empty; } diff --git a/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs b/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs index 5018c65a..f239242b 100644 --- a/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs +++ b/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs @@ -72,6 +72,9 @@ private void DoWork(object? state) logger.LogInformation("Background task deleted {DeleteCount} stale game servers.", deleteCount); } + var cleanupService = scope.ServiceProvider.GetRequiredService(); + await cleanupService.RemoveNonVerifiedAccountsAsync(); + await DeleteOldStatisticsAsync(scope); await MergeOldStatisticsAsync(scope); }); diff --git a/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs b/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs index b9f021dc..22704a65 100644 --- a/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs +++ b/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs @@ -1,7 +1,6 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using UT4MasterServer.Models.Settings; using UT4MasterServer.Services.Scoped; namespace UT4MasterServer.Services.Hosted; @@ -9,30 +8,24 @@ namespace UT4MasterServer.Services.Hosted; public sealed class ApplicationStartupService : IHostedService { private readonly ILogger logger; - private readonly AccountService accountService; - private readonly StatisticsService statisticsService; - private readonly CloudStorageService cloudStorageService; - private readonly ClientService clientService; - private readonly RatingsService ratingsService; - - public ApplicationStartupService( - ILogger logger, - ILogger statsLogger, - IOptions settings, - ILogger cloudStorageLogger, - ILogger ratingsLogger) + private readonly IServiceProvider serviceProvider; + + public ApplicationStartupService(ILogger logger, IServiceProvider serviceProvider) { this.logger = logger; - var db = new DatabaseContext(settings); - accountService = new AccountService(db, settings); - statisticsService = new StatisticsService(statsLogger, db); - cloudStorageService = new CloudStorageService(db, cloudStorageLogger); - clientService = new ClientService(db); - ratingsService = new RatingsService(ratingsLogger, db); + this.serviceProvider = serviceProvider; } public async Task StartAsync(CancellationToken cancellationToken) { + using var scope = serviceProvider.CreateScope(); + + var accountService = scope.ServiceProvider.GetRequiredService(); + var statisticsService = scope.ServiceProvider.GetRequiredService(); + var ratingsService = scope.ServiceProvider.GetRequiredService(); + var cloudStorageService = scope.ServiceProvider.GetRequiredService(); + var clientService = scope.ServiceProvider.GetRequiredService(); + logger.LogInformation("Configuring MongoDB indexes."); await accountService.CreateIndexesAsync(); await statisticsService.CreateIndexesAsync(); diff --git a/UT4MasterServer.Services/Interfaces/IEmailService.cs b/UT4MasterServer.Services/Interfaces/IEmailService.cs new file mode 100644 index 00000000..46357f5a --- /dev/null +++ b/UT4MasterServer.Services/Interfaces/IEmailService.cs @@ -0,0 +1,7 @@ +namespace UT4MasterServer.Services.Interfaces; + +public interface IEmailService +{ + Task SendTextEmailAsync(string fromAddress, List toAddresses, string subject, string body); + Task SendHTMLEmailAsync(string fromAddress, List toAddresses, string subject, string body); +} diff --git a/UT4MasterServer.Services/Scoped/AccountService.cs b/UT4MasterServer.Services/Scoped/AccountService.cs index 4734f575..9cf13c42 100644 --- a/UT4MasterServer.Services/Scoped/AccountService.cs +++ b/UT4MasterServer.Services/Scoped/AccountService.cs @@ -1,21 +1,34 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using MongoDB.Driver; using UT4MasterServer.Common; using UT4MasterServer.Common.Enums; +using UT4MasterServer.Common.Exceptions; using UT4MasterServer.Common.Helpers; using UT4MasterServer.Models.Database; using UT4MasterServer.Models.DTO.Responses; using UT4MasterServer.Models.Settings; +using UT4MasterServer.Services.Interfaces; namespace UT4MasterServer.Services.Scoped; public sealed class AccountService { private readonly IMongoCollection accountCollection; + private readonly ApplicationSettings applicationSettings; + private readonly IEmailService emailService; + private readonly ILogger _logger; - public AccountService(DatabaseContext dbContext, IOptions settings) + public AccountService( + DatabaseContext dbContext, + IOptions applicationSettings, + IEmailService emailService, + ILogger logger) { + this.applicationSettings = applicationSettings.Value; accountCollection = dbContext.Database.GetCollection("accounts"); + this.emailService = emailService; + _logger = logger; } public async Task CreateIndexesAsync() @@ -35,11 +48,15 @@ public async Task CreateAccountAsync(string username, string email, string passw { ID = EpicID.GenerateNew(), Username = username, - Email = email + Email = email, + VerificationLinkGUID = Guid.NewGuid().ToString(), + VerificationLinkExpiration = DateTime.UtcNow.AddMinutes(5), }; newAccount.Password = PasswordHelper.GetPasswordHash(newAccount.ID, password); await accountCollection.InsertOneAsync(newAccount); + + await SendVerificationLinkAsync(email, newAccount.ID, newAccount.VerificationLinkGUID); } public async Task GetAccountByEmailAsync(string email) @@ -174,5 +191,153 @@ public async Task RemoveAccountAsync(EpicID id) { await accountCollection.DeleteOneAsync(user => user.ID == id); } -} + public async Task> GetNonVerifiedAccountsAsync() + { + var filter = (Builders.Filter.BitsAnyClear(f => f.Flags, (long)AccountFlags.EmailVerified) | + Builders.Filter.Exists(f => f.Flags, false)) & + Builders.Filter.Lt(f => f.LastLoginAt, DateTime.UtcNow.AddMonths(-3)); + var accountsIDs = await accountCollection.Find(filter).Project(p => p.ID).ToListAsync(); + return accountsIDs; + } + + public async Task VerifyEmailAsync(EpicID accountID, string guid) + { + var filter = Builders.Filter.Eq(f => f.ID, accountID) & + Builders.Filter.Eq(f => f.VerificationLinkGUID, guid) & + Builders.Filter.Gt(f => f.VerificationLinkExpiration, DateTime.UtcNow) & + (Builders.Filter.BitsAnyClear(f => f.Flags, (long)AccountFlags.EmailVerified) | + Builders.Filter.Exists(f => f.Flags, false)); + var account = await accountCollection.Find(filter).FirstOrDefaultAsync(); + + if (account is null) + { + throw new EmailVerificationException("Email verification failed: requested account not found, verification link not found or expired."); + } + + var updateDefinition = Builders.Update + .BitwiseOr(s => s.Flags, AccountFlags.EmailVerified) + .Unset(u => u.VerificationLinkGUID) + .Unset(u => u.VerificationLinkExpiration); + await accountCollection.UpdateOneAsync(filter, updateDefinition); + } + + public async Task ResendVerificationLinkAsync(string email) + { + var filter = Builders.Filter.Eq(f => f.Email, email) & + (Builders.Filter.BitsAnyClear(f => f.Flags, (long)AccountFlags.EmailVerified) | + Builders.Filter.Exists(f => f.Flags, false)); + var account = await accountCollection.Find(filter).FirstOrDefaultAsync(); + + if (account is null) + { + throw new NotFoundException("Email not found or already verified."); + } + + var activationGUID = Guid.NewGuid().ToString(); + + var updateDefinition = Builders.Update + .Set(s => s.VerificationLinkGUID, activationGUID) + .Set(s => s.VerificationLinkExpiration, DateTime.UtcNow.AddMinutes(5)); + await accountCollection.UpdateOneAsync(filter, updateDefinition); + + await SendVerificationLinkAsync(email, account.ID, activationGUID); + } + + public async Task InitiateResetPasswordAsync(string email) + { + var filter = Builders.Filter.Eq(f => f.Email, email) & + Builders.Filter.BitsAnySet(f => f.Flags, (long)AccountFlags.EmailVerified); + var account = await accountCollection.Find(filter).FirstOrDefaultAsync(); + + if (account is null) + { + throw new NotFoundException("Email not found or not verified."); + } + + var guid = Guid.NewGuid().ToString(); + + var updateDefinition = Builders.Update + .Set(s => s.ResetLinkGUID, guid) + .Set(u => u.ResetLinkExpiration, DateTime.UtcNow.AddMinutes(5)); + await accountCollection.UpdateOneAsync(filter, updateDefinition); + + await SendResetPasswordLinkAsync(email, account.ID, guid); + } + + public async Task ResetPasswordAsync(EpicID accountID, string guid, string newPassword) + { + var filter = Builders.Filter.Eq(x => x.ID, accountID) & + Builders.Filter.Eq(x => x.ResetLinkGUID, guid) & + Builders.Filter.Gt(x => x.ResetLinkExpiration, DateTime.UtcNow); + var account = await accountCollection.Find(filter).FirstOrDefaultAsync(); + + if (account is null) + { + throw new NotFoundException("Requested account not found or reset link expired."); + } + + newPassword = PasswordHelper.GetPasswordHash(accountID, newPassword); + + var filterForUpdate = Builders.Filter.Eq(x => x.ID, accountID); + var update = Builders.Update + .Set(x => x.Password, newPassword) + .Unset(x => x.ResetLinkGUID) + .Unset(x => x.ResetLinkExpiration); + await accountCollection.UpdateOneAsync(filterForUpdate, update); + } + + private async Task SendVerificationLinkAsync(string email, EpicID accountID, string guid) + { + UriBuilder uriBuilder = new() + { + Scheme = applicationSettings.WebsiteScheme, + Host = applicationSettings.WebsiteDomain, + Port = applicationSettings.WebsitePort, + Path = "VerifyEmail", + Query = $"accountId={accountID}&guid={guid}" + }; + + var html = @$" +

Welcome to UT4 Master Server!

+

Click here to verify your email.

+ "; + + if (!ValidationHelper.ValidateEmail(applicationSettings.NoReplyEmail)) + { + _logger.LogWarning("Invalid or missing no-reply email: {Value}.", applicationSettings.NoReplyEmail); + return; + } + + _logger.LogDebug("Sending email verification link: {Uri}", uriBuilder.Uri); + + await emailService.SendHTMLEmailAsync(applicationSettings.NoReplyEmail, new List() { email }, "Email Verification", html); + } + + private async Task SendResetPasswordLinkAsync(string email, EpicID accountID, string guid) + { + UriBuilder uriBuilder = new() + { + Scheme = applicationSettings.WebsiteScheme, + Host = applicationSettings.WebsiteDomain, + Port = applicationSettings.WebsitePort, + Path = "ResetPassword", + Query = $"accountId={accountID}&guid={guid}" + }; + + var html = @$" +

Click here to reset your password for UT4 Master Server account.

+

If you didn't initiate password reset, ignore this message.

+ "; + + if (!ValidationHelper.ValidateEmail(applicationSettings.NoReplyEmail)) + { + _logger.LogWarning("Invalid or missing no-reply email: {Value}.", applicationSettings.NoReplyEmail); + return; + } + + _logger.LogDebug("Sending reset password link: {Uri}", uriBuilder.Uri); + + await emailService.SendHTMLEmailAsync(applicationSettings.NoReplyEmail, new List() { email }, "Reset Password", html); + } +} diff --git a/UT4MasterServer.Services/Scoped/AwsSesClient.cs b/UT4MasterServer.Services/Scoped/AwsSesClient.cs new file mode 100644 index 00000000..b8d1fa8b --- /dev/null +++ b/UT4MasterServer.Services/Scoped/AwsSesClient.cs @@ -0,0 +1,102 @@ +using Amazon.SimpleEmail; +using Amazon.SimpleEmail.Model; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text.Json; +using UT4MasterServer.Common.Exceptions; +using UT4MasterServer.Models.Settings; +using UT4MasterServer.Services.Interfaces; + +namespace UT4MasterServer.Services.Scoped; + +public sealed class AwsSesClient : IEmailService +{ + private readonly ILogger _logger; + private readonly IAmazonSimpleEmailService _amazonSimpleEmailService; + + public AwsSesClient(ILogger logger, IOptions awsSettings) + { + _logger = logger; + _amazonSimpleEmailService = new AmazonSimpleEmailServiceClient( + awsSettings.Value.AccessKey, + awsSettings.Value.SecretKey, + Amazon.RegionEndpoint.GetBySystemName(awsSettings.Value.RegionName)); + } + + public async Task SendTextEmailAsync(string fromAddress, List toAddresses, string subject, string body) + { + var request = new SendEmailRequest() + { + Source = fromAddress, + Destination = new Destination(toAddresses), + Message = new Message() + { + Subject = new Content(subject), + Body = new Body() + { + Text = new Content() + { + Charset = "UTF-8", + Data = body, + } + }, + } + }; + + await SendEmailAsync(request); + } + + public async Task SendHTMLEmailAsync(string fromAddress, List toAddresses, string subject, string body) + { + var request = new SendEmailRequest() + { + Source = fromAddress, + Destination = new Destination(toAddresses), + Message = new Message() + { + Subject = new Content(subject), + Body = new Body() + { + Html = new Content() + { + Charset = "UTF-8", + Data = body, + } + }, + } + }; + + await SendEmailAsync(request); + } + + private async Task SendEmailAsync(SendEmailRequest request) + { + try + { + _logger.LogInformation("Sending email."); + var response = await _amazonSimpleEmailService.SendEmailAsync(request); + + if (response is null) + { + throw new AwsSesClientException("Error occurred while sending email. Response not received."); + } + + _logger.LogInformation("Email sent successfully: {Response}.", JsonSerializer.Serialize(response)); + } + catch (MessageRejectedException ex) + { + _logger.LogError(ex, "Error occurred while sending email: {Request}.", JsonSerializer.Serialize(request)); + throw; + } + catch (MailFromDomainNotVerifiedException ex) + { + _logger.LogError(ex, "Error occurred while sending email: {Request}.", JsonSerializer.Serialize(request)); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while sending email: {Request}.", JsonSerializer.Serialize(request)); + throw; + } + } +} diff --git a/UT4MasterServer.Services/Scoped/CleanupService.cs b/UT4MasterServer.Services/Scoped/CleanupService.cs new file mode 100644 index 00000000..714ce85a --- /dev/null +++ b/UT4MasterServer.Services/Scoped/CleanupService.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Logging; +using UT4MasterServer.Common; +using UT4MasterServer.Services.Singleton; + +namespace UT4MasterServer.Services.Scoped; + +public sealed class CleanupService +{ + private readonly ILogger logger; + private readonly AccountService accountService; + private readonly SessionService sessionService; + private readonly CodeService codeService; + private readonly FriendService friendService; + private readonly CloudStorageService cloudStorageService; + private readonly StatisticsService statisticsService; + private readonly TrustedGameServerService trustedGameServerService; + private readonly RatingsService ratingsService; + + public CleanupService( + ILogger logger, + AccountService accountService, + SessionService sessionService, + CodeService codeService, + FriendService friendService, + CloudStorageService cloudStorageService, + StatisticsService statisticsService, + TrustedGameServerService trustedGameServerService, + RatingsService ratingsService +) + { + this.logger = logger; + this.accountService = accountService; + this.sessionService = sessionService; + this.codeService = codeService; + this.friendService = friendService; + this.cloudStorageService = cloudStorageService; + this.statisticsService = statisticsService; + this.trustedGameServerService = trustedGameServerService; + this.ratingsService = ratingsService; + } + + public async Task RemoveNonVerifiedAccountsAsync() + { + var nonVerifiedAccountIds = await accountService.GetNonVerifiedAccountsAsync(); + await RemoveAccountAndAssociatedDataAsync(nonVerifiedAccountIds); + } + + public async Task RemoveAccountAndAssociatedDataAsync(List accountIDs) + { + foreach (var accountID in accountIDs) + { + await RemoveAccountAndAssociatedDataAsync(accountID); + } + } + + public async Task RemoveAccountAndAssociatedDataAsync(EpicID accountID) + { + logger.LogInformation("Deleting account: {AccountID}.", accountID); + + await accountService.RemoveAccountAsync(accountID); + await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, accountID, EpicID.Empty); + await codeService.RemoveAllByAccountAsync(accountID); + await cloudStorageService.RemoveAllByAccountAsync(accountID); + await statisticsService.RemoveAllByAccountAsync(accountID); + await ratingsService.RemoveAllByAccountAsync(accountID); + await friendService.RemoveAllByAccountAsync(accountID); + await trustedGameServerService.RemoveAllByAccountAsync(accountID); + // NOTE: missing removal of account from live servers. this should take care of itself in a relatively short time. + } +} diff --git a/UT4MasterServer.Services/Singleton/RateLimitService.cs b/UT4MasterServer.Services/Singleton/RateLimitService.cs new file mode 100644 index 00000000..da7eae57 --- /dev/null +++ b/UT4MasterServer.Services/Singleton/RateLimitService.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Caching.Memory; +using UT4MasterServer.Common.Exceptions; + +namespace UT4MasterServer.Services.Singleton; + +public sealed class RateLimitService +{ + private readonly IMemoryCache memoryCache; + + public RateLimitService(IMemoryCache memoryCache) + { + this.memoryCache = memoryCache; + } + + public void CheckRateLimit(string key, int expirationInSeconds = 60) + { + if (memoryCache.TryGetValue(key, out var value)) + { + throw new RateLimitExceededException($"Rate limit exceeded. Wait {value.Subtract(DateTime.UtcNow).Seconds} second(s) and try again."); + } + + var expiration = DateTime.UtcNow.AddSeconds(expirationInSeconds); + memoryCache.Set(key, expiration, TimeSpan.FromSeconds(expirationInSeconds)); + } +} diff --git a/UT4MasterServer.Services/UT4MasterServer.Services.csproj b/UT4MasterServer.Services/UT4MasterServer.Services.csproj index 33e9edf4..e1558d3d 100644 --- a/UT4MasterServer.Services/UT4MasterServer.Services.csproj +++ b/UT4MasterServer.Services/UT4MasterServer.Services.csproj @@ -7,6 +7,8 @@ + + diff --git a/UT4MasterServer.Web/src/enums/role.ts b/UT4MasterServer.Web/src/enums/role.ts index bd7c917b..644893f1 100644 --- a/UT4MasterServer.Web/src/enums/role.ts +++ b/UT4MasterServer.Web/src/enums/role.ts @@ -4,5 +4,6 @@ export enum Role { Moderator = 'Moderator', Developer = 'Developer', ContentCreator = 'ContentCreator', - HubOwner = 'HubOwner' + HubOwner = 'HubOwner', + EmailVerified = 'EmailVerified' } diff --git a/UT4MasterServer.Web/src/pages/ForgotPassword.vue b/UT4MasterServer.Web/src/pages/ForgotPassword.vue new file mode 100644 index 00000000..324ef857 --- /dev/null +++ b/UT4MasterServer.Web/src/pages/ForgotPassword.vue @@ -0,0 +1,77 @@ + + + diff --git a/UT4MasterServer.Web/src/pages/Login.vue b/UT4MasterServer.Web/src/pages/Login.vue index f5fea4a9..0f102ad7 100644 --- a/UT4MasterServer.Web/src/pages/Login.vue +++ b/UT4MasterServer.Web/src/pages/Login.vue @@ -1,8 +1,18 @@ diff --git a/UT4MasterServer.Web/src/pages/Profile/PlayerCard.vue b/UT4MasterServer.Web/src/pages/Profile/PlayerCard.vue index 9c7e96c1..1ea29ef6 100644 --- a/UT4MasterServer.Web/src/pages/Profile/PlayerCard.vue +++ b/UT4MasterServer.Web/src/pages/Profile/PlayerCard.vue @@ -5,6 +5,38 @@