From 3a530b6639c79113e4e2aa38b8cb7efb49425dbd Mon Sep 17 00:00:00 2001 From: Moonbase Date: Sun, 27 Jul 2025 12:58:44 -0700 Subject: [PATCH] Implement Password Reset --- qtc-net-server/Controllers/AuthController.cs | 113 +++++++++++++++++- .../Services/EmailService/EmailService.cs | 61 +++++++++- .../Services/EmailService/IEmailService.cs | 1 + .../Services/TokenService/ITokenService.cs | 1 + .../Services/TokenService/TokenService.cs | 34 ++++++ 5 files changed, 207 insertions(+), 3 deletions(-) diff --git a/qtc-net-server/Controllers/AuthController.cs b/qtc-net-server/Controllers/AuthController.cs index 1e476d5..71d482c 100644 --- a/qtc-net-server/Controllers/AuthController.cs +++ b/qtc-net-server/Controllers/AuthController.cs @@ -138,6 +138,70 @@ namespace qtc_api.Controllers return Ok(new ServiceResponse { Success = false }); } + [HttpPost("request-password-reset")] + public async Task>> SendPasswordResetEmail(string email) + { + var user = await _userService.GetUserByEmail(email); + if (user != null && user.Success && user.Data != null) + { + var resetToken = _tokenService.GenerateEmailConfirmationToken(user.Data); // we can probably use the same JWT structure for password resets + var resetUrl = $"{Request.Scheme}://{Request.Host}/api/auth/start-password-reset?token={resetToken.Data}"; + + await _emailService.SendPasswordResetEmail(user.Data.Email, user.Data.Username, resetUrl); + + return Ok(new ServiceResponse { Success = true, Data = true }); + } + + return Ok(new ServiceResponse { Success = false }); + } + + [HttpPost("reset-password")] + public async Task>> ResetPassword(string confirmationToken, string password) + { + try + { + var handler = new JwtSecurityTokenHandler() + { + InboundClaimTypeMap = new Dictionary() + }; + + var jwt = handler.ReadJwtToken(confirmationToken); + + if (jwt != null) + { + var email = jwt.Claims.FirstOrDefault(e => e.Type == ClaimTypes.Email); + var id = jwt.Claims.FirstOrDefault(e => e.Type == ClaimTypes.NameIdentifier); + + if (email != null && id != null) + { + // get the user from id claim + var user = await _userService.GetUserById(id.Value); + if (user != null && user.Success && user.Data != null) + { + var now = DateTime.UtcNow; + if (user.Data.Email == email.Value && now < jwt.ValidTo.ToUniversalTime()) + { + user.Data.PasswordHash = BCrypt.Net.BCrypt.HashPassword(password); + await dataContext.SaveChangesAsync(); + + return Ok(new ServiceResponse { Success = true, Data = true }); + } + } + else + { + return Ok(new ServiceResponse { Success = false, Message = "The User This Reset Token Is Associated With No Longer Exists." }); + } + } + } + + return Ok(new ServiceResponse { Success = false, Message = "Token Invalid. You may need to be sent another reset link." }); + } + catch (SecurityTokenMalformedException) + { + return Ok(new ServiceResponse { Success = false, Message = "Token Invalid. You may need to be sent another reset link." }); + } + } + [HttpGet("verify-email")] public async Task> VerifyEmail(string token) { @@ -176,10 +240,55 @@ namespace qtc_api.Controllers } } - return Ok("Token Invalid. You may need to be sent another confirmation link."); + return Ok("Token Invalid. You may need to be sent another confirmation link. You can do this by clicking 'Resend Verification Email' in the client."); } catch (SecurityTokenMalformedException) { - return Ok("Token Invalid. You may need to be sent another confirmation link."); + return Ok("Token Invalid. You may need to be sent another confirmation link. You can do this by clicking 'Resend Verification Email' in the client."); + } + } + + [HttpGet("start-password-reset")] + public async Task> StartPasswordReset(string token) + { + try + { + var handler = new JwtSecurityTokenHandler() + { + InboundClaimTypeMap = new Dictionary() + }; + + var jwt = handler.ReadJwtToken(token); + + if (jwt != null) + { + var email = jwt.Claims.FirstOrDefault(e => e.Type == ClaimTypes.Email); + var id = jwt.Claims.FirstOrDefault(e => e.Type == ClaimTypes.NameIdentifier); + + if (email != null && id != null) + { + // get the user from id claim + var user = await _userService.GetUserById(id.Value); + if (user != null && user.Success && user.Data != null) + { + var now = DateTime.UtcNow; + if (user.Data.Email == email.Value && now < jwt.ValidTo.ToUniversalTime()) + { + var clientToken = _tokenService.GeneratePasswordResetConfirmationToken(user.Data); + return Ok($"To Reset Your Password, Paste The Following Code Into Your Client And Enter A New Password\n{clientToken.Data}\n\nNOTE: This code is only valid for one hour."); + } + } + else + { + return Ok("The User This Reset Link Is Associated With No Longer Exists."); + } + } + } + + return Ok("Token Invalid. You may need to be sent another reset link."); + } + catch (SecurityTokenMalformedException) + { + return Ok("Token Invalid. You may need to be sent another reset link."); } } } diff --git a/qtc-net-server/Services/EmailService/EmailService.cs b/qtc-net-server/Services/EmailService/EmailService.cs index 1413950..3db6dc3 100644 --- a/qtc-net-server/Services/EmailService/EmailService.cs +++ b/qtc-net-server/Services/EmailService/EmailService.cs @@ -38,7 +38,7 @@ namespace qtc_api.Services.EmailService // build confirmation email body StringBuilder emailBody = new(); - emailBody.AppendLine($"

Hello {name},

"); + emailBody.AppendLine($"

Hello {name},

"); emailBody.AppendLine("

Your receiving this message because you made a QtC.NET Account on a server that requires email confirmation.
"); emailBody.AppendLine(@$"You can confirm your account by clicking here.
"); emailBody.AppendLine("NOTE: This Link Is Only Valid For 24 Hours.

"); @@ -71,5 +71,64 @@ namespace qtc_api.Services.EmailService // disconnect await client.DisconnectAsync(true); } + + public async Task SendPasswordResetEmail(string email, string name, string passwordResetUrl) + { + // get config entries + bool confirmationRequired = _configuration.GetValue("EmailConfig:EmailConfirmationRequired"); + string? host = _configuration.GetValue("EmailConfig:SMTPServer"); + string? username = _configuration.GetValue("EmailConfig:SMTPUsername"); + string? password = _configuration.GetValue("EmailConfig:SMTPPassword"); + string? senderAddress = _configuration.GetValue("EmailConfig:SMTPSenderAddress"); + + if (!confirmationRequired) + { + _logger.LogInformation("Email Confirmation Is Disabled. For Better Security Of Your Instance, Consider Setting Up An Email Sender Service."); + return; + } + else if (host == null || username == null || password == null || senderAddress == null) + { + _logger.LogInformation("Email Confirmation Is Enabled But No SMTP Settings Were Set."); + return; + } + + // set email subject + string emailSubject = "QtC.NET Email Confirmation"; + + // build confirmation email body + StringBuilder emailBody = new(); + emailBody.AppendLine($"

Hello {name},

"); + emailBody.AppendLine("

Your receiving this message because you requested a password reset on a QtC.NET Account.
"); + emailBody.AppendLine(@$"You can reset your password by clicking here.
"); + emailBody.AppendLine("NOTE: This Link Is Only Valid For 24 Hours.

"); + emailBody.AppendLine("If you did not request a password reset on any QtC.NET account on any server, you may simply ignore this email.

"); + + // create new client + using var client = new SmtpClient() + { + RequireTLS = true + }; + + // connect and authenticate + await client.ConnectAsync(host, 587); + await client.AuthenticateAsync(username, password); + + // construct email + using var message = new MimeMessage(); + message.To.Add(new MailboxAddress(name, email)); + message.From.Add(new MailboxAddress("QtC.NET Server", senderAddress)); + + message.Subject = emailSubject; + message.Body = new TextPart(MimeKit.Text.TextFormat.Html) + { + Text = emailBody.ToString() + }; + + // send email + await client.SendAsync(message); + + // disconnect + await client.DisconnectAsync(true); + } } } diff --git a/qtc-net-server/Services/EmailService/IEmailService.cs b/qtc-net-server/Services/EmailService/IEmailService.cs index fd4d83e..015da9b 100644 --- a/qtc-net-server/Services/EmailService/IEmailService.cs +++ b/qtc-net-server/Services/EmailService/IEmailService.cs @@ -3,5 +3,6 @@ public interface IEmailService { public Task SendConfirmationEmail(string email, string name, string confirmUrl); + public Task SendPasswordResetEmail(string email, string name, string passwordResetUrl); } } diff --git a/qtc-net-server/Services/TokenService/ITokenService.cs b/qtc-net-server/Services/TokenService/ITokenService.cs index bb17f87..df7b1ed 100644 --- a/qtc-net-server/Services/TokenService/ITokenService.cs +++ b/qtc-net-server/Services/TokenService/ITokenService.cs @@ -4,6 +4,7 @@ { public Task> GenerateAccessTokenAndRefreshToken(User user, bool generateRefToken = true, bool remember = false); public ServiceResponse GenerateEmailConfirmationToken(User user); + public ServiceResponse GeneratePasswordResetConfirmationToken(User user); public Task> ValidateAccessToken(string accessToken); public Task> ValidateRefreshToken(string refreshToken); public ServiceResponse GetValidationParams(); diff --git a/qtc-net-server/Services/TokenService/TokenService.cs b/qtc-net-server/Services/TokenService/TokenService.cs index 89213c0..1421bd5 100644 --- a/qtc-net-server/Services/TokenService/TokenService.cs +++ b/qtc-net-server/Services/TokenService/TokenService.cs @@ -112,6 +112,40 @@ namespace qtc_api.Services.TokenService return serviceResponse; } + public ServiceResponse GeneratePasswordResetConfirmationToken(User user) + { + var serviceResponse = new ServiceResponse(); + + // Generate JWT Access Token + + List claims = new List() + { + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Email, user.Email) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration.GetSection("Jwt:Key").Value ?? Environment.GetEnvironmentVariable("JWT_KEY")!)); + var issuer = _configuration["Jwt:Issuer"]; + var audience = _configuration["Jwt:Audience"]; + + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: DateTime.UtcNow.AddHours(1), + signingCredentials: creds + ); + + var jwt = new JwtSecurityTokenHandler().WriteToken(token); + + serviceResponse.Success = true; + serviceResponse.Data = jwt; + + return serviceResponse; + } + public async Task> ValidateRefreshToken(string refreshToken) { var serviceResponse = new ServiceResponse();