From 9aa5d21c8e1dd095a33cc839afc1d760b545f424 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 16:49:17 +0100 Subject: [PATCH 01/38] Added AWS-related settings to appsettings --- .../Settings/AWSSettings.cs | 8 ++ UT4MasterServer/Program.cs | 1 + UT4MasterServer/appsettings.json | 91 ++++++++++--------- 3 files changed, 56 insertions(+), 44 deletions(-) create mode 100644 UT4MasterServer.Models/Settings/AWSSettings.cs 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/Program.cs b/UT4MasterServer/Program.cs index f22a6a5d..eecfba55 100644 --- a/UT4MasterServer/Program.cs +++ b/UT4MasterServer/Program.cs @@ -62,6 +62,7 @@ public static void Main(string[] args) // load settings objects builder.Services .Configure(builder.Configuration.GetSection("ApplicationSettings")) + .Configure(builder.Configuration.GetSection("AWS")) .Configure(builder.Configuration.GetSection("StatisticsSettings")) .Configure(builder.Configuration.GetSection("ReCaptchaSettings")); diff --git a/UT4MasterServer/appsettings.json b/UT4MasterServer/appsettings.json index e3378176..76a88a71 100644 --- a/UT4MasterServer/appsettings.json +++ b/UT4MasterServer/appsettings.json @@ -1,46 +1,49 @@ { - "ApplicationSettings": { - "DatabaseConnectionString": "mongodb://devroot:devroot@mongo:27017", - "DatabaseName": "ut4master", - "WebsiteDomain": "ut4.timiimit.com", - "AllowPasswordGrantType": true, - "ProxyClientIPHeader": "X-Forwarded-For", - "ProxyServers": [ - "172.20.0.1" - ] - }, - "StatisticsSettings": { - "DeleteOldStatisticsTimeZone": "Central European Standard Time", - "DeleteOldStatisticsHour": 10, - "DeleteOldStatisticsBeforeDays": 90, - "MergeOldStatisticsTimeZone": "Central European Standard Time", - "MergeOldStatisticsHour": 10 - }, - "ReCaptchaSettings": { - "SiteKey": "6LeG-B8kAAAAAOavz8EOqkEP3utz5AmHcpi4sPw1", - "SecretKey": "6LeG-B8kAAAAANkEORdGpmxGIZeBNClr4SiBKfO7" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Serilog": { - "MinimumLevel": { - "Default": "Warning", - "Override": { - "System": "Warning", - "Microsoft.AspNetCore": "Warning" - } - } - }, - "AllowedHosts": "*", - "Kestrel": { - "Endpoints": { - "Http": { - "Url": "http://0.0.0.0:80" - } - } - } + "ApplicationSettings": { + "DatabaseConnectionString": "mongodb://devroot:devroot@mongo:27017", + "DatabaseName": "ut4master", + "WebsiteDomain": "ut4.timiimit.com", + "AllowPasswordGrantType": true, + "ProxyClientIPHeader": "X-Forwarded-For", + "ProxyServers": ["172.20.0.1"] + }, + "AWS": { + "AccessKey": "", + "SecretKey": "", + "RegionName": "eu-central-1" + }, + "StatisticsSettings": { + "DeleteOldStatisticsTimeZone": "Central European Standard Time", + "DeleteOldStatisticsHour": 10, + "DeleteOldStatisticsBeforeDays": 90, + "MergeOldStatisticsTimeZone": "Central European Standard Time", + "MergeOldStatisticsHour": 10 + }, + "ReCaptchaSettings": { + "SiteKey": "6LeG-B8kAAAAAOavz8EOqkEP3utz5AmHcpi4sPw1", + "SecretKey": "6LeG-B8kAAAAANkEORdGpmxGIZeBNClr4SiBKfO7" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Warning", + "Override": { + "System": "Warning", + "Microsoft.AspNetCore": "Warning" + } + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://0.0.0.0:80" + } + } + } } From ecd3ce4ae28f1825c82b0f7995e509314fd1296b Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 16:50:46 +0100 Subject: [PATCH 02/38] Added basic implementation of Amazon SES --- .../Exceptions/AwsSesClientException.cs | 13 +++ .../DTO/Request/SendEmailRequest.cs | 9 ++ .../Scoped/AwsSesClient.cs | 101 ++++++++++++++++++ .../UT4MasterServer.Services.csproj | 1 + UT4MasterServer/Program.cs | 3 +- 5 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 UT4MasterServer.Common/Exceptions/AwsSesClientException.cs create mode 100644 UT4MasterServer.Models/DTO/Request/SendEmailRequest.cs create mode 100644 UT4MasterServer.Services/Scoped/AwsSesClient.cs 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.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.Services/Scoped/AwsSesClient.cs b/UT4MasterServer.Services/Scoped/AwsSesClient.cs new file mode 100644 index 00000000..b8df2d45 --- /dev/null +++ b/UT4MasterServer.Services/Scoped/AwsSesClient.cs @@ -0,0 +1,101 @@ +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; + +namespace UT4MasterServer.Services.Scoped; + +public sealed class AwsSesClient +{ + 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/UT4MasterServer.Services.csproj b/UT4MasterServer.Services/UT4MasterServer.Services.csproj index c7be0877..0e7b071b 100644 --- a/UT4MasterServer.Services/UT4MasterServer.Services.csproj +++ b/UT4MasterServer.Services/UT4MasterServer.Services.csproj @@ -7,6 +7,7 @@ + diff --git a/UT4MasterServer/Program.cs b/UT4MasterServer/Program.cs index eecfba55..deb4cdcf 100644 --- a/UT4MasterServer/Program.cs +++ b/UT4MasterServer/Program.cs @@ -100,7 +100,8 @@ public static void Main(string[] args) .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); // services whose instance is created once and are persistent builder.Services From 659328814e507ead87d07dff2a3b084db9d27a73 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 16:52:41 +0100 Subject: [PATCH 03/38] Exposed sending email in AdminPanelController for testing purposes --- .../Controllers/AdminPanelController.cs | 95 ++++++++++--------- 1 file changed, 52 insertions(+), 43 deletions(-) diff --git a/UT4MasterServer/Controllers/AdminPanelController.cs b/UT4MasterServer/Controllers/AdminPanelController.cs index ac2205f0..a490ab12 100644 --- a/UT4MasterServer/Controllers/AdminPanelController.cs +++ b/UT4MasterServer/Controllers/AdminPanelController.cs @@ -1,18 +1,18 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; using UT4MasterServer.Authentication; +using UT4MasterServer.Common; +using UT4MasterServer.Common.Enums; using UT4MasterServer.Common.Helpers; +using UT4MasterServer.Models; using UT4MasterServer.Models.Database; +using UT4MasterServer.Models.DTO.Request; using UT4MasterServer.Models.DTO.Requests; -using UT4MasterServer.Common; -using UT4MasterServer.Services.Scoped; -using UT4MasterServer.Services.Singleton; using UT4MasterServer.Models.DTO.Responses; -using UT4MasterServer.Models; using UT4MasterServer.Models.Responses; -using Microsoft.Net.Http.Headers; -using UT4MasterServer.Common.Enums; -using System.Linq; - +using UT4MasterServer.Services.Scoped; +using UT4MasterServer.Services.Singleton; + namespace UT4MasterServer.Controllers; [ApiController] @@ -30,9 +30,9 @@ public sealed class AdminPanelController : ControllerBase private readonly ClientService clientService; private readonly TrustedGameServerService trustedGameServerService; private readonly RatingsService ratingsService; - private readonly MatchmakingService matchmakingService; - + private readonly AwsSesClient awsSesClient; + public AdminPanelController( ILogger logger, AccountService accountService, @@ -44,7 +44,8 @@ public AdminPanelController( ClientService clientService, TrustedGameServerService trustedGameServerService, RatingsService ratingsService, - MatchmakingService matchmakingService) + MatchmakingService matchmakingService, + AwsSesClient awsSesClient) { this.logger = logger; this.accountService = accountService; @@ -56,11 +57,12 @@ public AdminPanelController( this.clientService = clientService; this.trustedGameServerService = trustedGameServerService; this.ratingsService = ratingsService; - this.matchmakingService = matchmakingService; - } - + this.matchmakingService = matchmakingService; + this.awsSesClient = awsSesClient; + } + #region Accounts - + [HttpGet("flags")] public async Task GetAllPossibleFlags() { @@ -106,14 +108,14 @@ public async Task SetAccountFlags(string accountID, [FromBody] st if (adminFlags.HasFlag(AccountFlags.Admin)) { if (flagsRemoved.HasFlag(AccountFlags.Admin)) - { - // TODO: there should be something like voting in order to pass this decision + { + // TODO: there should be something like voting in order to pass this decision return Unauthorized($"Cannot remove {nameof(AccountFlags.Admin)} flag, this action must be performed with direct access to database"); } if (flagsAdded.HasFlag(AccountFlags.Admin)) - { - // TODO: there should be something like voting in order to pass this decision + { + // TODO: there should be something like voting in order to pass this decision } } else if (adminFlags.HasFlag(AccountFlags.ACL_AccountsHigh)) @@ -138,7 +140,7 @@ public async Task SetAccountFlags(string accountID, [FromBody] st return Unauthorized($"Only {nameof(AccountFlags.Admin)} may remove ACL flags"); } } - else // if (adminFlags.HasFlag(AccountFlags.ACL_AccountsLow)) + else // if (adminFlags.HasFlag(AccountFlags.ACL_AccountsLow)) { if (AccountFlagsHelper.IsACLFlag(flagsAdded)) { @@ -198,7 +200,7 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel return Unauthorized($"Cannot change password of another {nameof(AccountFlags.Admin)}"); } } - else // if (admin.Account.Flags.HasFlag(AccountFlags.ACL_AccountsHigh) || adminFlags.HasFlag(AccountFlags.ACL_AccountsLow)) + else // if (admin.Account.Flags.HasFlag(AccountFlags.ACL_AccountsHigh) || adminFlags.HasFlag(AccountFlags.ACL_AccountsLow)) { if (flags.HasFlag(AccountFlags.Admin)) { @@ -209,9 +211,9 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel { return Unauthorized($"Cannot change password of an account with ACL flag"); } - } - - // passwords should already be hashed, but check its length just in case + } + + // passwords should already be hashed, but check its length just in case if (!ValidationHelper.ValidatePassword(body.NewPassword)) { return BadRequest($"newPassword is not a SHA512 hash"); @@ -227,10 +229,10 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel return BadRequest($"'iAmSure' was not 'true'"); } - await accountService.UpdateAccountPasswordAsync(account.ID, body.NewPassword); - - // logout user to make sure they remember the changed password by being forced to log in again, - // as well as prevent anyone else from using this account after successful password change. + await accountService.UpdateAccountPasswordAsync(account.ID, body.NewPassword); + + // logout user to make sure they remember the changed password by being forced to log in again, + // as well as prevent anyone else from using this account after successful password change. await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, account.ID, EpicID.Empty); return Ok(); @@ -318,12 +320,12 @@ public async Task DeleteAccountInfo(string id, [FromBody] bool? f account ); } - } - + } + #endregion - + #region Clients - + [HttpPost("clients/new")] public async Task CreateClient([FromBody] string name) { @@ -398,18 +400,18 @@ public async Task DeleteClient(string id) if (success != true) { return BadRequest(); - } - - // in case this client is a trusted server remove, it as well + } + + // in case this client is a trusted server remove, it as well await trustedGameServerService.RemoveAsync(eid); return Ok(); - } - + } + #endregion - + #region Trusted Servers - + [HttpGet("trusted_servers")] public async Task GetAllTrustedServers() { @@ -507,12 +509,12 @@ public async Task DeleteTrustedServer(string id) await matchmakingService.UpdateTrustLevelAsync(eid, GameServerTrust.Untrusted); return Ok(ret); - } - + } + #endregion - + #region Cloud Storage - + [HttpGet("mcp_files")] public async Task GetMCPFiles() { @@ -585,6 +587,13 @@ public async Task DeleteMCPFile(string filename) #endregion + [HttpPost("send-email")] + public async Task SendEmail([FromBody] SendEmailRequest sendEmailRequest) + { + await awsSesClient.SendTextEmailAsync(sendEmailRequest.From, sendEmailRequest.To, sendEmailRequest.Subject, sendEmailRequest.Body); + return Ok(); + } + [NonAction] private async Task<(Session Session, Account Account)> VerifyAccessAsync(params AccountFlags[] aclAny) { From d7edf2216b05d7000332bedd340d4546072a4e56 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 17:46:36 +0100 Subject: [PATCH 04/38] Refactored ApplicationStartupService --- .../Hosted/ApplicationStartupService.cs | 78 +++++++++---------- 1 file changed, 35 insertions(+), 43 deletions(-) diff --git a/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs b/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs index 50d04e13..0daf0536 100644 --- a/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs +++ b/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs @@ -1,50 +1,42 @@ -using Microsoft.Extensions.Hosting; +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 +namespace UT4MasterServer.Services.Hosted; + +public sealed class ApplicationStartupService : IHostedService { - public sealed class ApplicationStartupService : IHostedService + private readonly ILogger logger; + private readonly IServiceProvider serviceProvider; + + public ApplicationStartupService(ILogger logger, IServiceProvider serviceProvider) + { + this.logger = logger; + this.serviceProvider = serviceProvider; + } + + public async Task StartAsync(CancellationToken cancellationToken) { - 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) - { - 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); - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - logger.LogInformation("Configuring MongoDB indexes."); - await accountService.CreateIndexesAsync(); - await statisticsService.CreateIndexesAsync(); - await ratingsService.CreateIndexesAsync(); - - logger.LogInformation("Initializing MongoDB CloudStorage."); - await cloudStorageService.EnsureSystemFilesExistAsync(); - - logger.LogInformation("Initializing MongoDB Clients."); - await clientService.UpdateDefaultClientsAsync(); - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + 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(); + await ratingsService.CreateIndexesAsync(); + + logger.LogInformation("Initializing MongoDB CloudStorage."); + await cloudStorageService.EnsureSystemFilesExistAsync(); + + logger.LogInformation("Initializing MongoDB Clients."); + await clientService.UpdateDefaultClientsAsync(); } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } From 2dd3d81619e644cfb8bd5d38caa5f10e7605aeed Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 17:47:30 +0100 Subject: [PATCH 05/38] Exposed endpoint for sending email as HTML in AdminPanelController --- UT4MasterServer/Controllers/AdminPanelController.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/UT4MasterServer/Controllers/AdminPanelController.cs b/UT4MasterServer/Controllers/AdminPanelController.cs index a490ab12..e01f3437 100644 --- a/UT4MasterServer/Controllers/AdminPanelController.cs +++ b/UT4MasterServer/Controllers/AdminPanelController.cs @@ -587,11 +587,18 @@ public async Task DeleteMCPFile(string filename) #endregion - [HttpPost("send-email")] - public async Task SendEmail([FromBody] SendEmailRequest sendEmailRequest) + [HttpPost("send-text-email")] + public async Task SendTextEmail([FromBody] SendEmailRequest sendEmailRequest) { await awsSesClient.SendTextEmailAsync(sendEmailRequest.From, sendEmailRequest.To, sendEmailRequest.Subject, sendEmailRequest.Body); return Ok(); + } + + [HttpPost("send-html-email")] + public async Task SendHtmlEmail([FromBody] SendEmailRequest sendEmailRequest) + { + await awsSesClient.SendHTMLEmailAsync(sendEmailRequest.From, sendEmailRequest.To, sendEmailRequest.Subject, sendEmailRequest.Body); + return Ok(); } [NonAction] From 7712703beb10576f8f1c146de6c4d715f2b75557 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 18:14:10 +0100 Subject: [PATCH 06/38] Added status and activation GUID to Account model --- UT4MasterServer.Common/Enums/AccountStatus.cs | 7 +++++++ UT4MasterServer.Models/Database/Account.cs | 6 ++++++ 2 files changed, 13 insertions(+) create mode 100644 UT4MasterServer.Common/Enums/AccountStatus.cs diff --git a/UT4MasterServer.Common/Enums/AccountStatus.cs b/UT4MasterServer.Common/Enums/AccountStatus.cs new file mode 100644 index 00000000..a62fc90f --- /dev/null +++ b/UT4MasterServer.Common/Enums/AccountStatus.cs @@ -0,0 +1,7 @@ +namespace UT4MasterServer.Common.Enums; + +public enum AccountStatus +{ + PendingActivation = 0, + Active = 1, +} diff --git a/UT4MasterServer.Models/Database/Account.cs b/UT4MasterServer.Models/Database/Account.cs index eaaaa56f..b4e610cd 100644 --- a/UT4MasterServer.Models/Database/Account.cs +++ b/UT4MasterServer.Models/Database/Account.cs @@ -69,6 +69,12 @@ public class Account [BsonElement("Flags")] public AccountFlags Flags { get; set; } = 0; + [BsonIgnoreIfNull] + public string? ActivationGuid { get; set; } + + [BsonIgnoreIfDefault] + public AccountStatus Status { get; set; } = AccountStatus.PendingActivation; + [BsonIgnore] public float Level { From 4c0e67f6c8f278c7bb128fb43cd6c61cd2b613b1 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 18:15:06 +0100 Subject: [PATCH 07/38] Added NoReplyEmail to appsettings --- UT4MasterServer.Models/Settings/ApplicationSettings.cs | 5 +++++ UT4MasterServer/appsettings.json | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/UT4MasterServer.Models/Settings/ApplicationSettings.cs b/UT4MasterServer.Models/Settings/ApplicationSettings.cs index d28b7143..8aaea1bd 100644 --- a/UT4MasterServer.Models/Settings/ApplicationSettings.cs +++ b/UT4MasterServer.Models/Settings/ApplicationSettings.cs @@ -30,4 +30,9 @@ 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 + /// + public string NoReplyEmail { get; set; } = string.Empty; } diff --git a/UT4MasterServer/appsettings.json b/UT4MasterServer/appsettings.json index 76a88a71..ba377f76 100644 --- a/UT4MasterServer/appsettings.json +++ b/UT4MasterServer/appsettings.json @@ -5,7 +5,10 @@ "WebsiteDomain": "ut4.timiimit.com", "AllowPasswordGrantType": true, "ProxyClientIPHeader": "X-Forwarded-For", - "ProxyServers": ["172.20.0.1"] + "ProxyServers": [ + "172.20.0.1" + ], + "NoReplyEmail": "noreply@ut4.timiimit.com" }, "AWS": { "AccessKey": "", From 9f1c404845e75776d5d0c4448515afa88515361c Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 18:16:09 +0100 Subject: [PATCH 08/38] Formatted appsettings --- UT4MasterServer/appsettings.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/UT4MasterServer/appsettings.json b/UT4MasterServer/appsettings.json index ba377f76..aa05e9c2 100644 --- a/UT4MasterServer/appsettings.json +++ b/UT4MasterServer/appsettings.json @@ -5,9 +5,7 @@ "WebsiteDomain": "ut4.timiimit.com", "AllowPasswordGrantType": true, "ProxyClientIPHeader": "X-Forwarded-For", - "ProxyServers": [ - "172.20.0.1" - ], + "ProxyServers": [ "172.20.0.1" ], "NoReplyEmail": "noreply@ut4.timiimit.com" }, "AWS": { From 41891d9ba7edebc16d3ae7b910afac1b0643a934 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 18:16:48 +0100 Subject: [PATCH 09/38] Sending activation link on account creation --- .../Scoped/AccountService.cs | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/UT4MasterServer.Services/Scoped/AccountService.cs b/UT4MasterServer.Services/Scoped/AccountService.cs index a2cb78b7..190b9b8d 100644 --- a/UT4MasterServer.Services/Scoped/AccountService.cs +++ b/UT4MasterServer.Services/Scoped/AccountService.cs @@ -12,10 +12,17 @@ namespace UT4MasterServer.Services.Scoped; public sealed class AccountService { private readonly IMongoCollection accountCollection; + private readonly ApplicationSettings applicationSettings; + private readonly AwsSesClient awsSesClient; - public AccountService(DatabaseContext dbContext, IOptions settings) + public AccountService( + DatabaseContext dbContext, + IOptions applicationSettings, + AwsSesClient awsSesClient) { + this.applicationSettings = applicationSettings.Value; accountCollection = dbContext.Database.GetCollection("accounts"); + this.awsSesClient = awsSesClient; } public async Task CreateIndexesAsync() @@ -35,11 +42,15 @@ public async Task CreateAccountAsync(string username, string email, string passw { ID = EpicID.GenerateNew(), Username = username, - Email = email + Email = email, + ActivationGuid = Guid.NewGuid().ToString(), + Status = AccountStatus.PendingActivation, }; newAccount.Password = PasswordHelper.GetPasswordHash(newAccount.ID, password); await accountCollection.InsertOneAsync(newAccount); + + await SendActivationLinkAsync(email, newAccount.ActivationGuid); } public async Task GetAccountByEmailAsync(string email) @@ -172,5 +183,22 @@ public async Task RemoveAccountAsync(EpicID id) { await accountCollection.DeleteOneAsync(user => user.ID == id); } -} + private async Task SendActivationLinkAsync(string email, string guid) + { + UriBuilder uriBuilder = new() + { + Scheme = "https", + Host = applicationSettings.WebsiteDomain, + Path = "account/api/activate", + Query = $"email={email}&guid={guid}" + }; + + var html = @$" +

