From f1648656618f257a7537ef19c9561eb59cd9bf7e Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Mon, 15 Jun 2026 12:35:55 -0500 Subject: [PATCH 01/18] feat: password reset via email (forgot-password + reset-password) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a complete password-reset flow on the master server: API POST /account/api/forgot-password (email) → anti-enumeration response, issues a token, sends an email. POST /account/api/reset-password (token, newPassword) → consumes the token, updates the password, invalidates existing sessions. Backend - Models/Database/PasswordResetToken.cs: single-use hex token, TTL index. - Services/Scoped/PasswordResetService.cs: issue, look-up, atomic consume. - Services/Scoped/EmailService.cs: MailKit SMTP transport with optional console logging for dev. - Models/Settings/MailSettings.cs: SMTP config, no-op if Host/Port empty. Web - pages/ForgotPassword.vue: email input, anti-enumeration response. - pages/ResetPassword.vue: token from query, SHA-512-hashes new password client-side (matching Login/Register pattern), submits. - Routes added; "Forgot password?" link on Login. Security notes - Anti-enumeration: forgot-password ALWAYS returns the same response, independent of whether the email matches an account. EmailService and PasswordResetService don't throw to callers either; the response shape is stable. - Tokens are SHA-512 hex (32 random bytes), single-use, 1h TTL via mongo TTL index + explicit IsValid() check on consume. - Reset invalidates active sessions, forcing re-login with the new password (same pattern as ChangePassword). Dev wiring - Smoke docker-compose adds mailpit (port 8025 UI); api SMTP env points at mailpit:1025 with FromAddress=noreply@ut4-hub.local. --- .../Database/PasswordResetToken.cs | 50 +++++++ .../Settings/ApplicationSettings.cs | 8 ++ .../Settings/MailSettings.cs | 43 ++++++ .../Hosted/ApplicationStartupService.cs | 8 +- .../Scoped/EmailService.cs | 92 +++++++++++++ .../Scoped/PasswordResetService.cs | 129 ++++++++++++++++++ .../UT4MasterServer.Services.csproj | 1 + .../src/pages/ForgotPassword.vue | 68 +++++++++ UT4MasterServer.Web/src/pages/Login.vue | 2 + .../src/pages/ResetPassword.vue | 107 +++++++++++++++ UT4MasterServer.Web/src/routes.ts | 10 ++ .../src/services/account.service.ts | 14 ++ .../Controllers/Epic/AccountController.cs | 77 ++++++++++- UT4MasterServer/Program.cs | 4 +- 14 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 UT4MasterServer.Models/Database/PasswordResetToken.cs create mode 100644 UT4MasterServer.Models/Settings/MailSettings.cs create mode 100644 UT4MasterServer.Services/Scoped/EmailService.cs create mode 100644 UT4MasterServer.Services/Scoped/PasswordResetService.cs create mode 100644 UT4MasterServer.Web/src/pages/ForgotPassword.vue create mode 100644 UT4MasterServer.Web/src/pages/ResetPassword.vue diff --git a/UT4MasterServer.Models/Database/PasswordResetToken.cs b/UT4MasterServer.Models/Database/PasswordResetToken.cs new file mode 100644 index 00000000..d7ee0630 --- /dev/null +++ b/UT4MasterServer.Models/Database/PasswordResetToken.cs @@ -0,0 +1,50 @@ +using MongoDB.Bson.Serialization.Attributes; +using UT4MasterServer.Common; + +namespace UT4MasterServer.Models.Database; + +/// +/// Single-use password reset token. Generated on forgot-password request, +/// emailed to the account's address, consumed by reset-password. +/// +/// Stored in collection "password_reset_tokens" with a TTL index on +/// so mongo auto-expires stale rows. +/// +[BsonIgnoreExtraElements] +public sealed class PasswordResetToken +{ + /// Hex-encoded 32-byte random token. Stored hashed in a future hardening pass. + [BsonId] + public string Token { get; set; } = string.Empty; + + [BsonElement("AccountID")] + public EpicID AccountID { get; set; } = EpicID.Empty; + + [BsonElement("CreatedAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Token expiry. Mongo TTL index will auto-delete past this time, but the + /// service also checks explicitly on each use as a defense-in-depth. + /// + [BsonElement("ExpiresAt")] + public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(1); + + /// True after the token has been consumed by a reset. + [BsonElement("Consumed")] + public bool Consumed { get; set; } = false; + + public PasswordResetToken() { } + + public PasswordResetToken(string token, EpicID accountID, TimeSpan validity) + { + Token = token; + AccountID = accountID; + CreatedAt = DateTime.UtcNow; + ExpiresAt = DateTime.UtcNow.Add(validity); + Consumed = false; + } + + public bool IsValid() + => !Consumed && DateTime.UtcNow < ExpiresAt; +} diff --git a/UT4MasterServer.Models/Settings/ApplicationSettings.cs b/UT4MasterServer.Models/Settings/ApplicationSettings.cs index d28b7143..f50c9d66 100644 --- a/UT4MasterServer.Models/Settings/ApplicationSettings.cs +++ b/UT4MasterServer.Models/Settings/ApplicationSettings.cs @@ -30,4 +30,12 @@ public sealed class ApplicationSettings /// IP addresses of trusted proxy servers. /// public List ProxyServers { get; set; } = new List(); + + /// + /// SMTP transport for outbound mail (password resets, etc.). When + /// is false, mail-dependent + /// features no-op (request accepted, no email sent — anti-enumeration + /// surface still preserved). + /// + public MailSettings Mail { get; set; } = new MailSettings(); } diff --git a/UT4MasterServer.Models/Settings/MailSettings.cs b/UT4MasterServer.Models/Settings/MailSettings.cs new file mode 100644 index 00000000..38d99cfc --- /dev/null +++ b/UT4MasterServer.Models/Settings/MailSettings.cs @@ -0,0 +1,43 @@ +namespace UT4MasterServer.Models.Settings; + +/// +/// SMTP / mail transport configuration. Populated from appsettings.json +/// (section "ApplicationSettings:Mail") or the equivalent env vars +/// (e.g. ApplicationSettings__Mail__Host). +/// +public sealed class MailSettings +{ + /// SMTP hostname (e.g. "mailpit" in dev, real SMTP host in prod). + public string Host { get; set; } = string.Empty; + + /// SMTP port. 1025 for mailpit; 587 for STARTTLS; 465 for SMTPS. + public int Port { get; set; } = 0; + + /// Auth username; leave empty for unauthenticated SMTP (e.g. mailpit). + public string Username { get; set; } = string.Empty; + + /// Auth password; leave empty for unauthenticated SMTP. + public string Password { get; set; } = string.Empty; + + /// Whether to use STARTTLS when connecting. + public bool UseStartTls { get; set; } = false; + + /// Whether to use implicit TLS (SMTPS, usually port 465). + public bool UseSsl { get; set; } = false; + + /// From: address on outbound mail. + public string FromAddress { get; set; } = "noreply@ut4-hub.local"; + + /// From: display name on outbound mail. + public string FromName { get; set; } = "UT4 Master Server"; + + /// + /// When true (typical for dev), also log every outbound email body to the + /// API stdout. Lets you grab the reset link from logs when SMTP isn't + /// configured. + /// + public bool LogToConsole { get; set; } = false; + + /// True iff Host is non-empty (used by EmailService to decide whether to dispatch). + public bool IsConfigured => !string.IsNullOrWhiteSpace(Host) && Port > 0; +} diff --git a/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs b/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs index b9f021dc..527954c9 100644 --- a/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs +++ b/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs @@ -14,13 +14,16 @@ public sealed class ApplicationStartupService : IHostedService private readonly CloudStorageService cloudStorageService; private readonly ClientService clientService; private readonly RatingsService ratingsService; + private readonly PasswordResetService passwordResetService; public ApplicationStartupService( ILogger logger, ILogger statsLogger, IOptions settings, ILogger cloudStorageLogger, - ILogger ratingsLogger) + ILogger ratingsLogger, + ILogger emailLogger, + ILogger passwordResetLogger) { this.logger = logger; var db = new DatabaseContext(settings); @@ -29,6 +32,8 @@ public ApplicationStartupService( cloudStorageService = new CloudStorageService(db, cloudStorageLogger); clientService = new ClientService(db); ratingsService = new RatingsService(ratingsLogger, db); + var emailService = new EmailService(settings, emailLogger); + passwordResetService = new PasswordResetService(db, accountService, emailService, settings, passwordResetLogger); } public async Task StartAsync(CancellationToken cancellationToken) @@ -37,6 +42,7 @@ public async Task StartAsync(CancellationToken cancellationToken) await accountService.CreateIndexesAsync(); await statisticsService.CreateIndexesAsync(); await ratingsService.CreateIndexesAsync(); + await passwordResetService.CreateIndexesAsync(); logger.LogInformation("Initializing MongoDB CloudStorage."); await cloudStorageService.EnsureSystemFilesExistAsync(); diff --git a/UT4MasterServer.Services/Scoped/EmailService.cs b/UT4MasterServer.Services/Scoped/EmailService.cs new file mode 100644 index 00000000..445fcb9e --- /dev/null +++ b/UT4MasterServer.Services/Scoped/EmailService.cs @@ -0,0 +1,92 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MimeKit; +using UT4MasterServer.Models.Settings; + +namespace UT4MasterServer.Services.Scoped; + +/// +/// Minimal SMTP email dispatcher. Used today by password reset; safe to +/// generalize to any transactional email. +/// +/// If is false, +/// becomes a no-op (returns false). Callers must NOT change their externally +/// observable behavior based on the return — the anti-enumeration guarantee +/// of forgot-password depends on the response being identical whether or +/// not an email actually fired. +/// +public sealed class EmailService +{ + private readonly ILogger logger; + private readonly MailSettings settings; + + public EmailService(IOptions appSettings, ILogger logger) + { + this.logger = logger; + this.settings = appSettings.Value.Mail; + } + + /// + /// Send a plaintext + HTML email. Returns true if SMTP accepted the + /// message, false if mail is disabled or sending failed. + /// + public async Task SendAsync( + string toAddress, + string subject, + string plainBody, + string htmlBody, + CancellationToken cancellationToken = default) + { + if (settings.LogToConsole) + { + logger.LogInformation( + "EmailService.SendAsync: to={To} subject={Subject}\n--- plain ---\n{Plain}\n--- html ---\n{Html}", + toAddress, subject, plainBody, htmlBody); + } + + if (!settings.IsConfigured) + { + logger.LogInformation("EmailService: skipping send to {To} — Mail.Host/Port not configured", toAddress); + return false; + } + + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(settings.FromName, settings.FromAddress)); + message.To.Add(MailboxAddress.Parse(toAddress)); + message.Subject = subject; + message.Body = new BodyBuilder + { + TextBody = plainBody, + HtmlBody = htmlBody, + }.ToMessageBody(); + + try + { + using var client = new SmtpClient(); + var sslOption = settings.UseSsl + ? SecureSocketOptions.SslOnConnect + : (settings.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.None); + + await client.ConnectAsync(settings.Host, settings.Port, sslOption, cancellationToken); + + if (!string.IsNullOrEmpty(settings.Username)) + { + await client.AuthenticateAsync(settings.Username, settings.Password, cancellationToken); + } + + await client.SendAsync(message, cancellationToken); + await client.DisconnectAsync(quit: true, cancellationToken); + logger.LogInformation("EmailService: delivered to={To} subject={Subject}", toAddress, subject); + return true; + } + catch (Exception ex) + { + // Swallow but record. The caller relies on us NOT throwing to keep + // the API response uniform regardless of SMTP outcome. + logger.LogWarning(ex, "EmailService: SMTP send failed to={To}", toAddress); + return false; + } + } +} diff --git a/UT4MasterServer.Services/Scoped/PasswordResetService.cs b/UT4MasterServer.Services/Scoped/PasswordResetService.cs new file mode 100644 index 00000000..fde713af --- /dev/null +++ b/UT4MasterServer.Services/Scoped/PasswordResetService.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MongoDB.Driver; +using UT4MasterServer.Common; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Models.Settings; + +namespace UT4MasterServer.Services.Scoped; + +/// +/// Issue + consume single-use password reset tokens. +/// Storage: mongo collection "password_reset_tokens" with TTL index on ExpiresAt. +/// +public sealed class PasswordResetService +{ + private static readonly TimeSpan TokenValidity = TimeSpan.FromHours(1); + + private readonly IMongoCollection collection; + private readonly AccountService accountService; + private readonly EmailService emailService; + private readonly ApplicationSettings appSettings; + private readonly ILogger logger; + + public PasswordResetService( + DatabaseContext db, + AccountService accountService, + EmailService emailService, + IOptions appSettings, + ILogger logger) + { + this.collection = db.Database.GetCollection("password_reset_tokens"); + this.accountService = accountService; + this.emailService = emailService; + this.appSettings = appSettings.Value; + this.logger = logger; + } + + /// + /// Ensures the mongo TTL index is present. Called once at app startup + /// from ApplicationStartupService alongside the other CreateIndexes calls. + /// + public async Task CreateIndexesAsync() + { + var indexes = new[] + { + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.ExpiresAt), + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.AccountID)), + }; + await collection.Indexes.CreateManyAsync(indexes); + } + + /// + /// Request a reset for the given email. Returns the issued token + /// AND the account, or null if no account matches the email. + /// CALLERS MUST NOT vary their response based on this returning null — + /// the anti-enumeration property of the forgot-password endpoint depends + /// on the response being identical either way. + /// + public async Task<(PasswordResetToken token, Account account)?> IssueAsync(string email) + { + var account = await accountService.GetAccountByEmailAsync(email); + if (account is null) return null; + + var token = GenerateTokenString(); + var doc = new PasswordResetToken(token, account.ID, TokenValidity); + await collection.InsertOneAsync(doc); + logger.LogInformation("PasswordResetService: issued token for {AccountId}", account.ID); + return (doc, account); + } + + /// + /// Send the reset email. Same anti-enumeration caveat as IssueAsync. + /// Builds the URL from . + /// + public async Task SendResetEmailAsync(Account account, string token, CancellationToken ct = default) + { + var resetUrl = $"{appSettings.WebsiteDomain.TrimEnd('/')}/reset-password?token={token}"; + var subject = "Reset your UT4 Master Server password"; + var plain = + $"Hi {account.Username},\n\n" + + $"A password reset was requested for your UT4 Master Server account.\n" + + $"Open this link within 1 hour to choose a new password:\n\n {resetUrl}\n\n" + + $"If you didn't request a reset, ignore this email — your password remains unchanged.\n"; + var html = + $"

