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.Common/Exceptions/AccountActivationException.cs b/UT4MasterServer.Common/Exceptions/AccountActivationException.cs new file mode 100644 index 00000000..e95a7be2 --- /dev/null +++ b/UT4MasterServer.Common/Exceptions/AccountActivationException.cs @@ -0,0 +1,14 @@ +namespace UT4MasterServer.Common.Exceptions; + +[Serializable] +public sealed class AccountActivationException : Exception +{ + public AccountActivationException(string message) : base(message) + { + } + + public AccountActivationException(string message, Exception innerException) : base(message, innerException) + { + } +} + diff --git a/UT4MasterServer.Common/Exceptions/AccountNotActiveException.cs b/UT4MasterServer.Common/Exceptions/AccountNotActiveException.cs new file mode 100644 index 00000000..6bf471b2 --- /dev/null +++ b/UT4MasterServer.Common/Exceptions/AccountNotActiveException.cs @@ -0,0 +1,13 @@ +namespace UT4MasterServer.Common.Exceptions; + +[Serializable] +public sealed class AccountNotActiveException : Exception +{ + public AccountNotActiveException(string message) : base(message) + { + } + + public AccountNotActiveException(string message, Exception innerException) : base(message, innerException) + { + } +} 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/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.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 eaaaa56f..b038a288 100644 --- a/UT4MasterServer.Models/Database/Account.cs +++ b/UT4MasterServer.Models/Database/Account.cs @@ -69,6 +69,20 @@ public class Account [BsonElement("Flags")] public AccountFlags Flags { get; set; } = 0; + [BsonIgnoreIfNull] + public string? ActivationLinkGUID { get; set; } + + [BsonIgnoreIfNull] + public DateTime? ActivationLinkExpiration { get; set; } + + [BsonIgnoreIfNull] + public string? ResetLinkGUID { get; set; } + + [BsonIgnoreIfNull] + public DateTime? ResetLinkExpiration { get; set; } + + public AccountStatus Status { get; set; } = AccountStatus.PendingActivation; + [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..5e3dc26a 100644 --- a/UT4MasterServer.Models/Settings/ApplicationSettings.cs +++ b/UT4MasterServer.Models/Settings/ApplicationSettings.cs @@ -10,11 +10,21 @@ public sealed class ApplicationSettings /// public bool AllowPasswordGrantType { get; set; } = false; + /// + /// Used for URL generation when sending activation link, reset links, etc. + /// + public string WebsiteScheme { get; set; } = string.Empty; + /// /// Used just to redirect users to correct domain when UT4UU is being used. /// 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 +40,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.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; } diff --git a/UT4MasterServer.Services/Scoped/AccountService.cs b/UT4MasterServer.Services/Scoped/AccountService.cs index a2cb78b7..b5b6402a 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; @@ -12,10 +13,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 +43,16 @@ public async Task CreateAccountAsync(string username, string email, string passw { ID = EpicID.GenerateNew(), Username = username, - Email = email + Email = email, + ActivationLinkGUID = Guid.NewGuid().ToString(), + ActivationLinkExpiration = DateTime.UtcNow.AddMinutes(5), + Status = AccountStatus.PendingActivation, }; newAccount.Password = PasswordHelper.GetPasswordHash(newAccount.ID, password); await accountCollection.InsertOneAsync(newAccount); + + await SendActivationLinkAsync(email, newAccount.ID, newAccount.ActivationLinkGUID); } public async Task GetAccountByEmailAsync(string email) @@ -100,6 +113,11 @@ public async Task> SearchAccountsAsync(string usernameQue return null; } + if (account.Status == AccountStatus.PendingActivation) + { + throw new AccountNotActiveException("Account is pending activation. Check your email for activation link or resend it."); + } + return account; } @@ -172,5 +190,126 @@ public async Task RemoveAccountAsync(EpicID id) { await accountCollection.DeleteOneAsync(user => user.ID == id); } -} + public async Task ActivateAccountAsync(EpicID accountID, string guid) + { + var filter = Builders.Filter.Eq(f => f.ID, accountID) & + Builders.Filter.Eq(f => f.ActivationLinkGUID, guid) & + Builders.Filter.Gt(x => x.ActivationLinkExpiration, DateTime.UtcNow) & + Builders.Filter.Eq(f => f.Status, AccountStatus.PendingActivation); + var account = await accountCollection.Find(filter).FirstOrDefaultAsync(); + + if (account is null) + { + throw new AccountActivationException("Account activation failed: requested account not found, activation link expired or account in a wrong status."); + } + + var updateDefinition = Builders.Update + .Set(s => s.Status, AccountStatus.Active) + .Unset(u => u.ActivationLinkGUID) + .Unset(u => u.ActivationLinkExpiration); + await accountCollection.UpdateOneAsync(filter, updateDefinition); + } + + public async Task ResendActivationLinkAsync(string email) + { + var filter = Builders.Filter.Eq(f => f.Email, email) & + Builders.Filter.Eq(f => f.Status, AccountStatus.PendingActivation); + var account = await accountCollection.Find(filter).FirstOrDefaultAsync(); + + if (account is null) + { + throw new NotFoundException("Email not found or account in a wrong status."); + } + + var activationGUID = Guid.NewGuid().ToString(); + + var updateDefinition = Builders.Update + .Set(s => s.ActivationLinkGUID, activationGUID) + .Set(s => s.ActivationLinkExpiration, DateTime.UtcNow.AddMinutes(5)); + await accountCollection.UpdateOneAsync(filter, updateDefinition); + + await SendActivationLinkAsync(email, account.ID, activationGUID); + } + + 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 a 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, 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 SendActivationLinkAsync(string email, EpicID accountID, string guid) + { + UriBuilder uriBuilder = new() + { + Scheme = applicationSettings.WebsiteScheme, + Host = applicationSettings.WebsiteDomain, + Port = applicationSettings.WebsitePort, + Path = "ActivateAccount", + Query = $"accountId={accountID}&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); + } + + 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.

+ "; + + await awsSesClient.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..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/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 c7be0877..77487d08 100644 --- a/UT4MasterServer.Services/UT4MasterServer.Services.csproj +++ b/UT4MasterServer.Services/UT4MasterServer.Services.csproj @@ -7,6 +7,8 @@ + + diff --git a/UT4MasterServer.Web/src/pages/ActivateAccount.vue b/UT4MasterServer.Web/src/pages/ActivateAccount.vue new file mode 100644 index 00000000..44b9d73a --- /dev/null +++ b/UT4MasterServer.Web/src/pages/ActivateAccount.vue @@ -0,0 +1,53 @@ + + + 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..f2c42137 100644 --- a/UT4MasterServer.Web/src/pages/Login.vue +++ b/UT4MasterServer.Web/src/pages/Login.vue @@ -1,8 +1,25 @@ diff --git a/UT4MasterServer.Web/src/pages/Register.vue b/UT4MasterServer.Web/src/pages/Register.vue index cfb44e44..dff549d8 100644 --- a/UT4MasterServer.Web/src/pages/Register.vue +++ b/UT4MasterServer.Web/src/pages/Register.vue @@ -149,7 +149,7 @@ async function register() { } await accountService.register(formData); status.value = AsyncStatus.OK; - router.push('/Login'); + router.push(`/Login?activationLinkSent=true`); } catch (err: unknown) { status.value = AsyncStatus.ERROR; errorMessage.value = (err as Error)?.message; diff --git a/UT4MasterServer.Web/src/pages/ResendActivation.vue b/UT4MasterServer.Web/src/pages/ResendActivation.vue new file mode 100644 index 00000000..be44c6a4 --- /dev/null +++ b/UT4MasterServer.Web/src/pages/ResendActivation.vue @@ -0,0 +1,80 @@ + + + diff --git a/UT4MasterServer.Web/src/pages/ResetPassword.vue b/UT4MasterServer.Web/src/pages/ResetPassword.vue new file mode 100644 index 00000000..ddd7cbc2 --- /dev/null +++ b/UT4MasterServer.Web/src/pages/ResetPassword.vue @@ -0,0 +1,137 @@ + + + diff --git a/UT4MasterServer.Web/src/routes.ts b/UT4MasterServer.Web/src/routes.ts index 41b0fd29..482b0e4b 100644 --- a/UT4MasterServer.Web/src/routes.ts +++ b/UT4MasterServer.Web/src/routes.ts @@ -98,11 +98,31 @@ export const routes: RouteRecordRaw[] = [ component: async () => import('./pages/Register.vue'), beforeEnter: publicGuard }, + { + path: `/ActivateAccount`, + component: async () => import('./pages/ActivateAccount.vue'), + beforeEnter: publicGuard + }, + { + path: `/ResendActivation`, + component: async () => import('./pages/ResendActivation.vue'), + beforeEnter: publicGuard + }, { path: `/Login`, component: async () => import('./pages/Login.vue'), beforeEnter: publicGuard }, + { + path: `/ForgotPassword`, + 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 98281f10..4ef7f23f 100644 --- a/UT4MasterServer.Web/src/services/account.service.ts +++ b/UT4MasterServer.Web/src/services/account.service.ts @@ -5,6 +5,10 @@ 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 { IActivateAccountRequest } from '@/types/activate-account-request'; +import { IResendActivationLinkRequest } from '@/types/resend-activation-link-request'; +import { IInitiateResetPasswordRequest } from '@/types/initiate-reset-password-request'; +import { IResetPasswordRequest } from '@/types/reset-password-request'; import HttpService from './http.service'; export default class AccountService extends HttpService { @@ -66,4 +70,31 @@ export default class AccountService extends HttpService { false ); } + + async activateAccount(request: IActivateAccountRequest) { + return await this.post(`${this.baseUrl}/activate-account`, { + body: request + }); + } + + async resendActivationLink(request: IResendActivationLinkRequest) { + return await this.post(`${this.baseUrl}/resend-activation-link`, { + body: request + }); + } + + async initiateResetPassword(request: IInitiateResetPasswordRequest) { + return await this.post(`${this.baseUrl}/initiate-reset-password`, { + body: request + }); + } + + async resetPassword(request: IResetPasswordRequest) { + return await this.post( + `${this.baseUrl}/reset-password`, + { + body: request + } + ); + } } diff --git a/UT4MasterServer.Web/src/services/http.service.ts b/UT4MasterServer.Web/src/services/http.service.ts index 5056a5b6..ea1c3002 100644 --- a/UT4MasterServer.Web/src/services/http.service.ts +++ b/UT4MasterServer.Web/src/services/http.service.ts @@ -9,10 +9,10 @@ export interface HttpRequestOptions { } export class HttpError { - code: number; + code: string; message: string; - constructor(code: number, message: string) { + constructor(code: string, message: string) { this.code = code; this.message = message; } diff --git a/UT4MasterServer.Web/src/types/activate-account-request.ts b/UT4MasterServer.Web/src/types/activate-account-request.ts new file mode 100644 index 00000000..b7f864b2 --- /dev/null +++ b/UT4MasterServer.Web/src/types/activate-account-request.ts @@ -0,0 +1,4 @@ +export interface IActivateAccountRequest { + accountId: string; + guid: string; +} diff --git a/UT4MasterServer.Web/src/types/initiate-reset-password-request.ts b/UT4MasterServer.Web/src/types/initiate-reset-password-request.ts new file mode 100644 index 00000000..13fd87df --- /dev/null +++ b/UT4MasterServer.Web/src/types/initiate-reset-password-request.ts @@ -0,0 +1,3 @@ +export interface IInitiateResetPasswordRequest { + email: string; +} diff --git a/UT4MasterServer.Web/src/types/resend-activation-link-request.ts b/UT4MasterServer.Web/src/types/resend-activation-link-request.ts new file mode 100644 index 00000000..692a51b0 --- /dev/null +++ b/UT4MasterServer.Web/src/types/resend-activation-link-request.ts @@ -0,0 +1,3 @@ +export interface IResendActivationLinkRequest { + email: string; +} diff --git a/UT4MasterServer.Web/src/types/reset-password-request.ts b/UT4MasterServer.Web/src/types/reset-password-request.ts new file mode 100644 index 00000000..a857843d --- /dev/null +++ b/UT4MasterServer.Web/src/types/reset-password-request.ts @@ -0,0 +1,5 @@ +export interface IResetPasswordRequest { + accountId: string; + guid: string; + newPassword: string; +} diff --git a/UT4MasterServer/Controllers/AdminPanelController.cs b/UT4MasterServer/Controllers/AdminPanelController.cs index ac2205f0..e01f3437 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,20 @@ public async Task DeleteMCPFile(string filename) #endregion + [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] private async Task<(Session Session, Account Account)> VerifyAccessAsync(params AccountFlags[] aclAny) { diff --git a/UT4MasterServer/Controllers/Epic/AccountController.cs b/UT4MasterServer/Controllers/Epic/AccountController.cs index 18292fd3..ecbedfa7 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; + 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", + }); + } - public AccountController(ILogger logger, AccountService accountService, SessionService sessionService, IOptions reCaptchaSettings) : base(logger) - { - this.accountService = accountService; - this.sessionService = sessionService; - this.reCaptchaSettings = reCaptchaSettings; - } + var ids = accountIDs.Distinct().Select(x => EpicID.FromString(x)); + var accounts = await accountService.GetAccountsAsync(ids.ToList()); - #region ACCOUNT LISTING API + var retrievedAccountIDs = accounts.Select(x => x.ID.ToString()); + logger.LogInformation($"{authenticatedUser.Session.AccountID} is looking for {string.Join(", ", retrievedAccountIDs)}"); - [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); + // 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); + } - logger.LogInformation($"Get metadata of {eid}"); + obj.Add("externalAuths", new JObject()); + arr.Add(obj); + } - // unknown structure, but epic always seems to respond with this - return Json("{}"); - } + return Json(arr); + } - [HttpGet("public/account/{id}/externalAuths")] - public IActionResult GetExternalAuths(string id) - { - EpicID eid = EpicID.FromString(id); + #endregion + + #region UNIMPORTANT API + + [HttpGet("accounts/{id}/metadata")] + public IActionResult GetMetadata(string id) + { + EpicID eid = EpicID.FromString(id); + + logger.LogInformation($"Get metadata of {eid}"); - logger.LogInformation($"Get external auths of {eid}"); - // we don't really care about these, but structure for my github externalAuth is the following: - /* + // 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,23 +173,23 @@ public IActionResult GetExternalAuths(string id) "dateAdded": "2018-01-17T18:58:39.831Z" }] */ - return Json("[]"); - } + return Json("[]"); + } - [HttpGet("epicdomains/ssodomains")] - [AllowAnonymous] - public IActionResult GetSSODomains() - { - logger.LogInformation(@"Get SSO domains"); + [HttpGet("epicdomains/ssodomains")] + [AllowAnonymous] + public IActionResult GetSSODomains() + { + logger.LogInformation(@"Get SSO domains"); - // epic responds with this: ["unrealengine.com","unrealtournament.com","fortnite.com","epicgames.com"] + // epic responds with this: ["unrealengine.com","unrealtournament.com","fortnite.com","epicgames.com"] - return Json("[]"); - } + return Json("[]"); + } - #endregion + #endregion - #region NON-EPIC API + #region NON-EPIC API [HttpPost("create/account")] [AllowAnonymous] @@ -207,167 +218,219 @@ public async Task RegisterAccount([FromForm] string username, [Fr } } - 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"); - } - - #endregion + 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] + [HttpPost("activate-account")] + public async Task ActivateAccount([FromForm] string accountID, [FromForm] string guid) + { + EpicID eid = EpicID.FromString(accountID); + await accountService.ActivateAccountAsync(eid, guid); + return Ok(); + } + + [AllowAnonymous] + [HttpPost("resend-activation-link")] + public async Task ResendActivationLink([FromForm] 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(ResendActivationLink)}-{clientIpAddress}"); + + await accountService.ResendActivationLinkAsync(email); + return Ok(); + } + + [AllowAnonymous] + [HttpPost("initiate-reset-password")] + public async Task InitiateResetPassword([FromForm] 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(); + } + + [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/Controllers/ErrorsController.cs b/UT4MasterServer/Controllers/ErrorsController.cs index b6826c75..fd2be547 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,89 @@ 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 AccountActivationException accountActivationException: + { + var err = new ErrorResponse() + { + ErrorCode = "ut4masterserver.errors.accountactivation", + ErrorMessage = accountActivationException.Message, + MessageVars = Array.Empty(), + NumericErrorCode = 404 + }; + + logger.LogError(accountActivationException, "Account activation failed."); + return StatusCode(404, err); + } + + case AccountNotActiveException accountNotActiveException: + { + var err = new ErrorResponse() + { + ErrorCode = "ut4masterserver.errors.accountpendingactivation", + ErrorMessage = accountNotActiveException.Message, + MessageVars = Array.Empty(), + NumericErrorCode = 401 + }; + + logger.LogError(accountNotActiveException, "Account pending activation."); + return StatusCode(401, err); + } + + case NotFoundException notFoundException: + { + var err = new ErrorResponse() + { + ErrorCode = "ut4masterserver.errors.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.errors.ratelimitexceeded", + ErrorMessage = rateLimitExceededException.Message, + MessageVars = Array.Empty(), + NumericErrorCode = 400 + }; + + logger.LogWarning(rateLimitExceededException, BadRequestError); + return StatusCode(400, err); + } } logger.LogError(exception, InternalServerError); diff --git a/UT4MasterServer/Program.cs b/UT4MasterServer/Program.cs index 36f8cfd4..bdcad895 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")); @@ -87,7 +88,7 @@ public static void Main(string[] args) // we ignore the fact that proxy list file was not found } }); - + builder.Services.Configure(x => { if (builder.Environment.IsProduction()) @@ -99,6 +100,9 @@ public static void Main(string[] args) } }); + // Microsoft services + builder.Services.AddMemoryCache(); + // services whose instance is created per-request builder.Services .AddScoped() @@ -110,13 +114,15 @@ public static void Main(string[] args) .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); // services whose instance is created once and are persistent builder.Services .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); // hosted services builder.Services diff --git a/UT4MasterServer/appsettings.Development.json b/UT4MasterServer/appsettings.Development.json index 39e99c33..8c8e490c 100644 --- a/UT4MasterServer/appsettings.Development.json +++ b/UT4MasterServer/appsettings.Development.json @@ -1,4 +1,9 @@ { + "ApplicationSettings": { + "WebsiteScheme": "http", + "WebsiteDomain": "localhost", + "WebsitePort": 8080 + }, "Logging": { "LogLevel": { "Default": "Trace", diff --git a/UT4MasterServer/appsettings.json b/UT4MasterServer/appsettings.json index 9644b9fc..52f46ba1 100644 --- a/UT4MasterServer/appsettings.json +++ b/UT4MasterServer/appsettings.json @@ -2,9 +2,17 @@ "ApplicationSettings": { "DatabaseConnectionString": "mongodb://devroot:devroot@mongo:27017", "DatabaseName": "ut4master", + "WebsiteScheme": "https", + "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": "", + "SecretKey": "", + "RegionName": "eu-central-1" }, "StatisticsSettings": { "DeleteOldStatisticsTimeZone": "Central European Standard Time",