Compare commits

..

No commits in common. "10526dea33054be1ff8f89cc13e0ab71a2b7623f" and "1cbbe8c209d51e452c57ff714d3dd67a2d7a745f" have entirely different histories.

11 changed files with 17 additions and 446 deletions

View File

@ -1,15 +1,10 @@
using Azure;
using Microsoft.AspNetCore.Authorization;
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
{
@ -18,21 +13,17 @@ namespace qtc_api.Controllers
public class AuthController : ControllerBase
{
private readonly IUserService _userService;
private readonly IEmailService _emailService;
private readonly ITokenService _tokenService;
private readonly IHubContext<ChatHub> _chatGWContext;
private readonly IConfiguration _configuration;
private readonly ServerConfig serverConfig;
private readonly DataContext dataContext;
public AuthController(IUserService userService, ITokenService tokenService, IHubContext<ChatHub> chatGWContext, DataContext dataContext, IConfiguration configuration, IEmailService emailService)
public AuthController(IUserService userService, ITokenService tokenService, IHubContext<ChatHub> chatGWContext, DataContext dataContext)
{
_userService = userService;
_tokenService = tokenService;
_chatGWContext = chatGWContext;
_configuration = configuration;
_emailService = emailService;
serverConfig = JsonSerializer.Deserialize<ServerConfig>(JsonDocument.Parse(System.IO.File.ReadAllText("./ServerConfig.json")));
this.dataContext = dataContext;
@ -45,14 +36,8 @@ namespace qtc_api.Controllers
{
var response = await _userService.AddUser(userDto);
await _chatGWContext.Clients.All.SendAsync("RefreshUserLists");
if(response.Success != false && response.Data != null)
if(response.Success != false)
{
// 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
{
@ -85,15 +70,6 @@ namespace qtc_api.Controllers
});
}
if(!dbUser.Data.IsEmailVerified && _configuration.GetValue<bool>("EmailConfig:EmailConfirmationRequired"))
{
return Ok(new ServiceResponse<string>
{
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";
@ -120,176 +96,5 @@ namespace qtc_api.Controllers
var response = await _tokenService.ValidateRefreshToken(token);
return Ok(response);
}
[HttpPost("resend-verification-email")]
public async Task<ActionResult<ServiceResponse<bool>>> 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<bool> { Success = true, Data = true });
}
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(UserPasswordResetDto request)
{
try
{
var handler = new JwtSecurityTokenHandler()
{
InboundClaimTypeMap = new Dictionary<string, string>()
};
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<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")]
public async Task<ActionResult<string>> VerifyEmail(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())
{
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<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

@ -1,8 +0,0 @@
namespace qtc_api.Dtos.User
{
public class UserPasswordResetDto
{
public string Token { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
}

View File

@ -9,7 +9,6 @@
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;

View File

@ -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<DataContext>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSignalR();
builder.Services.AddStackExchangeRedisCache(options =>
@ -55,9 +55,6 @@ 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<IEmailService, EmailService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IRoomService, RoomService>();

View File

@ -1,134 +0,0 @@

using MailKit.Net.Smtp;
using MimeKit;
namespace qtc_api.Services.EmailService
{
public class EmailService : IEmailService
{
private IConfiguration _configuration;
private ILogger<EmailService> _logger;
public EmailService(IConfiguration configuration, ILogger<EmailService> logger)
{
_configuration = configuration;
_logger = logger;
}
public async Task SendConfirmationEmail(string email, string name, string confirmUrl)
{
// 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 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("NOTE: This Link Is Only Valid For 24 Hours.<br><br>");
emailBody.AppendLine("If you did not create a 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);
}
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

@ -1,8 +0,0 @@
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);
}
}

View File

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

View File

@ -78,74 +78,6 @@ namespace qtc_api.Services.TokenService
return serviceResponse;
}
public ServiceResponse<string> GenerateEmailConfirmationToken(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(24),
signingCredentials: creds
);
var jwt = new JwtSecurityTokenHandler().WriteToken(token);
serviceResponse.Success = true;
serviceResponse.Data = jwt;
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)
{
var serviceResponse = new ServiceResponse<string>();

View File

@ -1,6 +1,4 @@
using qtc_api.Services.EmailService;
namespace qtc_api.Services.UserService
namespace qtc_api.Services.UserService
{
public class UserService : IUserService
{

View File

@ -7,13 +7,6 @@
"GeneralConfig": {
"CDNPath": "./user-content"
},
"EmailConfig": {
"EmailConfirmationRequired": false,
"SMTPServer": "",
"SMTPUsername": "api",
"SMTPPassword": "",
"SMTPSenderAddress": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",

View File

@ -11,24 +11,23 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="MailKit" Version="4.13.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.7" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.13.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.6" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.12.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="StackExchange.Redis" Version="2.8.58" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="9.0.3" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="9.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.13.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
</ItemGroup>
<ItemGroup>