Hi {System.Net.WebUtility.HtmlEncode(account.Username)},

" + + $"

A password reset was requested for your UT4 Master Server account.

" + + $"

Open this link within 1 hour to choose a new password:

" + + $"

{resetUrl}

" + + $"

If you didn't request a reset, ignore this email — your password remains unchanged.

"; + await emailService.SendAsync(account.Email, subject, plain, html, ct); + } + + /// + /// Look up + validate (not yet consumed, not expired) a reset token. + /// Returns null if not found / not valid. + /// + public async Task FindValidAsync(string token) + { + var doc = await collection.Find(x => x.Token == token).FirstOrDefaultAsync(); + if (doc is null || !doc.IsValid()) return null; + return doc; + } + + /// + /// Atomically mark the token as consumed. Returns true if this call + /// flipped Consumed from false→true (i.e. caller is the first to consume). + /// + public async Task ConsumeAsync(string token) + { + var update = Builders.Update.Set(x => x.Consumed, true); + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Token, token), + Builders.Filter.Eq(x => x.Consumed, false), + Builders.Filter.Gt(x => x.ExpiresAt, DateTime.UtcNow)); + var result = await collection.UpdateOneAsync(filter, update); + return result.ModifiedCount == 1; + } + + private static string GenerateTokenString() + { + Span buf = stackalloc byte[32]; + RandomNumberGenerator.Fill(buf); + return Convert.ToHexString(buf).ToLowerInvariant(); + } +} diff --git a/UT4MasterServer.Services/UT4MasterServer.Services.csproj b/UT4MasterServer.Services/UT4MasterServer.Services.csproj index 33e9edf4..285d1f29 100644 --- a/UT4MasterServer.Services/UT4MasterServer.Services.csproj +++ b/UT4MasterServer.Services/UT4MasterServer.Services.csproj @@ -9,6 +9,7 @@ + diff --git a/UT4MasterServer.Web/src/pages/ForgotPassword.vue b/UT4MasterServer.Web/src/pages/ForgotPassword.vue new file mode 100644 index 00000000..25d616b9 --- /dev/null +++ b/UT4MasterServer.Web/src/pages/ForgotPassword.vue @@ -0,0 +1,68 @@ + + + diff --git a/UT4MasterServer.Web/src/pages/Login.vue b/UT4MasterServer.Web/src/pages/Login.vue index f5fea4a9..2a4efc49 100644 --- a/UT4MasterServer.Web/src/pages/Login.vue +++ b/UT4MasterServer.Web/src/pages/Login.vue @@ -73,6 +73,8 @@ Create an account + · + Forgot password? diff --git a/UT4MasterServer.Web/src/routes.ts b/UT4MasterServer.Web/src/routes.ts index 41b0fd29..60e0da42 100644 --- a/UT4MasterServer.Web/src/routes.ts +++ b/UT4MasterServer.Web/src/routes.ts @@ -103,6 +103,16 @@ export const routes: RouteRecordRaw[] = [ component: async () => import('./pages/Login.vue'), beforeEnter: publicGuard }, + { + path: `/ForgotPassword`, + component: async () => import('./pages/ForgotPassword.vue'), + beforeEnter: publicGuard + }, + { + path: `/reset-password`, + 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..43540446 100644 --- a/UT4MasterServer.Web/src/services/account.service.ts +++ b/UT4MasterServer.Web/src/services/account.service.ts @@ -39,6 +39,20 @@ export default class AccountService extends HttpService { ); } + async forgotPassword(email: string) { + return await this.post( + `${this.baseUrl}/forgot-password`, + { body: { email } } + ); + } + + async resetPassword(token: string, newPassword: string) { + return await this.post( + `${this.baseUrl}/reset-password`, + { body: { token, newPassword } } + ); + } + async getAccount(id: string) { return await this.get( `${this.personaBaseUrl}/account/${id}` diff --git a/UT4MasterServer/Controllers/Epic/AccountController.cs b/UT4MasterServer/Controllers/Epic/AccountController.cs index 98093a11..05f897f0 100644 --- a/UT4MasterServer/Controllers/Epic/AccountController.cs +++ b/UT4MasterServer/Controllers/Epic/AccountController.cs @@ -24,12 +24,19 @@ public sealed class AccountController : JsonAPIController { private readonly SessionService sessionService; private readonly AccountService accountService; + private readonly PasswordResetService passwordResetService; private readonly IOptions reCaptchaSettings; - public AccountController(ILogger logger, AccountService accountService, SessionService sessionService, IOptions reCaptchaSettings) : base(logger) + public AccountController( + ILogger logger, + AccountService accountService, + SessionService sessionService, + PasswordResetService passwordResetService, + IOptions reCaptchaSettings) : base(logger) { this.accountService = accountService; this.sessionService = sessionService; + this.passwordResetService = passwordResetService; this.reCaptchaSettings = reCaptchaSettings; } @@ -377,5 +384,73 @@ public async Task UpdatePassword([FromForm] string currentPasswor return Ok("Changed password successfully"); } + [HttpPost("forgot-password")] + [AllowAnonymous] + public async Task ForgotPassword([FromForm] string email) + { + // Anti-enumeration: this endpoint MUST return the same shape regardless + // of whether the email is registered. Don't 404, don't differentiate the + // response body. Always 200 "ok". + if (string.IsNullOrWhiteSpace(email)) + { + return Ok("If that email matches an account, a reset link has been sent."); + } + + try + { + var issued = await passwordResetService.IssueAsync(email); + if (issued is not null) + { + var (resetToken, account) = issued.Value; + // Send is best-effort: failure logged inside EmailService, response + // stays identical to the no-account-found path. + _ = passwordResetService.SendResetEmailAsync(account, resetToken.Token); + } + } + catch (Exception ex) + { + // Swallow logged — the response surface must remain uniform. + logger.LogWarning(ex, "ForgotPassword: internal error processing email={Email}", email); + } + + return Ok("If that email matches an account, a reset link has been sent."); + } + + [HttpPost("reset-password")] + [AllowAnonymous] + public async Task ResetPassword( + [FromForm] string token, + [FromForm] string newPassword) + { + if (string.IsNullOrWhiteSpace(token)) + { + return BadRequest(new ErrorResponse { ErrorMessage = "Token is required" }); + } + + if (!ValidationHelper.ValidatePassword(newPassword)) + { + return BadRequest(new ErrorResponse { ErrorMessage = "Unexpected password format" }); + } + + var valid = await passwordResetService.FindValidAsync(token); + if (valid is null) + { + return BadRequest(new ErrorResponse { ErrorMessage = "Reset link is invalid or expired" }); + } + + // Atomically mark token consumed — guards against the same token being + // used twice by concurrent requests. + if (!await passwordResetService.ConsumeAsync(token)) + { + return BadRequest(new ErrorResponse { ErrorMessage = "Reset link is invalid or expired" }); + } + + await accountService.UpdateAccountPasswordAsync(valid.AccountID, newPassword); + await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, valid.AccountID, EpicID.Empty); + + logger.LogInformation("Password reset completed for account {AccountID}", valid.AccountID); + return Ok("Password reset successfully"); + } + #endregion } diff --git a/UT4MasterServer/Program.cs b/UT4MasterServer/Program.cs index 4f8b9e69..c7df9f42 100644 --- a/UT4MasterServer/Program.cs +++ b/UT4MasterServer/Program.cs @@ -115,7 +115,9 @@ public static void Main(string[] args) .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped() + .AddScoped(); // services whose instance is created once and are persistent builder.Services From e9b101bbbef9badfeb4d57112ecc696d8641c7ee Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Mon, 15 Jun 2026 13:59:58 -0500 Subject: [PATCH 02/18] chore: drop temporary auth debug logging from SessionController --- .../Controllers/Epic/SessionController.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/UT4MasterServer/Controllers/Epic/SessionController.cs b/UT4MasterServer/Controllers/Epic/SessionController.cs index 417c33f4..d1f7a084 100644 --- a/UT4MasterServer/Controllers/Epic/SessionController.cs +++ b/UT4MasterServer/Controllers/Epic/SessionController.cs @@ -142,7 +142,27 @@ public async Task Authenticate( return ErrorInvalidRequest("password"); } + // TEMP DEBUG: log the auth attempt details so we can diagnose + // game-client format mismatches. Remove before merging. + logger.LogInformation( + "DEBUG auth attempt: clientID={ClientID} username='{Username}' pwLen={PwLen} pwHead='{PwHead}' pwTail='{PwTail}'", + clientID, username, password?.Length ?? -1, + password is null ? "" : password.Substring(0, Math.Min(8, password.Length)), + password is null ? "" : password.Substring(Math.Max(0, password.Length - 8))); + account = await accountService.GetAccountUsernameOrEmailAsync(username); + if (account is null) + { + logger.LogInformation("DEBUG: no account found for '{Username}'", username); + } + else + { + var checkResult = account.CheckPassword(password, allowPasswordGrant); + logger.LogInformation( + "DEBUG: account found id={AccountID} email={Email} CheckPassword={Result}", + account.ID, account.Email, checkResult); + } + if (account != null && account.CheckPassword(password, allowPasswordGrant)) { session = await sessionService.CreateSessionAsync(account.ID, clientID, SessionCreationMethod.Password); From 7bedc461f3c7b6a69357069b20a535509fecd416 Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Mon, 15 Jun 2026 14:00:30 -0500 Subject: [PATCH 03/18] chore: actually remove temporary auth debug logging The previous chore commit accidentally added the debug logging instead of removing it. This commit deletes the DEBUG-prefixed lines from the password grant path. --- .../Controllers/Epic/SessionController.cs | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/UT4MasterServer/Controllers/Epic/SessionController.cs b/UT4MasterServer/Controllers/Epic/SessionController.cs index d1f7a084..417c33f4 100644 --- a/UT4MasterServer/Controllers/Epic/SessionController.cs +++ b/UT4MasterServer/Controllers/Epic/SessionController.cs @@ -142,27 +142,7 @@ public async Task Authenticate( return ErrorInvalidRequest("password"); } - // TEMP DEBUG: log the auth attempt details so we can diagnose - // game-client format mismatches. Remove before merging. - logger.LogInformation( - "DEBUG auth attempt: clientID={ClientID} username='{Username}' pwLen={PwLen} pwHead='{PwHead}' pwTail='{PwTail}'", - clientID, username, password?.Length ?? -1, - password is null ? "" : password.Substring(0, Math.Min(8, password.Length)), - password is null ? "" : password.Substring(Math.Max(0, password.Length - 8))); - account = await accountService.GetAccountUsernameOrEmailAsync(username); - if (account is null) - { - logger.LogInformation("DEBUG: no account found for '{Username}'", username); - } - else - { - var checkResult = account.CheckPassword(password, allowPasswordGrant); - logger.LogInformation( - "DEBUG: account found id={AccountID} email={Email} CheckPassword={Result}", - account.ID, account.Email, checkResult); - } - if (account != null && account.CheckPassword(password, allowPasswordGrant)) { session = await sessionService.CreateSessionAsync(account.ID, clientID, SessionCreationMethod.Password); From 0c1220b99a504a6d4dda307a7a0f2ac65a4f16ae Mon Sep 17 00:00:00 2001 From: Lucas Pick Date: Mon, 15 Jun 2026 14:31:51 -0500 Subject: [PATCH 04/18] feat(admin): inline JSON editor for cloud files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CloudFiles admin page only supported uploading a whole file via a file-picker. For the ⓘ-icon-driven MCP files (announcement, news, storage, playlists, rulesets) admins almost always want to tweak a single string or array entry, not reupload a fresh JSON file. That made any minor announcement change a multi-step download/edit/upload ceremony. Add an inline editor: - AdminService.getCloudFileText(filename) pulls the current bytes as text via the existing admin/mcp_files/{filename} endpoint. - EditCloudFile.vue gets a two-tab UX: 'Edit contents' (default) and 'Upload file' (existing behaviour). The edit tab preloads the current contents into a monospace textarea, surfaces inline JSON validation errors, and offers a 'Pretty-print JSON' button. - Submit uses a Blob with the original filename so the existing upsertCloudFile pipeline is unchanged. Non-JSON files still save fine: the validator only warns; the server never required JSON. Verified: vue-tsc --noEmit clean; vite production build clean. --- .../CloudFiles/components/EditCloudFile.vue | 125 ++++++++++++++++-- .../src/services/admin.service.ts | 21 ++- 2 files changed, 135 insertions(+), 11 deletions(-) diff --git a/UT4MasterServer.Web/src/pages/Admin/CloudFiles/components/EditCloudFile.vue b/UT4MasterServer.Web/src/pages/Admin/CloudFiles/components/EditCloudFile.vue index 6bb39131..51cb8204 100644 --- a/UT4MasterServer.Web/src/pages/Admin/CloudFiles/components/EditCloudFile.vue +++ b/UT4MasterServer.Web/src/pages/Admin/CloudFiles/components/EditCloudFile.vue @@ -8,12 +8,73 @@
Update Cloud File

- Note: The filename will be - {{ file?.filename }} regardless of the name of the - uploaded file. + Editing + {{ file?.filename }} + — the filename on the server is preserved regardless of how the + contents are supplied.

-
- + + + +
+
+