Welcome to UT4 Master Server!

+

Click here to activate your UT4 Master Server account.

+ "; + + await awsSesClient.SendHTMLEmailAsync(applicationSettings.NoReplyEmail, new List() { email }, "Account Activation", html); + } +} From 061f8dd2bcd65fef73eb49e95f641635ae6df78e Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 20:29:04 +0100 Subject: [PATCH 10/38] Added WebsiteDomain to development appsettings --- UT4MasterServer/appsettings.Development.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/UT4MasterServer/appsettings.Development.json b/UT4MasterServer/appsettings.Development.json index 39e99c33..8a12b92c 100644 --- a/UT4MasterServer/appsettings.Development.json +++ b/UT4MasterServer/appsettings.Development.json @@ -1,4 +1,7 @@ { + "ApplicationSettings": { + "WebsiteDomain": "localhost" + }, "Logging": { "LogLevel": { "Default": "Trace", From 3101779696d476c31ed70bee0e65ab04b59d89b9 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 20:29:18 +0100 Subject: [PATCH 11/38] Saving account status always --- UT4MasterServer.Models/Database/Account.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/UT4MasterServer.Models/Database/Account.cs b/UT4MasterServer.Models/Database/Account.cs index b4e610cd..ac4d5591 100644 --- a/UT4MasterServer.Models/Database/Account.cs +++ b/UT4MasterServer.Models/Database/Account.cs @@ -72,7 +72,6 @@ public class Account [BsonIgnoreIfNull] public string? ActivationGuid { get; set; } - [BsonIgnoreIfDefault] public AccountStatus Status { get; set; } = AccountStatus.PendingActivation; [BsonIgnore] From cc61671dd0ff89ac8fb460b3ef2015f7c0be4f65 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 20:30:05 +0100 Subject: [PATCH 12/38] Added endpoint and method for activating account --- .../Scoped/AccountService.cs | 20 +++++++++++++++++++ .../Controllers/Epic/AccountController.cs | 12 +++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/UT4MasterServer.Services/Scoped/AccountService.cs b/UT4MasterServer.Services/Scoped/AccountService.cs index 190b9b8d..4c11f480 100644 --- a/UT4MasterServer.Services/Scoped/AccountService.cs +++ b/UT4MasterServer.Services/Scoped/AccountService.cs @@ -184,6 +184,26 @@ public async Task RemoveAccountAsync(EpicID id) await accountCollection.DeleteOneAsync(user => user.ID == id); } + public async Task ActivateAccountAsync(string email, string guid) + { + var filter = Builders.Filter.Eq(f => f.Email, email) & + Builders.Filter.Eq(f => f.ActivationGuid, guid) & + Builders.Filter.Eq(f => f.Status, AccountStatus.PendingActivation); + var account = await accountCollection.Find(filter).FirstOrDefaultAsync(); + + if (account is not null) + { + var updateDefinition = Builders.Update + .Set(s => s.Status, AccountStatus.Active) + .Unset(u => u.ActivationGuid); + await accountCollection.UpdateOneAsync(filter, updateDefinition); + + return true; + } + + return false; + } + private async Task SendActivationLinkAsync(string email, string guid) { UriBuilder uriBuilder = new() diff --git a/UT4MasterServer/Controllers/Epic/AccountController.cs b/UT4MasterServer/Controllers/Epic/AccountController.cs index be6ca35f..fa2d3461 100644 --- a/UT4MasterServer/Controllers/Epic/AccountController.cs +++ b/UT4MasterServer/Controllers/Epic/AccountController.cs @@ -359,7 +359,15 @@ public async Task UpdatePassword([FromForm] string currentPasswor logger.LogInformation($"Updated password for {user.Session.AccountID}"); return Ok("Changed password successfully"); - } + } - #endregion + [AllowAnonymous] + [HttpGet("activate")] + public async Task ActivateAccount([FromQuery] string email, [FromQuery] string guid) + { + var activated = await accountService.ActivateAccountAsync(email, guid); + return Ok(activated); + } + + #endregion } From 7b983c9ddbce621bc001c9e16fc25278ea1229b1 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 20:53:36 +0100 Subject: [PATCH 13/38] Added getRouteParamBooleanValue to utilities --- UT4MasterServer.Web/src/utils/utilities.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/UT4MasterServer.Web/src/utils/utilities.ts b/UT4MasterServer.Web/src/utils/utilities.ts index 5a414d76..403d42a5 100644 --- a/UT4MasterServer.Web/src/utils/utilities.ts +++ b/UT4MasterServer.Web/src/utils/utilities.ts @@ -70,3 +70,12 @@ export function getRouteParamNumberValue( const paramString = params[key]; return paramString?.length ? +paramString : defaultValue; } + +export function getRouteParamBooleanValue( + params: RouteParams, + key: string, + defaultValue: boolean +) { + const paramString = params[key]; + return paramString?.length ? paramString === 'true' : defaultValue; +} From 74bb5dc079d651463e522495c77988c11bf5506d Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 20:54:22 +0100 Subject: [PATCH 14/38] Showing alert for activation link after registration --- UT4MasterServer.Web/src/pages/Login.vue | 13 +++++++++++++ UT4MasterServer.Web/src/pages/Register.vue | 5 ++++- UT4MasterServer.Web/src/routes.ts | 3 ++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/UT4MasterServer.Web/src/pages/Login.vue b/UT4MasterServer.Web/src/pages/Login.vue index f5fea4a9..f2dcc967 100644 --- a/UT4MasterServer.Web/src/pages/Login.vue +++ b/UT4MasterServer.Web/src/pages/Login.vue @@ -3,6 +3,12 @@
Log In +
+
Activation link sent to email.
+
import('./pages/Login.vue'), beforeEnter: publicGuard }, From a3ad9feeaa4d731e4f22ce20e823b690c958f881 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 21:33:17 +0100 Subject: [PATCH 15/38] Changed path for activation link --- UT4MasterServer.Services/Scoped/AccountService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UT4MasterServer.Services/Scoped/AccountService.cs b/UT4MasterServer.Services/Scoped/AccountService.cs index 4c11f480..b2e2c598 100644 --- a/UT4MasterServer.Services/Scoped/AccountService.cs +++ b/UT4MasterServer.Services/Scoped/AccountService.cs @@ -210,7 +210,7 @@ private async Task SendActivationLinkAsync(string email, string guid) { Scheme = "https", Host = applicationSettings.WebsiteDomain, - Path = "account/api/activate", + Path = "Activation", Query = $"email={email}&guid={guid}" }; From aa3359698e6562b666b4bcd0ba0f3fd58862b86d Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Thu, 16 Feb 2023 21:33:37 +0100 Subject: [PATCH 16/38] Added activation route and component on the frontent --- UT4MasterServer.Web/src/pages/Activation.vue | 56 +++++++++++++++++++ UT4MasterServer.Web/src/routes.ts | 5 ++ .../src/services/account.service.ts | 6 ++ 3 files changed, 67 insertions(+) create mode 100644 UT4MasterServer.Web/src/pages/Activation.vue diff --git a/UT4MasterServer.Web/src/pages/Activation.vue b/UT4MasterServer.Web/src/pages/Activation.vue new file mode 100644 index 00000000..b55e0d86 --- /dev/null +++ b/UT4MasterServer.Web/src/pages/Activation.vue @@ -0,0 +1,56 @@ + + + diff --git a/UT4MasterServer.Web/src/routes.ts b/UT4MasterServer.Web/src/routes.ts index e7e6ab5a..457e626d 100644 --- a/UT4MasterServer.Web/src/routes.ts +++ b/UT4MasterServer.Web/src/routes.ts @@ -98,6 +98,11 @@ export const routes: RouteRecordRaw[] = [ component: async () => import('./pages/Register.vue'), beforeEnter: publicGuard }, + { + path: `/Activation`, + component: async () => import('./pages/Activation.vue'), + beforeEnter: publicGuard + }, { name: 'Login', path: `/Login/:activationLinkSent?`, diff --git a/UT4MasterServer.Web/src/services/account.service.ts b/UT4MasterServer.Web/src/services/account.service.ts index 98281f10..d4f1bd41 100644 --- a/UT4MasterServer.Web/src/services/account.service.ts +++ b/UT4MasterServer.Web/src/services/account.service.ts @@ -66,4 +66,10 @@ export default class AccountService extends HttpService { false ); } + + async activateAccount(email: string, guid: string) { + return await this.get( + `${this.baseUrl}/activate?email=${email}&guid=${guid}` + ); + } } From 8fd986d1688c649ef770781547bbbaf99bba0464 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Sat, 18 Feb 2023 11:51:26 +0100 Subject: [PATCH 17/38] Added fields needed for reset password to account --- UT4MasterServer.Models/Database/Account.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/UT4MasterServer.Models/Database/Account.cs b/UT4MasterServer.Models/Database/Account.cs index ac4d5591..be4d17c1 100644 --- a/UT4MasterServer.Models/Database/Account.cs +++ b/UT4MasterServer.Models/Database/Account.cs @@ -72,6 +72,12 @@ public class Account [BsonIgnoreIfNull] public string? ActivationGuid { get; set; } + [BsonIgnoreIfNull] + public string? ResetLinkGUID { get; set; } + + [BsonIgnoreIfNull] + public DateTime? ResetLinkExpiration { get; set; } + public AccountStatus Status { get; set; } = AccountStatus.PendingActivation; [BsonIgnore] From 494d76c1f541d0e1ff556682b7f1e4533f77e3fd Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Sat, 18 Feb 2023 11:53:35 +0100 Subject: [PATCH 18/38] Added RateLimitService --- .../Exceptions/RateLimitExceededException.cs | 13 ++++++++++ .../Singleton/RateLimitService.cs | 25 +++++++++++++++++++ .../UT4MasterServer.Services.csproj | 1 + UT4MasterServer/Program.cs | 6 ++++- 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 UT4MasterServer.Common/Exceptions/RateLimitExceededException.cs create mode 100644 UT4MasterServer.Services/Singleton/RateLimitService.cs 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.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 0e7b071b..77487d08 100644 --- a/UT4MasterServer.Services/UT4MasterServer.Services.csproj +++ b/UT4MasterServer.Services/UT4MasterServer.Services.csproj @@ -8,6 +8,7 @@ + diff --git a/UT4MasterServer/Program.cs b/UT4MasterServer/Program.cs index deb4cdcf..430d44d3 100644 --- a/UT4MasterServer/Program.cs +++ b/UT4MasterServer/Program.cs @@ -89,6 +89,9 @@ public static void Main(string[] args) } }); + // Microsoft services + builder.Services.AddMemoryCache(); + // services whose instance is created per-request builder.Services .AddScoped() @@ -107,7 +110,8 @@ public static void Main(string[] args) builder.Services .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); // hosted services builder.Services From b42b55fca1fdf0c662fa723846d9f90ac1ca5c8d Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Sat, 18 Feb 2023 11:54:47 +0100 Subject: [PATCH 19/38] Added initiate reset password methods to backend --- .../Exceptions/NotFoundException.cs | 13 + .../Scoped/AccountService.cs | 40 + .../Controllers/Epic/AccountController.cs | 692 +++++++++--------- 3 files changed, 413 insertions(+), 332 deletions(-) create mode 100644 UT4MasterServer.Common/Exceptions/NotFoundException.cs 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.Services/Scoped/AccountService.cs b/UT4MasterServer.Services/Scoped/AccountService.cs index b2e2c598..5428440c 100644 --- a/UT4MasterServer.Services/Scoped/AccountService.cs +++ b/UT4MasterServer.Services/Scoped/AccountService.cs @@ -2,6 +2,7 @@ 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; @@ -204,6 +205,27 @@ public async Task ActivateAccountAsync(string email, string guid) return false; } + public async Task InitiateResetPasswordAsync(string email) + { + var filter = Builders.Filter.Eq(f => f.Email, email) & + Builders.Filter.Eq(f => f.Status, AccountStatus.Active); + var account = await accountCollection.Find(filter).FirstOrDefaultAsync(); + + if (account is null) + { + throw new NotFoundException("Email not found or account in wrong status."); + } + + 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, guid); + } + private async Task SendActivationLinkAsync(string email, string guid) { UriBuilder uriBuilder = new() @@ -221,4 +243,22 @@ private async Task SendActivationLinkAsync(string email, string guid) await awsSesClient.SendHTMLEmailAsync(applicationSettings.NoReplyEmail, new List() { email }, "Account Activation", html); } + + private async Task SendResetPasswordLinkAsync(string email, string guid) + { + UriBuilder uriBuilder = new() + { + Scheme = "https", + Host = applicationSettings.WebsiteDomain, + Path = "ResetPassword", + Query = $"email={email}&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.

