From 5abc3d822135f380b706d672d6b56d15f44c2bc3 Mon Sep 17 00:00:00 2001 From: Moonbase Date: Sat, 26 Jul 2025 13:09:19 -0700 Subject: [PATCH 1/6] Initial Implementation Of Email Verification --- qtc-net-server/Models/User.cs | 1 + qtc-net-server/Program.cs | 4 + .../Services/EmailService/EmailService.cs | 75 +++++++++++++++++++ .../Services/EmailService/IEmailService.cs | 7 ++ qtc-net-server/appsettings.json | 7 ++ qtc-net-server/qtc-net-server.csproj | 25 ++++--- 6 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 qtc-net-server/Services/EmailService/EmailService.cs create mode 100644 qtc-net-server/Services/EmailService/IEmailService.cs diff --git a/qtc-net-server/Models/User.cs b/qtc-net-server/Models/User.cs index 04f39e0..acd63e9 100644 --- a/qtc-net-server/Models/User.cs +++ b/qtc-net-server/Models/User.cs @@ -9,6 +9,7 @@ public string Role { get; set; } = string.Empty; public string PasswordHash { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; + public bool IsEmailVerified { get; set; } = false; public DateTime DateOfBirth { get; set; } public DateTime CreatedAt { get; set; } public int Status { get; set; } = 0; diff --git a/qtc-net-server/Program.cs b/qtc-net-server/Program.cs index 3490c2b..9ff283a 100644 --- a/qtc-net-server/Program.cs +++ b/qtc-net-server/Program.cs @@ -17,6 +17,7 @@ using qtc_api.Services.ContactService; using qtc_api.Services.CurrencyGamesService; using qtc_api.Services.GameRoomService; using qtc_api.Services.StoreService; +using qtc_api.Services.EmailService; var builder = WebApplication.CreateBuilder(args); @@ -55,6 +56,9 @@ builder.Services.AddAuthentication().AddJwtBearer(options => options.TokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); else throw new Exception("Cannot Find Environment Variables 'JWT_KEY'. Please Check Environment."); }); + +builder.Services.AddTransient(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/qtc-net-server/Services/EmailService/EmailService.cs b/qtc-net-server/Services/EmailService/EmailService.cs new file mode 100644 index 0000000..2df077f --- /dev/null +++ b/qtc-net-server/Services/EmailService/EmailService.cs @@ -0,0 +1,75 @@ + +using MailKit.Net.Smtp; +using MimeKit; + +namespace qtc_api.Services.EmailService +{ + public class EmailService : IEmailService + { + private IConfiguration _configuration; + private ILogger _logger; + public EmailService(IConfiguration configuration, ILogger logger) + { + _configuration = configuration; + _logger = logger; + } + + public async Task SendConfirmationEmail(string email, string name, string confirmUrl) + { + // 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 StringBuilder(); + emailBody.AppendLine("Hello! This email was used to create an account on a QtC.NET Server."); + emailBody.AppendLine(); + emailBody.AppendLine($"You can confirm your email by clicking here - {confirmUrl}"); + emailBody.AppendLine(); + emailBody.AppendLine("If you did not create a 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, true); + 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.Plain) + { + 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 new file mode 100644 index 0000000..fd4d83e --- /dev/null +++ b/qtc-net-server/Services/EmailService/IEmailService.cs @@ -0,0 +1,7 @@ +namespace qtc_api.Services.EmailService +{ + public interface IEmailService + { + public Task SendConfirmationEmail(string email, string name, string confirmUrl); + } +} diff --git a/qtc-net-server/appsettings.json b/qtc-net-server/appsettings.json index 935f72c..3fe0b91 100644 --- a/qtc-net-server/appsettings.json +++ b/qtc-net-server/appsettings.json @@ -7,6 +7,13 @@ "GeneralConfig": { "CDNPath": "./user-content" }, + "EmailConfig": { + "EmailConfirmationRequired": false, + "SMTPServer": "", + "SMTPUsername": "api", + "SMTPPassword": "", + "SMTPSenderAddress": "" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/qtc-net-server/qtc-net-server.csproj b/qtc-net-server/qtc-net-server.csproj index 6488c48..e80f137 100644 --- a/qtc-net-server/qtc-net-server.csproj +++ b/qtc-net-server/qtc-net-server.csproj @@ -11,23 +11,24 @@ - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - - - + + + - + -- 2.47.2 From 4085267499cae06a60a3ffaeb9e16909e8076137 Mon Sep 17 00:00:00 2001 From: Moonbase Date: Sat, 26 Jul 2025 14:35:49 -0700 Subject: [PATCH 2/6] Implement Email Verification In Auth Flow --- qtc-net-server/Controllers/AuthController.cs | 70 ++++++++++++++++++- qtc-net-server/Program.cs | 1 - .../Services/EmailService/EmailService.cs | 2 +- .../Services/TokenService/ITokenService.cs | 1 + .../Services/TokenService/TokenService.cs | 34 +++++++++ .../Services/UserService/UserService.cs | 4 +- 6 files changed, 107 insertions(+), 5 deletions(-) diff --git a/qtc-net-server/Controllers/AuthController.cs b/qtc-net-server/Controllers/AuthController.cs index 3587482..bdb69d0 100644 --- a/qtc-net-server/Controllers/AuthController.cs +++ b/qtc-net-server/Controllers/AuthController.cs @@ -3,8 +3,12 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Razor.TagHelpers; using qtc_api.Dtos.User; +using qtc_api.Services.EmailService; +using System.IdentityModel.Tokens.Jwt; using System.Runtime.CompilerServices; +using System.Security.Claims; using System.Text.Json; +using ZstdSharp.Unsafe; namespace qtc_api.Controllers { @@ -13,17 +17,21 @@ namespace qtc_api.Controllers public class AuthController : ControllerBase { private readonly IUserService _userService; + private readonly IEmailService _emailService; private readonly ITokenService _tokenService; private readonly IHubContext _chatGWContext; + private readonly IConfiguration _configuration; private readonly ServerConfig serverConfig; private readonly DataContext dataContext; - public AuthController(IUserService userService, ITokenService tokenService, IHubContext chatGWContext, DataContext dataContext) + public AuthController(IUserService userService, ITokenService tokenService, IHubContext chatGWContext, DataContext dataContext, IConfiguration configuration, IEmailService emailService) { _userService = userService; _tokenService = tokenService; _chatGWContext = chatGWContext; + _configuration = configuration; + _emailService = emailService; serverConfig = JsonSerializer.Deserialize(JsonDocument.Parse(System.IO.File.ReadAllText("./ServerConfig.json"))); this.dataContext = dataContext; @@ -36,8 +44,14 @@ namespace qtc_api.Controllers { var response = await _userService.AddUser(userDto); await _chatGWContext.Clients.All.SendAsync("RefreshUserLists"); - if(response.Success != false) + if(response.Success != false && response.Data != null) { + // send confirmation email (shouldn't do anything if email confirmation is disabled) + var confirmationToken = _tokenService.GenerateEmailConfirmationToken(response.Data); + var confirmationUrl = $"{Request.Scheme}://{Request.Host}/api/auth/verify-email?token={confirmationToken}"; + + await _emailService.SendConfirmationEmail(response.Data.Email, response.Data.Username, confirmationUrl); + return Ok(response); } else { @@ -70,6 +84,15 @@ namespace qtc_api.Controllers }); } + if(!dbUser.Data.IsEmailVerified && _configuration.GetValue("EmailConfig:EmailConfirmationRequired")) + { + return Ok(new ServiceResponse + { + Message = "You need to verify your email on this server. Check your inbox or spam. If you have not received an email, click 'Resend Verification Email'", + Success = false + }); + } + if (dbUser.Data.Id == serverConfig.AdminUserId && dbUser.Data.Role != "Admin") { dbUser.Data.Role = "Admin"; @@ -96,5 +119,48 @@ namespace qtc_api.Controllers var response = await _tokenService.ValidateRefreshToken(token); return Ok(response); } + + [HttpPost("verify-email")] + public async Task> VerifyEmail(string token) + { + try + { + var handler = new JwtSecurityTokenHandler(); + handler.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 && jwt.ValidTo.ToUniversalTime() < now) + { + user.Data.IsEmailVerified = true; + await dataContext.SaveChangesAsync(); + + return Ok("Email Verified! You may now login on the client."); + } + } else + { + return Ok("The User This Confirmation Link Is Associated With No Longer Exists."); + } + } + } + + return Ok("Token Invalid. You may need to be sent another confirmation link."); + } catch (SecurityTokenMalformedException) + { + return Ok("Token Invalid. You may need to be sent another confirmation link."); + } + } } } diff --git a/qtc-net-server/Program.cs b/qtc-net-server/Program.cs index 9ff283a..ef1c2fd 100644 --- a/qtc-net-server/Program.cs +++ b/qtc-net-server/Program.cs @@ -24,7 +24,6 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddDbContext(); -builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSignalR(); builder.Services.AddStackExchangeRedisCache(options => diff --git a/qtc-net-server/Services/EmailService/EmailService.cs b/qtc-net-server/Services/EmailService/EmailService.cs index 2df077f..3072e04 100644 --- a/qtc-net-server/Services/EmailService/EmailService.cs +++ b/qtc-net-server/Services/EmailService/EmailService.cs @@ -37,7 +37,7 @@ namespace qtc_api.Services.EmailService string emailSubject = "QtC.NET Email Confirmation"; // build confirmation email body - StringBuilder emailBody = new StringBuilder(); + StringBuilder emailBody = new(); emailBody.AppendLine("Hello! This email was used to create an account on a QtC.NET Server."); emailBody.AppendLine(); emailBody.AppendLine($"You can confirm your email by clicking here - {confirmUrl}"); diff --git a/qtc-net-server/Services/TokenService/ITokenService.cs b/qtc-net-server/Services/TokenService/ITokenService.cs index 6d0b67a..bb17f87 100644 --- a/qtc-net-server/Services/TokenService/ITokenService.cs +++ b/qtc-net-server/Services/TokenService/ITokenService.cs @@ -3,6 +3,7 @@ public interface ITokenService { public Task> GenerateAccessTokenAndRefreshToken(User user, bool generateRefToken = true, bool remember = false); + public ServiceResponse GenerateEmailConfirmationToken(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 563691e..89213c0 100644 --- a/qtc-net-server/Services/TokenService/TokenService.cs +++ b/qtc-net-server/Services/TokenService/TokenService.cs @@ -78,6 +78,40 @@ namespace qtc_api.Services.TokenService return serviceResponse; } + public ServiceResponse GenerateEmailConfirmationToken(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(24), + 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(); diff --git a/qtc-net-server/Services/UserService/UserService.cs b/qtc-net-server/Services/UserService/UserService.cs index 07b09a5..51f6892 100644 --- a/qtc-net-server/Services/UserService/UserService.cs +++ b/qtc-net-server/Services/UserService/UserService.cs @@ -1,4 +1,6 @@ -namespace qtc_api.Services.UserService +using qtc_api.Services.EmailService; + +namespace qtc_api.Services.UserService { public class UserService : IUserService { -- 2.47.2 From 503653e9516824e0ed8b5e1b279420986db18b36 Mon Sep 17 00:00:00 2001 From: Moonbase Date: Sat, 26 Jul 2025 16:37:39 -0700 Subject: [PATCH 3/6] Verification Fixes --- qtc-net-server/Controllers/AuthController.cs | 6 +++--- qtc-net-server/Services/EmailService/EmailService.cs | 11 +++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/qtc-net-server/Controllers/AuthController.cs b/qtc-net-server/Controllers/AuthController.cs index bdb69d0..2527db7 100644 --- a/qtc-net-server/Controllers/AuthController.cs +++ b/qtc-net-server/Controllers/AuthController.cs @@ -48,7 +48,7 @@ namespace qtc_api.Controllers { // send confirmation email (shouldn't do anything if email confirmation is disabled) var confirmationToken = _tokenService.GenerateEmailConfirmationToken(response.Data); - var confirmationUrl = $"{Request.Scheme}://{Request.Host}/api/auth/verify-email?token={confirmationToken}"; + var confirmationUrl = $"{Request.Scheme}://{Request.Host}/api/auth/verify-email?token={confirmationToken.Data}"; await _emailService.SendConfirmationEmail(response.Data.Email, response.Data.Username, confirmationUrl); @@ -120,7 +120,7 @@ namespace qtc_api.Controllers return Ok(response); } - [HttpPost("verify-email")] + [HttpGet("verify-email")] public async Task> VerifyEmail(string token) { try @@ -142,7 +142,7 @@ namespace qtc_api.Controllers if (user != null && user.Success && user.Data != null) { var now = DateTime.UtcNow; - if(user.Data.Email == email.Value && jwt.ValidTo.ToUniversalTime() < now) + if(user.Data.Email == email.Value && now < jwt.ValidTo.ToUniversalTime()) { user.Data.IsEmailVerified = true; await dataContext.SaveChangesAsync(); diff --git a/qtc-net-server/Services/EmailService/EmailService.cs b/qtc-net-server/Services/EmailService/EmailService.cs index 3072e04..1d1b106 100644 --- a/qtc-net-server/Services/EmailService/EmailService.cs +++ b/qtc-net-server/Services/EmailService/EmailService.cs @@ -38,20 +38,19 @@ namespace qtc_api.Services.EmailService // build confirmation email body StringBuilder emailBody = new(); - emailBody.AppendLine("Hello! This email was used to create an account on a QtC.NET Server."); + emailBody.AppendLine($"Hello {name},"); + emailBody.AppendLine(); + emailBody.AppendLine($"Your receiving this message because you made a QtC.NET Account on a server that requires email confirmation."); emailBody.AppendLine(); emailBody.AppendLine($"You can confirm your email by clicking here - {confirmUrl}"); emailBody.AppendLine(); emailBody.AppendLine("If you did not create a QtC.NET account on any server, you may simply ignore this email."); // create new client - using var client = new SmtpClient() - { - RequireTLS = true - }; + using var client = new SmtpClient(); // connect and authenticate - await client.ConnectAsync(host, 587, true); + await client.ConnectAsync(host, 587); await client.AuthenticateAsync(username, password); // construct email -- 2.47.2 From 08fb330de3e75d520eb8a7734645873b8d2c1efb Mon Sep 17 00:00:00 2001 From: Moonbase Date: Sat, 26 Jul 2025 23:32:42 -0700 Subject: [PATCH 4/6] Use HTML Body To Prevent Spam Flags --- qtc-net-server/Controllers/AuthController.cs | 26 ++++++++++++++++--- .../Services/EmailService/EmailService.cs | 19 +++++++------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/qtc-net-server/Controllers/AuthController.cs b/qtc-net-server/Controllers/AuthController.cs index 2527db7..1e476d5 100644 --- a/qtc-net-server/Controllers/AuthController.cs +++ b/qtc-net-server/Controllers/AuthController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authorization; +using Azure; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Razor.TagHelpers; @@ -120,13 +121,32 @@ namespace qtc_api.Controllers return Ok(response); } + [HttpPost("resend-email")] + public async Task>> ResendVerificationEmail(string email) + { + var user = await _userService.GetUserByEmail(email); + if (user != null && user.Success && user.Data != null) + { + var confirmationToken = _tokenService.GenerateEmailConfirmationToken(user.Data); + var confirmationUrl = $"{Request.Scheme}://{Request.Host}/api/auth/verify-email?token={confirmationToken.Data}"; + + await _emailService.SendConfirmationEmail(user.Data.Email, user.Data.Username, confirmationUrl); + + return Ok(new ServiceResponse { Success = true, Data = true }); + } + + return Ok(new ServiceResponse { Success = false }); + } + [HttpGet("verify-email")] public async Task> VerifyEmail(string token) { try { - var handler = new JwtSecurityTokenHandler(); - handler.InboundClaimTypeMap = new Dictionary(); + var handler = new JwtSecurityTokenHandler() + { + InboundClaimTypeMap = new Dictionary() + }; var jwt = handler.ReadJwtToken(token); diff --git a/qtc-net-server/Services/EmailService/EmailService.cs b/qtc-net-server/Services/EmailService/EmailService.cs index 1d1b106..1413950 100644 --- a/qtc-net-server/Services/EmailService/EmailService.cs +++ b/qtc-net-server/Services/EmailService/EmailService.cs @@ -38,16 +38,17 @@ namespace qtc_api.Services.EmailService // build confirmation email body StringBuilder emailBody = new(); - emailBody.AppendLine($"Hello {name},"); - emailBody.AppendLine(); - emailBody.AppendLine($"Your receiving this message because you made a QtC.NET Account on a server that requires email confirmation."); - emailBody.AppendLine(); - emailBody.AppendLine($"You can confirm your email by clicking here - {confirmUrl}"); - emailBody.AppendLine(); - emailBody.AppendLine("If you did not create a QtC.NET account on any server, you may simply ignore this email."); + 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.

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

"); // create new client - using var client = new SmtpClient(); + using var client = new SmtpClient() + { + RequireTLS = true + }; // connect and authenticate await client.ConnectAsync(host, 587); @@ -59,7 +60,7 @@ namespace qtc_api.Services.EmailService message.From.Add(new MailboxAddress("QtC.NET Server", senderAddress)); message.Subject = emailSubject; - message.Body = new TextPart(MimeKit.Text.TextFormat.Plain) + message.Body = new TextPart(MimeKit.Text.TextFormat.Html) { Text = emailBody.ToString() }; -- 2.47.2 From 3a530b6639c79113e4e2aa38b8cb7efb49425dbd Mon Sep 17 00:00:00 2001 From: Moonbase Date: Sun, 27 Jul 2025 12:58:44 -0700 Subject: [PATCH 5/6] 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(); -- 2.47.2 From ae60f58107e72a7e5925962f0d53bbcba17cf100 Mon Sep 17 00:00:00 2001 From: Moonbase Date: Sun, 27 Jul 2025 13:54:58 -0700 Subject: [PATCH 6/6] Send Password Reset Over DTO Instead Of Parameters --- qtc-net-server/Controllers/AuthController.cs | 8 ++++---- qtc-net-server/Dtos/User/UserPasswordResetDto.cs | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 qtc-net-server/Dtos/User/UserPasswordResetDto.cs diff --git a/qtc-net-server/Controllers/AuthController.cs b/qtc-net-server/Controllers/AuthController.cs index 71d482c..c9fef8f 100644 --- a/qtc-net-server/Controllers/AuthController.cs +++ b/qtc-net-server/Controllers/AuthController.cs @@ -121,7 +121,7 @@ namespace qtc_api.Controllers return Ok(response); } - [HttpPost("resend-email")] + [HttpPost("resend-verification-email")] public async Task>> ResendVerificationEmail(string email) { var user = await _userService.GetUserByEmail(email); @@ -156,7 +156,7 @@ namespace qtc_api.Controllers } [HttpPost("reset-password")] - public async Task>> ResetPassword(string confirmationToken, string password) + public async Task>> ResetPassword(UserPasswordResetDto request) { try { @@ -165,7 +165,7 @@ namespace qtc_api.Controllers InboundClaimTypeMap = new Dictionary() }; - var jwt = handler.ReadJwtToken(confirmationToken); + var jwt = handler.ReadJwtToken(request.Token); if (jwt != null) { @@ -181,7 +181,7 @@ namespace qtc_api.Controllers var now = DateTime.UtcNow; if (user.Data.Email == email.Value && now < jwt.ValidTo.ToUniversalTime()) { - user.Data.PasswordHash = BCrypt.Net.BCrypt.HashPassword(password); + user.Data.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); await dataContext.SaveChangesAsync(); return Ok(new ServiceResponse { Success = true, Data = true }); diff --git a/qtc-net-server/Dtos/User/UserPasswordResetDto.cs b/qtc-net-server/Dtos/User/UserPasswordResetDto.cs new file mode 100644 index 0000000..0845278 --- /dev/null +++ b/qtc-net-server/Dtos/User/UserPasswordResetDto.cs @@ -0,0 +1,8 @@ +namespace qtc_api.Dtos.User +{ + public class UserPasswordResetDto + { + public string Token { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + } +} -- 2.47.2