diff --git a/qtc-net-server/Controllers/AuthController.cs b/qtc-net-server/Controllers/AuthController.cs index 3587482..c9fef8f 100644 --- a/qtc-net-server/Controllers/AuthController.cs +++ b/qtc-net-server/Controllers/AuthController.cs @@ -1,10 +1,15 @@ -using Microsoft.AspNetCore.Authorization; +using Azure; +using Microsoft.AspNetCore.Authorization; 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 +18,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 +45,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.Data}"; + + await _emailService.SendConfirmationEmail(response.Data.Email, response.Data.Username, confirmationUrl); + return Ok(response); } else { @@ -70,6 +85,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 +120,176 @@ namespace qtc_api.Controllers var response = await _tokenService.ValidateRefreshToken(token); return Ok(response); } + + [HttpPost("resend-verification-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 }); + } + + [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(UserPasswordResetDto request) + { + try + { + var handler = new JwtSecurityTokenHandler() + { + InboundClaimTypeMap = new Dictionary() + }; + + var jwt = handler.ReadJwtToken(request.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()) + { + user.Data.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.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) + { + 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()) + { + 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. 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. 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/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; + } +} 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..ef1c2fd 100644 --- a/qtc-net-server/Program.cs +++ b/qtc-net-server/Program.cs @@ -17,13 +17,13 @@ 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); 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 => @@ -55,6 +55,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..3db6dc3 --- /dev/null +++ b/qtc-net-server/Services/EmailService/EmailService.cs @@ -0,0 +1,134 @@ + +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(); + 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() + { + 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); + } + + 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 new file mode 100644 index 0000000..015da9b --- /dev/null +++ b/qtc-net-server/Services/EmailService/IEmailService.cs @@ -0,0 +1,8 @@ +namespace qtc_api.Services.EmailService +{ + 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 6d0b67a..df7b1ed 100644 --- a/qtc-net-server/Services/TokenService/ITokenService.cs +++ b/qtc-net-server/Services/TokenService/ITokenService.cs @@ -3,6 +3,8 @@ public interface ITokenService { 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 563691e..1421bd5 100644 --- a/qtc-net-server/Services/TokenService/TokenService.cs +++ b/qtc-net-server/Services/TokenService/TokenService.cs @@ -78,6 +78,74 @@ 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 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(); 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 { 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 - - - - + + + + - - - + + + - +