2026-07-23 13:54:06 -07:00

309 lines
14 KiB
C#

using qtc_api.Services.EmailService;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json;
namespace qtc_api.Controllers
{
[Route("api/auth")]
[ApiController]
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)
{
_userService = userService;
_tokenService = tokenService;
_chatGWContext = chatGWContext;
_configuration = configuration;
_emailService = emailService;
serverConfig = JsonSerializer.Deserialize<ServerConfig>(JsonDocument.Parse(System.IO.File.ReadAllText("./Resources/ServerConfig.json")));
this.dataContext = dataContext;
}
[HttpPost("register")]
public async Task<ActionResult<ServiceResponse<User>>> Register(UserDto userDto)
{
if (userDto != null)
{
var response = await _userService.AddUser(userDto);
await _chatGWContext.Clients.All.SendAsync("RefreshUserLists");
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
{
return StatusCode(500, response.Message);
}
}
else
{
return BadRequest();
}
}
[HttpPost("login")]
public async Task<ActionResult<ServiceResponse<string>>> Login(UserLoginDto request)
{
var dbUser = await _userService.GetUserByEmail(request.Email);
if (dbUser.Data == null)
{
return Ok(new ServiceResponse<string>
{
Message = "User not found.",
Success = false
});
}
else if (!BCrypt.Net.BCrypt.Verify(request.Password, dbUser.Data.PasswordHash))
{
return Ok(new ServiceResponse<string>
{
Message = "Incorrect password.",
Success = false
});
}
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";
}
var token = await _tokenService.GenerateAccessTokenAndRefreshToken(dbUser.Data, true, request.RememberMe);
// check if the new user tag can be removed (14 days or 2 weeks)
var now = DateTime.UtcNow;
if (now.Date > dbUser.Data.CreatedAt.Date.AddDays(14) && dbUser.Data.Tags.Contains("new_user"))
{
List<string> _tagsList = [.. dbUser.Data.Tags];
_tagsList.Remove("new_user");
dbUser.Data.Tags = [.. _tagsList];
}
await dataContext.SaveChangesAsync();
return Ok(token);
}
[HttpPost("refresh")]
public async Task<ActionResult<ServiceResponse<string>>> RefreshLogin(string token)
{
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")]
[Authorize]
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.GeneratePasswordResetConfirmationToken(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 jwtValidationResult = await handler.ValidateTokenAsync(request.Token, _tokenService.GetValidationParams().Data);
if (!jwtValidationResult.IsValid) return Ok(new ServiceResponse<bool> { Success = false, Message = "Token Invalid. You may need to be sent another reset link." });
var jwtClaims = jwtValidationResult.ClaimsIdentity;
var jwtValidTo = jwtValidationResult.SecurityToken.ValidTo;
if (!jwtClaims.HasClaim("TokenType", "client-password-reset")) return Ok(new ServiceResponse<bool> { Success = false, Message = "Token Invalid. You may need to be sent another reset link." });
if (jwtClaims != null)
{
var email = jwtClaims.Claims.FirstOrDefault(e => e.Type == ClaimTypes.Email);
var id = jwtClaims.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 < jwtValidTo.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 jwtValidationResult = await handler.ValidateTokenAsync(token, _tokenService.GetValidationParams().Data);
if (!jwtValidationResult.IsValid) return Ok("Token Invalid. You may need to be sent another reset link.");
var jwtClaims = jwtValidationResult.ClaimsIdentity;
var jwtValidTo = jwtValidationResult.SecurityToken.ValidTo;
if (!jwtClaims.HasClaim("TokenType", "email-confirmation")) return Ok("Token Invalid. You may need to be sent another reset link.");
if (jwtClaims != null)
{
var email = jwtClaims.Claims.FirstOrDefault(e => e.Type == ClaimTypes.Email);
var id = jwtClaims.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 < jwtValidTo.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 jwtValidationResult = await handler.ValidateTokenAsync(token, _tokenService.GetValidationParams().Data);
if (!jwtValidationResult.IsValid) return Ok("Token Invalid. You may need to be sent another reset link.");
var jwtClaims = jwtValidationResult.ClaimsIdentity;
var jwtValidTo = jwtValidationResult.SecurityToken.ValidTo;
if (!jwtClaims.HasClaim("TokenType", "password-reset")) return Ok("Token Invalid. You may need to be sent another reset link.");
if (jwtClaims != null)
{
var email = jwtClaims.Claims.FirstOrDefault(e => e.Type == ClaimTypes.Email);
var id = jwtClaims.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 < jwtValidTo.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.");
}
}
}
}