Implement Password Reset

This commit is contained in:
Alan Moon 2025-07-27 12:58:44 -07:00
parent 08fb330de3
commit 3a530b6639
5 changed files with 207 additions and 3 deletions

View File

@ -138,6 +138,70 @@ namespace qtc_api.Controllers
return Ok(new ServiceResponse<bool> { Success = false }); return Ok(new ServiceResponse<bool> { Success = false });
} }
[HttpPost("request-password-reset")]
public async Task<ActionResult<ServiceResponse<bool>>> 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<bool> { Success = true, Data = true });
}
return Ok(new ServiceResponse<bool> { Success = false });
}
[HttpPost("reset-password")]
public async Task<ActionResult<ServiceResponse<bool>>> ResetPassword(string confirmationToken, string password)
{
try
{
var handler = new JwtSecurityTokenHandler()
{
InboundClaimTypeMap = new Dictionary<string, string>()
};
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<bool> { Success = true, Data = true });
}
}
else
{
return Ok(new ServiceResponse<bool> { Success = false, Message = "The User This Reset Token Is Associated With No Longer Exists." });
}
}
}
return Ok(new ServiceResponse<bool> { Success = false, Message = "Token Invalid. You may need to be sent another reset link." });
}
catch (SecurityTokenMalformedException)
{
return Ok(new ServiceResponse<bool> { Success = false, Message = "Token Invalid. You may need to be sent another reset link." });
}
}
[HttpGet("verify-email")] [HttpGet("verify-email")]
public async Task<ActionResult<string>> VerifyEmail(string token) public async Task<ActionResult<string>> 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) } 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<ActionResult<string>> StartPasswordReset(string token)
{
try
{
var handler = new JwtSecurityTokenHandler()
{
InboundClaimTypeMap = new Dictionary<string, string>()
};
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.");
} }
} }
} }

View File

@ -38,7 +38,7 @@ namespace qtc_api.Services.EmailService
// build confirmation email body // build confirmation email body
StringBuilder emailBody = new(); StringBuilder emailBody = new();
emailBody.AppendLine($"<h1>Hello {name},</h1>"); emailBody.AppendLine($"<h2>Hello {name},</h2>");
emailBody.AppendLine("<p>Your receiving this message because you made a QtC.NET Account on a server that requires email confirmation.<br>"); emailBody.AppendLine("<p>Your receiving this message because you made a QtC.NET Account on a server that requires email confirmation.<br>");
emailBody.AppendLine(@$"You can confirm your account by clicking <a href=""{confirmUrl}"">here.</a><br>"); emailBody.AppendLine(@$"You can confirm your account by clicking <a href=""{confirmUrl}"">here.</a><br>");
emailBody.AppendLine("NOTE: This Link Is Only Valid For 24 Hours.<br><br>"); emailBody.AppendLine("NOTE: This Link Is Only Valid For 24 Hours.<br><br>");
@ -71,5 +71,64 @@ namespace qtc_api.Services.EmailService
// disconnect // disconnect
await client.DisconnectAsync(true); await client.DisconnectAsync(true);
} }
public async Task SendPasswordResetEmail(string email, string name, string passwordResetUrl)
{
// get config entries
bool confirmationRequired = _configuration.GetValue<bool>("EmailConfig:EmailConfirmationRequired");
string? host = _configuration.GetValue<string>("EmailConfig:SMTPServer");
string? username = _configuration.GetValue<string>("EmailConfig:SMTPUsername");
string? password = _configuration.GetValue<string>("EmailConfig:SMTPPassword");
string? senderAddress = _configuration.GetValue<string>("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($"<h2>Hello {name},</h2>");
emailBody.AppendLine("<p>Your receiving this message because you requested a password reset on a QtC.NET Account.<br>");
emailBody.AppendLine(@$"You can reset your password by clicking <a href=""{passwordResetUrl}"">here.</a><br>");
emailBody.AppendLine("NOTE: This Link Is Only Valid For 24 Hours.<br><br>");
emailBody.AppendLine("If you did not request a password reset on any QtC.NET account on any server, you may simply ignore this email.</p>");
// 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);
}
} }
} }

View File

@ -3,5 +3,6 @@
public interface IEmailService public interface IEmailService
{ {
public Task SendConfirmationEmail(string email, string name, string confirmUrl); public Task SendConfirmationEmail(string email, string name, string confirmUrl);
public Task SendPasswordResetEmail(string email, string name, string passwordResetUrl);
} }
} }

View File

@ -4,6 +4,7 @@
{ {
public Task<ServiceResponse<string>> GenerateAccessTokenAndRefreshToken(User user, bool generateRefToken = true, bool remember = false); public Task<ServiceResponse<string>> GenerateAccessTokenAndRefreshToken(User user, bool generateRefToken = true, bool remember = false);
public ServiceResponse<string> GenerateEmailConfirmationToken(User user); public ServiceResponse<string> GenerateEmailConfirmationToken(User user);
public ServiceResponse<string> GeneratePasswordResetConfirmationToken(User user);
public Task<ServiceResponse<bool>> ValidateAccessToken(string accessToken); public Task<ServiceResponse<bool>> ValidateAccessToken(string accessToken);
public Task<ServiceResponse<string>> ValidateRefreshToken(string refreshToken); public Task<ServiceResponse<string>> ValidateRefreshToken(string refreshToken);
public ServiceResponse<TokenValidationParameters> GetValidationParams(); public ServiceResponse<TokenValidationParameters> GetValidationParams();

View File

@ -112,6 +112,40 @@ namespace qtc_api.Services.TokenService
return serviceResponse; return serviceResponse;
} }
public ServiceResponse<string> GeneratePasswordResetConfirmationToken(User user)
{
var serviceResponse = new ServiceResponse<string>();
// Generate JWT Access Token
List<Claim> claims = new List<Claim>()
{
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<ServiceResponse<string>> ValidateRefreshToken(string refreshToken) public async Task<ServiceResponse<string>> ValidateRefreshToken(string refreshToken)
{ {
var serviceResponse = new ServiceResponse<string>(); var serviceResponse = new ServiceResponse<string>();