+ "; + + await awsSesClient.SendHTMLEmailAsync(applicationSettings.NoReplyEmail, new List() { email }, "Reset Password", html); + } } diff --git a/UT4MasterServer/Controllers/Epic/AccountController.cs b/UT4MasterServer/Controllers/Epic/AccountController.cs index fa2d3461..08cafefa 100644 --- a/UT4MasterServer/Controllers/Epic/AccountController.cs +++ b/UT4MasterServer/Controllers/Epic/AccountController.cs @@ -3,12 +3,13 @@ using Microsoft.Extensions.Options; using Newtonsoft.Json.Linq; using UT4MasterServer.Authentication; -using UT4MasterServer.Common.Helpers; using UT4MasterServer.Common; +using UT4MasterServer.Common.Helpers; using UT4MasterServer.Models; using UT4MasterServer.Models.DTO.Responses; using UT4MasterServer.Models.Settings; using UT4MasterServer.Services.Scoped; +using UT4MasterServer.Services.Singleton; namespace UT4MasterServer.Controllers.Epic; @@ -21,140 +22,150 @@ namespace UT4MasterServer.Controllers.Epic; [Produces("application/json")] public sealed class AccountController : JsonAPIController { - private readonly SessionService sessionService; - private readonly AccountService accountService; - private readonly IOptions reCaptchaSettings; - - public AccountController(ILogger logger, AccountService accountService, SessionService sessionService, IOptions reCaptchaSettings) : base(logger) - { - this.accountService = accountService; - this.sessionService = sessionService; - this.reCaptchaSettings = reCaptchaSettings; - } - - #region ACCOUNT LISTING API - - [HttpGet("public/account/{id}")] - public async Task GetAccount(string id) - { - if (User.Identity is not EpicUserIdentity authenticatedUser) - return Unauthorized(); - - // TODO: EPIC doesn't throw here if id is invalid (like 'abc'). Return this same ErrorResponse like for account_not_found - EpicID eid = EpicID.FromString(id); - - if (eid != authenticatedUser.Session.AccountID) - return Unauthorized(); - - logger.LogInformation($"{authenticatedUser.Session.AccountID} is looking for account {id}"); - - var account = await accountService.GetAccountAsync(eid); - if (account == null) - return NotFound(new ErrorResponse - { - ErrorCode = "errors.com.epicgames.account.account_not_found", - ErrorMessage = $"Sorry, we couldn't find an account for {id}", - MessageVars = new[] { id }, - NumericErrorCode = 18007, - OriginatingService = "com.epicgames.account.public", - Intent = "prod", - }); - - var obj = new JObject(); - obj.Add("id", account.ID.ToString()); - obj.Add("displayName", account.Username); - obj.Add("name", $"{account.Username}"); // fake a random one - obj.Add("email", account.Email);//$"{account.ID}@{Request.Host}"); // fake a random one - obj.Add("failedLoginAttempts", 0); - obj.Add("lastLogin", account.LastLoginAt.ToStringISO()); - obj.Add("numberOfDisplayNameChanges", 0); - obj.Add("ageGroup", "UNKNOWN"); - obj.Add("headless", false); - obj.Add("country", "US"); // two letter country code - obj.Add("lastName", $"{account.Username}"); // fake a random one - obj.Add("preferredLanguage", "en"); // two letter language code - obj.Add("canUpdateDisplayName", true); - obj.Add("tfaEnabled", true); - obj.Add("emailVerified", false);//true); - obj.Add("minorVerified", false); - obj.Add("minorExpected", false); - obj.Add("minorStatus", "UNKNOWN"); - obj.Add("cabinedMode", false); - obj.Add("hasHashedEmail", false); - - return Json(obj.ToString(Newtonsoft.Json.Formatting.None)); - } - - [HttpGet("public/account")] - public async Task GetAccounts([FromQuery(Name = "accountId")] List accountIDs) - { - if (User.Identity is not EpicUserIdentity authenticatedUser) - return Unauthorized(); - - if (accountIDs.Count == 0 || accountIDs.Count > 100) - { - return NotFound(new ErrorResponse - { - ErrorCode = "errors.com.epicgames.account.invalid_account_id_count", - ErrorMessage = "Sorry, the number of account id should be at least one and not more than 100.", - MessageVars = new[] { "100" }, - NumericErrorCode = 18066, - OriginatingService = "com.epicgames.account.public", - Intent = "prod", - }); - } - - var ids = accountIDs.Distinct().Select(x => EpicID.FromString(x)); - var accounts = await accountService.GetAccountsAsync(ids.ToList()); - - var retrievedAccountIDs = accounts.Select(x => x.ID.ToString()); - logger.LogInformation($"{authenticatedUser.Session.AccountID} is looking for {string.Join(", ", retrievedAccountIDs)}"); - - // create json response - var arr = new JArray(); - foreach (var account in accounts) - { - var obj = new JObject(); - obj.Add("id", account.ID.ToString()); - obj.Add("displayName", account.Username); - if (account.ID == authenticatedUser.Session.AccountID) - { - // this is returned only when you ask about yourself - obj.Add("minorVerified", false); - obj.Add("minorStatus", "UNKNOWN"); - obj.Add("cabinedMode", false); - } - - obj.Add("externalAuths", new JObject()); - arr.Add(obj); - } - - return Json(arr); - } - - #endregion - - #region UNIMPORTANT API - - [HttpGet("accounts/{id}/metadata")] - public IActionResult GetMetadata(string id) - { - EpicID eid = EpicID.FromString(id); - - logger.LogInformation($"Get metadata of {eid}"); - - // unknown structure, but epic always seems to respond with this - return Json("{}"); - } - - [HttpGet("public/account/{id}/externalAuths")] - public IActionResult GetExternalAuths(string id) - { - EpicID eid = EpicID.FromString(id); - - logger.LogInformation($"Get external auths of {eid}"); - // we don't really care about these, but structure for my github externalAuth is the following: - /* + private readonly SessionService sessionService; + private readonly AccountService accountService; + private readonly RateLimitService rateLimitService; + private readonly IOptions? applicationSettings; + private readonly IOptions reCaptchaSettings; + + public AccountController( + ILogger logger, + AccountService accountService, + SessionService sessionService, + RateLimitService rateLimitService, + IOptions applicationSettings, + IOptions reCaptchaSettings) : base(logger) + { + this.accountService = accountService; + this.sessionService = sessionService; + this.rateLimitService = rateLimitService; + this.applicationSettings = applicationSettings; + this.reCaptchaSettings = reCaptchaSettings; + } + + #region ACCOUNT LISTING API + + [HttpGet("public/account/{id}")] + public async Task GetAccount(string id) + { + if (User.Identity is not EpicUserIdentity authenticatedUser) + return Unauthorized(); + + // TODO: EPIC doesn't throw here if id is invalid (like 'abc'). Return this same ErrorResponse like for account_not_found + EpicID eid = EpicID.FromString(id); + + if (eid != authenticatedUser.Session.AccountID) + return Unauthorized(); + + logger.LogInformation($"{authenticatedUser.Session.AccountID} is looking for account {id}"); + + var account = await accountService.GetAccountAsync(eid); + if (account == null) + return NotFound(new ErrorResponse + { + ErrorCode = "errors.com.epicgames.account.account_not_found", + ErrorMessage = $"Sorry, we couldn't find an account for {id}", + MessageVars = new[] { id }, + NumericErrorCode = 18007, + OriginatingService = "com.epicgames.account.public", + Intent = "prod", + }); + + var obj = new JObject(); + obj.Add("id", account.ID.ToString()); + obj.Add("displayName", account.Username); + obj.Add("name", $"{account.Username}"); // fake a random one + obj.Add("email", account.Email);//$"{account.ID}@{Request.Host}"); // fake a random one + obj.Add("failedLoginAttempts", 0); + obj.Add("lastLogin", account.LastLoginAt.ToStringISO()); + obj.Add("numberOfDisplayNameChanges", 0); + obj.Add("ageGroup", "UNKNOWN"); + obj.Add("headless", false); + obj.Add("country", "US"); // two letter country code + obj.Add("lastName", $"{account.Username}"); // fake a random one + obj.Add("preferredLanguage", "en"); // two letter language code + obj.Add("canUpdateDisplayName", true); + obj.Add("tfaEnabled", true); + obj.Add("emailVerified", false);//true); + obj.Add("minorVerified", false); + obj.Add("minorExpected", false); + obj.Add("minorStatus", "UNKNOWN"); + obj.Add("cabinedMode", false); + obj.Add("hasHashedEmail", false); + + return Json(obj.ToString(Newtonsoft.Json.Formatting.None)); + } + + [HttpGet("public/account")] + public async Task GetAccounts([FromQuery(Name = "accountId")] List accountIDs) + { + if (User.Identity is not EpicUserIdentity authenticatedUser) + return Unauthorized(); + + if (accountIDs.Count == 0 || accountIDs.Count > 100) + { + return NotFound(new ErrorResponse + { + ErrorCode = "errors.com.epicgames.account.invalid_account_id_count", + ErrorMessage = "Sorry, the number of account id should be at least one and not more than 100.", + MessageVars = new[] { "100" }, + NumericErrorCode = 18066, + OriginatingService = "com.epicgames.account.public", + Intent = "prod", + }); + } + + var ids = accountIDs.Distinct().Select(x => EpicID.FromString(x)); + var accounts = await accountService.GetAccountsAsync(ids.ToList()); + + var retrievedAccountIDs = accounts.Select(x => x.ID.ToString()); + logger.LogInformation($"{authenticatedUser.Session.AccountID} is looking for {string.Join(", ", retrievedAccountIDs)}"); + + // create json response + var arr = new JArray(); + foreach (var account in accounts) + { + var obj = new JObject(); + obj.Add("id", account.ID.ToString()); + obj.Add("displayName", account.Username); + if (account.ID == authenticatedUser.Session.AccountID) + { + // this is returned only when you ask about yourself + obj.Add("minorVerified", false); + obj.Add("minorStatus", "UNKNOWN"); + obj.Add("cabinedMode", false); + } + + obj.Add("externalAuths", new JObject()); + arr.Add(obj); + } + + return Json(arr); + } + + #endregion + + #region UNIMPORTANT API + + [HttpGet("accounts/{id}/metadata")] + public IActionResult GetMetadata(string id) + { + EpicID eid = EpicID.FromString(id); + + logger.LogInformation($"Get metadata of {eid}"); + + // unknown structure, but epic always seems to respond with this + return Json("{}"); + } + + [HttpGet("public/account/{id}/externalAuths")] + public IActionResult GetExternalAuths(string id) + { + EpicID eid = EpicID.FromString(id); + + logger.LogInformation($"Get external auths of {eid}"); + // we don't really care about these, but structure for my github externalAuth is the following: + /* [{ "accountId": "0b0f09b400854b9b98932dd9e5abe7c5", "type": "github", "externalAuthId": "timiimit", "externalDisplayName": "timiimit", @@ -162,203 +173,203 @@ public IActionResult GetExternalAuths(string id) "dateAdded": "2018-01-17T18:58:39.831Z" }] */ - return Json("[]"); - } - - [HttpGet("epicdomains/ssodomains")] - [AllowAnonymous] - public IActionResult GetSSODomains() - { - logger.LogInformation(@"Get SSO domains"); - - // epic responds with this: ["unrealengine.com","unrealtournament.com","fortnite.com","epicgames.com"] - - return Json("[]"); - } - - #endregion - - #region NON-EPIC API - - [HttpPost("create/account")] - [AllowAnonymous] - public async Task RegisterAccount([FromForm] string username, [FromForm] string email, [FromForm] string password, [FromForm] string recaptchaToken) - { - var reCaptchaSecret = reCaptchaSettings.Value.SecretKey; - var httpClient = new HttpClient(); - var httpResponse = await httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={reCaptchaSecret}&response={recaptchaToken}"); - if (httpResponse.StatusCode != System.Net.HttpStatusCode.OK) - { - return Conflict("Recaptcha validation failed"); - } - - var jsonResponse = await httpResponse.Content.ReadAsStringAsync(); - var jsonData = JObject.Parse(jsonResponse); - if (jsonData["success"]?.ToObject() != true) - { - return Conflict("Recaptcha validation failed"); - } - - var account = await accountService.GetAccountAsync(username); - if (account != null) - { - logger.LogInformation($"Could not register duplicate account: {username}"); - return Conflict("Username already exists"); - } - - if (!ValidationHelper.ValidateUsername(username)) - { - logger.LogInformation($"Entered an invalid username: {username}"); - return Conflict("You have entered an invalid username"); - } - - email = email.ToLower(); - account = await accountService.GetAccountByEmailAsync(email); - if (account != null) - { - logger.LogInformation($"Could not register duplicate email: {email}"); - return Conflict("Email already exists"); - } - - if (!ValidationHelper.ValidateEmail(email)) - { - logger.LogInformation($"Entered an invalid email format: {email}"); - return Conflict("You have entered an invalid email address"); - } - - if (!ValidationHelper.ValidatePassword(password)) - { - logger.LogInformation($"Entered password was in invalid format"); - return Conflict("Unexpected password format"); - } - - await accountService.CreateAccountAsync(username, email, password); // TODO: this cannot fail? - - - logger.LogInformation($"Registered new user: {username}"); - - return Ok("Account created successfully"); - } - - [HttpPatch("update/username")] - public async Task UpdateUsername([FromForm] string newUsername) - { - if (User.Identity is not EpicUserIdentity user) - { - return Unauthorized(); - } - - if (!ValidationHelper.ValidateUsername(newUsername)) - { - return ValidationProblem(); - } - - var matchingAccount = await accountService.GetAccountAsync(newUsername); - if (matchingAccount != null) - { - logger.LogInformation($"Change Username failed, already taken: {newUsername}"); - return Conflict(new ErrorResponse() - { - ErrorMessage = $"Username already taken" - }); - } - - var account = await accountService.GetAccountAsync(user.Session.AccountID); - if (account == null) - { - return NotFound(new ErrorResponse() - { - ErrorMessage = $"Failed to retrieve your account" - }); - } - - account.Username = newUsername; - await accountService.UpdateAccountAsync(account); - - logger.LogInformation($"Updated username for {user.Session.AccountID} to: {newUsername}"); - - return Ok("Changed username successfully"); - } - - [HttpPatch("update/email")] - public async Task UpdateEmail([FromForm] string newEmail) - { - if (User.Identity is not EpicUserIdentity user) - { - return Unauthorized(); - } - - newEmail = newEmail.ToLower(); - if (!ValidationHelper.ValidateEmail(newEmail)) - { - return ValidationProblem(); - } - - var account = await accountService.GetAccountAsync(user.Session.AccountID); - if (account == null) - { - return NotFound(new ErrorResponse() - { - ErrorMessage = $"Failed to retrieve your account" - }); - } - - account.Email = newEmail; - await accountService.UpdateAccountAsync(account); - - logger.LogInformation($"Updated email for {user.Session.AccountID} to: {newEmail}"); - - return Ok("Changed email successfully"); - } - - [HttpPatch("update/password")] - public async Task UpdatePassword([FromForm] string currentPassword, [FromForm] string newPassword) - { - if (User.Identity is not EpicUserIdentity user) - { - throw new UnauthorizedAccessException(); - } - - if (user.Session.ClientID != ClientIdentification.Launcher.ID) - { - throw new UnauthorizedAccessException("Password can only be changed from the website"); - } - - // passwords should already be hashed, but check its length just in case - if (!ValidationHelper.ValidatePassword(newPassword)) - { - return BadRequest(new ErrorResponse() - { - ErrorMessage = $"newPassword is not a SHA512 hash" - }); - } - - var account = await accountService.GetAccountAsync(user.Session.AccountID); - if (account == null) - { - return NotFound(new ErrorResponse() - { - ErrorMessage = $"Failed to retrieve your account" - }); - } - - if (!account.CheckPassword(currentPassword, false)) - { - return BadRequest(new ErrorResponse() - { - ErrorMessage = $"Current Password is invalid" - }); - } - - await accountService.UpdateAccountPasswordAsync(account.ID, newPassword); - - // logout user to make sure they remember they changed password by being forced to log in again, - // as well as prevent anyone else from using this account after successful password change. - await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, user.Session.AccountID, EpicID.Empty); - - logger.LogInformation($"Updated password for {user.Session.AccountID}"); - - return Ok("Changed password successfully"); + return Json("[]"); + } + + [HttpGet("epicdomains/ssodomains")] + [AllowAnonymous] + public IActionResult GetSSODomains() + { + logger.LogInformation(@"Get SSO domains"); + + // epic responds with this: ["unrealengine.com","unrealtournament.com","fortnite.com","epicgames.com"] + + return Json("[]"); + } + + #endregion + + #region NON-EPIC API + + [HttpPost("create/account")] + [AllowAnonymous] + public async Task RegisterAccount([FromForm] string username, [FromForm] string email, [FromForm] string password, [FromForm] string recaptchaToken) + { + var reCaptchaSecret = reCaptchaSettings.Value.SecretKey; + var httpClient = new HttpClient(); + var httpResponse = await httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={reCaptchaSecret}&response={recaptchaToken}"); + if (httpResponse.StatusCode != System.Net.HttpStatusCode.OK) + { + return Conflict("Recaptcha validation failed"); + } + + var jsonResponse = await httpResponse.Content.ReadAsStringAsync(); + var jsonData = JObject.Parse(jsonResponse); + if (jsonData["success"]?.ToObject() != true) + { + return Conflict("Recaptcha validation failed"); + } + + var account = await accountService.GetAccountAsync(username); + if (account != null) + { + logger.LogInformation($"Could not register duplicate account: {username}"); + return Conflict("Username already exists"); + } + + if (!ValidationHelper.ValidateUsername(username)) + { + logger.LogInformation($"Entered an invalid username: {username}"); + return Conflict("You have entered an invalid username"); + } + + email = email.ToLower(); + account = await accountService.GetAccountByEmailAsync(email); + if (account != null) + { + logger.LogInformation($"Could not register duplicate email: {email}"); + return Conflict("Email already exists"); + } + + if (!ValidationHelper.ValidateEmail(email)) + { + logger.LogInformation($"Entered an invalid email format: {email}"); + return Conflict("You have entered an invalid email address"); + } + + if (!ValidationHelper.ValidatePassword(password)) + { + logger.LogInformation($"Entered password was in invalid format"); + return Conflict("Unexpected password format"); + } + + await accountService.CreateAccountAsync(username, email, password); // TODO: this cannot fail? + + + logger.LogInformation($"Registered new user: {username}"); + + return Ok("Account created successfully"); + } + + [HttpPatch("update/username")] + public async Task UpdateUsername([FromForm] string newUsername) + { + if (User.Identity is not EpicUserIdentity user) + { + return Unauthorized(); + } + + if (!ValidationHelper.ValidateUsername(newUsername)) + { + return ValidationProblem(); + } + + var matchingAccount = await accountService.GetAccountAsync(newUsername); + if (matchingAccount != null) + { + logger.LogInformation($"Change Username failed, already taken: {newUsername}"); + return Conflict(new ErrorResponse() + { + ErrorMessage = $"Username already taken" + }); + } + + var account = await accountService.GetAccountAsync(user.Session.AccountID); + if (account == null) + { + return NotFound(new ErrorResponse() + { + ErrorMessage = $"Failed to retrieve your account" + }); + } + + account.Username = newUsername; + await accountService.UpdateAccountAsync(account); + + logger.LogInformation($"Updated username for {user.Session.AccountID} to: {newUsername}"); + + return Ok("Changed username successfully"); + } + + [HttpPatch("update/email")] + public async Task UpdateEmail([FromForm] string newEmail) + { + if (User.Identity is not EpicUserIdentity user) + { + return Unauthorized(); + } + + newEmail = newEmail.ToLower(); + if (!ValidationHelper.ValidateEmail(newEmail)) + { + return ValidationProblem(); + } + + var account = await accountService.GetAccountAsync(user.Session.AccountID); + if (account == null) + { + return NotFound(new ErrorResponse() + { + ErrorMessage = $"Failed to retrieve your account" + }); + } + + account.Email = newEmail; + await accountService.UpdateAccountAsync(account); + + logger.LogInformation($"Updated email for {user.Session.AccountID} to: {newEmail}"); + + return Ok("Changed email successfully"); + } + + [HttpPatch("update/password")] + public async Task UpdatePassword([FromForm] string currentPassword, [FromForm] string newPassword) + { + if (User.Identity is not EpicUserIdentity user) + { + throw new UnauthorizedAccessException(); + } + + if (user.Session.ClientID != ClientIdentification.Launcher.ID) + { + throw new UnauthorizedAccessException("Password can only be changed from the website"); + } + + // passwords should already be hashed, but check its length just in case + if (!ValidationHelper.ValidatePassword(newPassword)) + { + return BadRequest(new ErrorResponse() + { + ErrorMessage = $"newPassword is not a SHA512 hash" + }); + } + + var account = await accountService.GetAccountAsync(user.Session.AccountID); + if (account == null) + { + return NotFound(new ErrorResponse() + { + ErrorMessage = $"Failed to retrieve your account" + }); + } + + if (!account.CheckPassword(currentPassword, false)) + { + return BadRequest(new ErrorResponse() + { + ErrorMessage = $"Current Password is invalid" + }); + } + + await accountService.UpdateAccountPasswordAsync(account.ID, newPassword); + + // logout user to make sure they remember they changed password by being forced to log in again, + // as well as prevent anyone else from using this account after successful password change. + await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, user.Session.AccountID, EpicID.Empty); + + logger.LogInformation($"Updated password for {user.Session.AccountID}"); + + return Ok("Changed password successfully"); } [AllowAnonymous] @@ -369,5 +380,22 @@ public async Task ActivateAccount([FromQuery] string email, [From return Ok(activated); } + [AllowAnonymous] + [HttpGet("initiate-reset-password")] + public async Task InitiateResetPassword([FromQuery] string email) + { + var clientIpAddress = GetClientIP(applicationSettings); + if (clientIpAddress == null) + { + logger.LogError("Could not determine IP Address of remote machine."); + return StatusCode(StatusCodes.Status500InternalServerError); + } + + rateLimitService.CheckRateLimit($"{nameof(InitiateResetPassword)}-{clientIpAddress}"); + + await accountService.InitiateResetPasswordAsync(email); + return Ok(); + } + #endregion } From 88d43eb81581447506e7d23de3955ca4390947dc Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Sat, 18 Feb 2023 11:55:08 +0100 Subject: [PATCH 20/38] Added NotFoundException and RateLimitExceededException to ErrorsController --- .../Controllers/ErrorsController.cs | 68 +++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/UT4MasterServer/Controllers/ErrorsController.cs b/UT4MasterServer/Controllers/ErrorsController.cs index b6826c75..89d35149 100644 --- a/UT4MasterServer/Controllers/ErrorsController.cs +++ b/UT4MasterServer/Controllers/ErrorsController.cs @@ -9,6 +9,8 @@ namespace UT4MasterServer.Controllers; [Route("api/errors")] public sealed class ErrorsController : ControllerBase { + private const string NotFoundError = "Not found."; + private const string BadRequestError = "Bad request."; private const string InternalServerError = "Internal server error occurred."; private const string UnauthorizedError = "Attempt to access resource without required authorization."; @@ -32,33 +34,61 @@ public IActionResult Index() logger.LogError(InternalServerError); return StatusCode(statusCode, message); } - + switch (exception) { case InvalidEpicIDException invalidEpicIDException: - { - var err = new ErrorResponse() { - ErrorCode = invalidEpicIDException.ErrorCode, - ErrorMessage = invalidEpicIDException.Message, - MessageVars = new string[] { invalidEpicIDException.ID }, - NumericErrorCode = invalidEpicIDException.NumericErrorCode - }; + var err = new ErrorResponse() + { + ErrorCode = invalidEpicIDException.ErrorCode, + ErrorMessage = invalidEpicIDException.Message, + MessageVars = new string[] { invalidEpicIDException.ID }, + NumericErrorCode = invalidEpicIDException.NumericErrorCode + }; - logger.LogError(exception, "Tried using {ID} as EpicID.", invalidEpicIDException.ID); - return StatusCode(400, err); - } + logger.LogError(exception, "Tried using {ID} as EpicID.", invalidEpicIDException.ID); + return StatusCode(400, err); + } case UnauthorizedAccessException unauthorizedAccessException: - { - logger.LogWarning(exception, UnauthorizedError); - return StatusCode(401, new ErrorResponse() { - ErrorCode = "com.epicgames.errors.unauthorized", - ErrorMessage = string.IsNullOrWhiteSpace(unauthorizedAccessException.Message) ? UnauthorizedError : unauthorizedAccessException.Message, - NumericErrorCode = 401 - }); - } + logger.LogWarning(exception, UnauthorizedError); + return StatusCode(401, new ErrorResponse() + { + ErrorCode = "com.epicgames.errors.unauthorized", + ErrorMessage = string.IsNullOrWhiteSpace(unauthorizedAccessException.Message) ? UnauthorizedError : unauthorizedAccessException.Message, + NumericErrorCode = 401 + }); + } + + case NotFoundException notFoundException: + { + var err = new ErrorResponse() + { + ErrorCode = "ut4masterserver.notfound", + ErrorMessage = notFoundException.Message, + MessageVars = Array.Empty(), + NumericErrorCode = 404 + }; + + logger.LogWarning(notFoundException, NotFoundError); + return StatusCode(404, err); + } + + case RateLimitExceededException rateLimitExceededException: + { + var err = new ErrorResponse() + { + ErrorCode = "ut4masterserver.ratelimitexceeded", + ErrorMessage = rateLimitExceededException.Message, + MessageVars = Array.Empty(), + NumericErrorCode = 400 + }; + + logger.LogWarning(rateLimitExceededException, BadRequestError); + return StatusCode(400, err); + } } logger.LogError(exception, InternalServerError); From 703c08d55c81e3db6de6ddbd80ab495f435554f8 Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Sat, 18 Feb 2023 12:24:30 +0100 Subject: [PATCH 21/38] Added Forgot Password page on frontend --- .../src/pages/ForgotPassword.vue | 75 +++++++++++++++++++ UT4MasterServer.Web/src/pages/Login.vue | 3 +- UT4MasterServer.Web/src/routes.ts | 5 ++ .../src/services/account.service.ts | 6 ++ 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 UT4MasterServer.Web/src/pages/ForgotPassword.vue diff --git a/UT4MasterServer.Web/src/pages/ForgotPassword.vue b/UT4MasterServer.Web/src/pages/ForgotPassword.vue new file mode 100644 index 00000000..d796b6c2 --- /dev/null +++ b/UT4MasterServer.Web/src/pages/ForgotPassword.vue @@ -0,0 +1,75 @@ + + + diff --git a/UT4MasterServer.Web/src/pages/Login.vue b/UT4MasterServer.Web/src/pages/Login.vue index f2dcc967..8ad26ec8 100644 --- a/UT4MasterServer.Web/src/pages/Login.vue +++ b/UT4MasterServer.Web/src/pages/Login.vue @@ -78,7 +78,8 @@
- Create an account + Create an account | + Forgot password diff --git a/UT4MasterServer.Web/src/routes.ts b/UT4MasterServer.Web/src/routes.ts index 8837c210..120a814b 100644 --- a/UT4MasterServer.Web/src/routes.ts +++ b/UT4MasterServer.Web/src/routes.ts @@ -114,6 +114,11 @@ export const routes: RouteRecordRaw[] = [ component: async () => import('./pages/ForgotPassword.vue'), beforeEnter: publicGuard }, + { + path: `/ResetPassword`, + component: async () => import('./pages/ResetPassword.vue'), + beforeEnter: publicGuard + }, { path: `/`, redirect: '/Login' diff --git a/UT4MasterServer.Web/src/services/account.service.ts b/UT4MasterServer.Web/src/services/account.service.ts index 3b02a899..e941b588 100644 --- a/UT4MasterServer.Web/src/services/account.service.ts +++ b/UT4MasterServer.Web/src/services/account.service.ts @@ -5,6 +5,7 @@ import { IChangePasswordRequest } from '@/types/change-password-request'; import { IChangeUsernameRequest } from '@/types/change-username-request'; import { IRegisterRequest } from '@/types/register-request'; import { ISearchAccountsResponse } from '@/types/search-accounts-response'; +import { IResetPasswordRequest } from '@/types/reset-password'; import HttpService from './http.service'; export default class AccountService extends HttpService { @@ -78,4 +79,13 @@ export default class AccountService extends HttpService { `${this.baseUrl}/initiate-reset-password?email=${email}` ); } + + async resetPassword(request: IResetPasswordRequest) { + return await this.post( + `${this.baseUrl}/reset-password`, + { + body: request + } + ); + } } diff --git a/UT4MasterServer.Web/src/types/reset-password.ts b/UT4MasterServer.Web/src/types/reset-password.ts new file mode 100644 index 00000000..a857843d --- /dev/null +++ b/UT4MasterServer.Web/src/types/reset-password.ts @@ -0,0 +1,5 @@ +export interface IResetPasswordRequest { + accountId: string; + guid: string; + newPassword: string; +} diff --git a/UT4MasterServer/Controllers/Epic/AccountController.cs b/UT4MasterServer/Controllers/Epic/AccountController.cs index 08cafefa..31d2feb3 100644 --- a/UT4MasterServer/Controllers/Epic/AccountController.cs +++ b/UT4MasterServer/Controllers/Epic/AccountController.cs @@ -397,5 +397,14 @@ public async Task InitiateResetPassword([FromQuery] string email) return Ok(); } + [AllowAnonymous] + [HttpPost("reset-password")] + public async Task ResetPassword([FromForm] string accountID, [FromForm] string guid, [FromForm] string newPassword) + { + EpicID eid = EpicID.FromString(accountID); + await accountService.ResetPasswordAsync(eid, guid, newPassword); + return Ok(); + } + #endregion } diff --git a/UT4MasterServer/appsettings.Development.json b/UT4MasterServer/appsettings.Development.json index 8a12b92c..77ece1f3 100644 --- a/UT4MasterServer/appsettings.Development.json +++ b/UT4MasterServer/appsettings.Development.json @@ -1,6 +1,7 @@ { "ApplicationSettings": { - "WebsiteDomain": "localhost" + "WebsiteScheme": "http", + "WebsiteDomain": "localhost:8080" }, "Logging": { "LogLevel": { diff --git a/UT4MasterServer/appsettings.json b/UT4MasterServer/appsettings.json index aa05e9c2..cd9e51f1 100644 --- a/UT4MasterServer/appsettings.json +++ b/UT4MasterServer/appsettings.json @@ -2,6 +2,7 @@ "ApplicationSettings": { "DatabaseConnectionString": "mongodb://devroot:devroot@mongo:27017", "DatabaseName": "ut4master", + "WebsiteScheme": "https", "WebsiteDomain": "ut4.timiimit.com", "AllowPasswordGrantType": true, "ProxyClientIPHeader": "X-Forwarded-For", From 4cbc651141f028929a9858ced8a714c45021aa8e Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Sat, 18 Feb 2023 19:46:38 +0100 Subject: [PATCH 23/38] Reverted WebsitePort settings --- UT4MasterServer.Models/Settings/ApplicationSettings.cs | 5 +++++ UT4MasterServer.Services/Scoped/AccountService.cs | 1 + UT4MasterServer/appsettings.Development.json | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/UT4MasterServer.Models/Settings/ApplicationSettings.cs b/UT4MasterServer.Models/Settings/ApplicationSettings.cs index f94596e1..5e3dc26a 100644 --- a/UT4MasterServer.Models/Settings/ApplicationSettings.cs +++ b/UT4MasterServer.Models/Settings/ApplicationSettings.cs @@ -20,6 +20,11 @@ public sealed class ApplicationSettings /// 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 . diff --git a/UT4MasterServer.Services/Scoped/AccountService.cs b/UT4MasterServer.Services/Scoped/AccountService.cs index a95ee87a..ed02d074 100644 --- a/UT4MasterServer.Services/Scoped/AccountService.cs +++ b/UT4MasterServer.Services/Scoped/AccountService.cs @@ -272,6 +272,7 @@ private async Task SendResetPasswordLinkAsync(string email, EpicID accountID, st { Scheme = applicationSettings.WebsiteScheme, Host = applicationSettings.WebsiteDomain, + Port = applicationSettings.WebsitePort, Path = "ResetPassword", Query = $"accountId={accountID}&guid={guid}" }; diff --git a/UT4MasterServer/appsettings.Development.json b/UT4MasterServer/appsettings.Development.json index 77ece1f3..8c8e490c 100644 --- a/UT4MasterServer/appsettings.Development.json +++ b/UT4MasterServer/appsettings.Development.json @@ -1,7 +1,8 @@ { "ApplicationSettings": { "WebsiteScheme": "http", - "WebsiteDomain": "localhost:8080" + "WebsiteDomain": "localhost", + "WebsitePort": 8080 }, "Logging": { "LogLevel": { From 2c8ba5a92540e5bdb8ce382b91461a29d9c5492f Mon Sep 17 00:00:00 2001 From: Belmir Patkovic Date: Sat, 18 Feb 2023 19:50:42 +0100 Subject: [PATCH 24/38] Hiding components when reset password completes --- UT4MasterServer.Web/src/pages/ResetPassword.vue | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/UT4MasterServer.Web/src/pages/ResetPassword.vue b/UT4MasterServer.Web/src/pages/ResetPassword.vue index f4bbb4c0..4e354044 100644 --- a/UT4MasterServer.Web/src/pages/ResetPassword.vue +++ b/UT4MasterServer.Web/src/pages/ResetPassword.vue @@ -12,9 +12,12 @@ v-show="passwordChanged" class="alert alert-dismissible alert-success" > -
Password changed successfully.
+
+ Password changed successfully. Click + here to go to login page. +
-
+
@@ -32,7 +35,7 @@
New password is required
-
+
@@ -50,7 +53,7 @@
Confirm new password is required
-
+