diff --git a/Controllers/PasswordResetController.cs b/Controllers/PasswordResetController.cs new file mode 100644 index 0000000..27d16fa --- /dev/null +++ b/Controllers/PasswordResetController.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Mvc; +using PATHFINDER_BACKEND.DTOs; +using PATHFINDER_BACKEND.Services; + +namespace PATHFINDER_BACKEND.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class PasswordResetController : ControllerBase + { + private readonly PasswordResetService _passwordResetService; + private readonly ILogger _logger; + + public PasswordResetController( + PasswordResetService passwordResetService, + ILogger logger) + { + _passwordResetService = passwordResetService; + _logger = logger; + } + + /// + /// POST /api/passwordreset/forgot + /// Sends a password reset link to the user's email + /// + [HttpPost("forgot")] + public async Task ForgotPassword([FromBody] ForgotPasswordRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var result = await _passwordResetService.ForgotPasswordAsync(request); + + if (!result.Success) + { + return BadRequest(new { message = result.Message }); + } + + return Ok(new { + message = result.Message, + expiresAt = result.ExpiresAt + }); + } + + /// + /// POST /api/passwordreset/reset + /// Resets the user's password using a valid token + /// + [HttpPost("reset")] + public async Task ResetPassword([FromBody] ResetPasswordRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var result = await _passwordResetService.ResetPasswordAsync(request); + + if (!result.Success) + { + return BadRequest(new { message = result.Message }); + } + + return Ok(new { message = result.Message }); + } + + /// + /// POST /api/passwordreset/validate-token + /// Validates if a reset token is still valid (for frontend) + /// + [HttpPost("validate-token")] + public async Task ValidateToken([FromBody] ValidateTokenRequest request) + { + if (string.IsNullOrWhiteSpace(request.Token)) + { + return Ok(new { valid = false, message = "Token is required" }); + } + + var resetToken = await _passwordResetService.ValidateTokenAsync(request.Token); + + if (resetToken == null) + { + return Ok(new { valid = false, message = "Token is invalid or expired" }); + } + + return Ok(new { + valid = true, + email = resetToken.Email, + userType = resetToken.UserType, + expiresAt = resetToken.ExpiresAt + }); + } + } + + public class ValidateTokenRequest + { + public string Token { get; set; } = ""; + } +} \ No newline at end of file diff --git a/DTOs/ForgotPasswordRequest.cs b/DTOs/ForgotPasswordRequest.cs new file mode 100644 index 0000000..421ba01 --- /dev/null +++ b/DTOs/ForgotPasswordRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace PATHFINDER_BACKEND.DTOs +{ + public class ForgotPasswordRequest + { + [Required(ErrorMessage = "Email is required")] + [EmailAddress(ErrorMessage = "Invalid email format")] + public string Email { get; set; } = ""; + + [Required(ErrorMessage = "User type is required")] + [RegularExpression("^(STUDENT|COMPANY)$", ErrorMessage = "User type must be STUDENT or COMPANY")] + public string UserType { get; set; } = ""; + } +} \ No newline at end of file diff --git a/DTOs/PasswordResetResponse.cs b/DTOs/PasswordResetResponse.cs new file mode 100644 index 0000000..f75d269 --- /dev/null +++ b/DTOs/PasswordResetResponse.cs @@ -0,0 +1,10 @@ +namespace PATHFINDER_BACKEND.DTOs +{ + public class PasswordResetResponse + { + public bool Success { get; set; } + public string Message { get; set; } = ""; + public string? ResetToken { get; set; } + public DateTime? ExpiresAt { get; set; } + } +} \ No newline at end of file diff --git a/DTOs/ResetPasswordRequest.cs b/DTOs/ResetPasswordRequest.cs new file mode 100644 index 0000000..c1cd4dc --- /dev/null +++ b/DTOs/ResetPasswordRequest.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace PATHFINDER_BACKEND.DTOs +{ + public class ResetPasswordRequest + { + [Required(ErrorMessage = "Token is required")] + public string Token { get; set; } = ""; + + [Required(ErrorMessage = "New password is required")] + [MinLength(8, ErrorMessage = "Password must be at least 8 characters")] + public string NewPassword { get; set; } = ""; + + [Required(ErrorMessage = "Confirm password is required")] + [Compare("NewPassword", ErrorMessage = "Passwords do not match")] + public string ConfirmPassword { get; set; } = ""; + } +} \ No newline at end of file diff --git a/Models/PasswordResetToken.cs b/Models/PasswordResetToken.cs new file mode 100644 index 0000000..5a27fba --- /dev/null +++ b/Models/PasswordResetToken.cs @@ -0,0 +1,13 @@ +namespace PATHFINDER_BACKEND.Models +{ + public class PasswordResetToken + { + public int Id { get; set; } + public string Email { get; set; } = ""; + public string Token { get; set; } = ""; + public string UserType { get; set; } = ""; // "STUDENT" or "COMPANY" + public bool Used { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/PathFinder.DatabaseMigrator/Migrations/021_Create_PasswordReset_Tables.sql b/PathFinder.DatabaseMigrator/Migrations/021_Create_PasswordReset_Tables.sql new file mode 100644 index 0000000..6dfaebe --- /dev/null +++ b/PathFinder.DatabaseMigrator/Migrations/021_Create_PasswordReset_Tables.sql @@ -0,0 +1,40 @@ +-- ============================================= +-- Migration: Password Reset Functionality +-- Description: Tables for storing password reset tokens +-- Date: 2026-04-16 +-- ============================================= + +BEGIN TRY + BEGIN TRANSACTION; + + -- Create password reset tokens table + IF OBJECT_ID('dbo.password_reset_tokens', 'U') IS NULL + BEGIN + CREATE TABLE dbo.password_reset_tokens ( + id INT IDENTITY(1,1) PRIMARY KEY, + email NVARCHAR(150) NOT NULL, + token NVARCHAR(255) NOT NULL, + user_type NVARCHAR(20) NOT NULL, -- 'STUDENT' or 'COMPANY' + used BIT NOT NULL DEFAULT 0, + expires_at DATETIME2 NOT NULL, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT UQ_password_reset_tokens_token UNIQUE (token) + ); + + -- Create indexes for performance + CREATE INDEX IX_password_reset_tokens_email ON dbo.password_reset_tokens(email); + CREATE INDEX IX_password_reset_tokens_token ON dbo.password_reset_tokens(token); + CREATE INDEX IX_password_reset_tokens_expires_at ON dbo.password_reset_tokens(expires_at); + + PRINT '✅ Created password_reset_tokens table'; + END + + COMMIT TRANSACTION; + PRINT '✅ Password reset migration completed successfully!'; +END TRY +BEGIN CATCH + ROLLBACK TRANSACTION; + PRINT '❌ Error: ' + ERROR_MESSAGE(); + THROW; +END CATCH \ No newline at end of file diff --git a/Program.cs b/Program.cs index 0069c9d..7b49d9c 100644 --- a/Program.cs +++ b/Program.cs @@ -40,6 +40,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); //AI Services builder.Services.AddMemoryCache(); @@ -127,6 +129,7 @@ var app = builder.Build(); + // Create upload directories for local storage var webRootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"); if (!Directory.Exists(webRootPath)) diff --git a/Repositories/PasswordResetRepository.cs b/Repositories/PasswordResetRepository.cs new file mode 100644 index 0000000..46235a9 --- /dev/null +++ b/Repositories/PasswordResetRepository.cs @@ -0,0 +1,123 @@ +using Microsoft.Data.SqlClient; +using PATHFINDER_BACKEND.Data; +using PATHFINDER_BACKEND.Models; + +namespace PATHFINDER_BACKEND.Repositories +{ + public class PasswordResetRepository + { + private readonly Db _db; + + public PasswordResetRepository(Db db) + { + _db = db; + } + + public async Task EnsureTableExistsAsync() + { + var sql = @" + IF OBJECT_ID('dbo.password_reset_tokens', 'U') IS NULL + BEGIN + CREATE TABLE dbo.password_reset_tokens ( + id INT IDENTITY(1,1) PRIMARY KEY, + email NVARCHAR(150) NOT NULL, + token NVARCHAR(255) NOT NULL, + user_type NVARCHAR(20) NOT NULL, + used BIT NOT NULL DEFAULT 0, + expires_at DATETIME2 NOT NULL, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT UQ_password_reset_tokens_token UNIQUE (token) + ); + + CREATE INDEX IX_password_reset_tokens_email ON dbo.password_reset_tokens(email); + CREATE INDEX IX_password_reset_tokens_token ON dbo.password_reset_tokens(token); + CREATE INDEX IX_password_reset_tokens_expires_at ON dbo.password_reset_tokens(expires_at); + END"; + + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = new SqlCommand(sql, conn); + await cmd.ExecuteNonQueryAsync(); + } + + public async Task SaveResetTokenAsync(PasswordResetToken token) + { + const string sql = @" + INSERT INTO dbo.password_reset_tokens (email, token, user_type, used, expires_at, created_at) + VALUES (@email, @token, @userType, @used, @expiresAt, @createdAt)"; + + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@email", token.Email); + cmd.Parameters.AddWithValue("@token", token.Token); + cmd.Parameters.AddWithValue("@userType", token.UserType); + cmd.Parameters.AddWithValue("@used", token.Used); + cmd.Parameters.AddWithValue("@expiresAt", token.ExpiresAt); + cmd.Parameters.AddWithValue("@createdAt", token.CreatedAt); + await cmd.ExecuteNonQueryAsync(); + } + + public async Task GetValidTokenAsync(string token) + { + const string sql = @" + SELECT TOP 1 id, email, token, user_type, used, expires_at, created_at + FROM dbo.password_reset_tokens + WHERE token = @token + AND used = 0 + AND expires_at > SYSUTCDATETIME() + ORDER BY created_at DESC"; + + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@token", token); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + return new PasswordResetToken + { + Id = reader.GetInt32(0), + Email = reader.GetString(1), + Token = reader.GetString(2), + UserType = reader.GetString(3), + Used = reader.GetBoolean(4), + ExpiresAt = reader.GetDateTime(5), + CreatedAt = reader.GetDateTime(6) + }; + } + return null; + } + + public async Task MarkTokenAsUsedAsync(int tokenId) + { + const string sql = @" + UPDATE dbo.password_reset_tokens + SET used = 1 + WHERE id = @id"; + + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@id", tokenId); + await cmd.ExecuteNonQueryAsync(); + } + + public async Task InvalidateAllTokensForEmailAsync(string email, string userType) + { + const string sql = @" + UPDATE dbo.password_reset_tokens + SET used = 1 + WHERE email = @email AND user_type = @userType AND used = 0"; + + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@email", email); + cmd.Parameters.AddWithValue("@userType", userType); + await cmd.ExecuteNonQueryAsync(); + } + } +} \ No newline at end of file diff --git a/Services/EmailService.cs b/Services/EmailService.cs index a615413..cc926e9 100644 --- a/Services/EmailService.cs +++ b/Services/EmailService.cs @@ -1,33 +1,150 @@ +using System.Net; +using System.Net.Mail; + namespace PATHFINDER_BACKEND.Services { public class EmailService : IEmailService { private readonly IConfiguration _configuration; private readonly ILogger _logger; + private readonly string _smtpHost; + private readonly int _smtpPort; + private readonly string _smtpUsername; + private readonly string _smtpPassword; + private readonly bool _enableSsl; public EmailService(IConfiguration configuration, ILogger logger) { _configuration = configuration; _logger = logger; + + // Read SMTP settings from configuration + _smtpHost = _configuration["Email:SmtpHost"] ?? "smtp.gmail.com"; + _smtpPort = int.Parse(_configuration["Email:SmtpPort"] ?? "587"); + _smtpUsername = _configuration["Email:SmtpUsername"] ?? ""; + _smtpPassword = _configuration["Email:SmtpPassword"] ?? ""; + _enableSsl = bool.Parse(_configuration["Email:EnableSsl"] ?? "true"); } - public async Task SendCompanyApprovalEmailAsync(string toEmail, string companyName, string status, string? rejectionReason = null) + public async Task SendPasswordResetEmailAsync(string toEmail, string resetToken, string userType) { try { - // Implement your email sending logic here - // Examples: SendGrid, SMTP, Amazon SES, etc. - - _logger.LogInformation($"Email would be sent to {toEmail} for company {companyName} with status {status}"); + var frontendUrl = _configuration["App:FrontendUrl"] ?? "https://pathfinder-frontend-navy.vercel.app"; + var resetLink = $"{frontendUrl}/auth/reset-password?token={resetToken}&type={userType.ToLower()}"; - // Placeholder - replace with actual email implementation - await Task.Delay(100); + var subject = "Password Reset Request - PathFinder"; + var body = $@" + + + + + +
+
+

PathFinder - Password Reset

+
+
+

Hello,

+

We received a request to reset your password for your {userType} account.

+

Click the button below to reset your password:

+

+ Reset Password +

+

Or copy this link:
{resetLink}

+

This link will expire in 1 hour.

+

If you didn't request this, please ignore this email.

+
+

Security Notice: Never share this link with anyone.

+
+ +
+ + + "; + + using var client = new SmtpClient(_smtpHost, _smtpPort); + client.Credentials = new NetworkCredential(_smtpUsername, _smtpPassword); + client.EnableSsl = _enableSsl; + + var mailMessage = new MailMessage + { + From = new MailAddress(_smtpUsername, "PathFinder Support"), + Subject = subject, + Body = body, + IsBodyHtml = true + }; + mailMessage.To.Add(toEmail); + + await client.SendMailAsync(mailMessage); + _logger.LogInformation($"Password reset email sent to {toEmail}"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to send password reset email to {toEmail}"); + return false; + } + } + + public async Task SendCompanyApprovalEmailAsync(string toEmail, string companyName, string status, string? rejectionReason = null) + { + try + { + var frontendUrl = _configuration["App:FrontendUrl"] ?? "http://localhost:3000"; + var subject = status == "APPROVED" + ? "Your Company Registration has been Approved - PathFinder" + : "Your Company Registration Status - PathFinder"; + var body = status == "APPROVED" + ? $@" + + +

Congratulations, {companyName}!

+

Your company registration has been approved.

+

You can now log in to your account and start posting jobs.

+

Click here to login

+ + " + : $@" + + +

Company Registration Update - {companyName}

+

Your company registration has been rejected.

+

Reason: {rejectionReason ?? "Not specified"}

+

Please contact support for more information.

+ + "; + + using var client = new SmtpClient(_smtpHost, _smtpPort); + client.Credentials = new NetworkCredential(_smtpUsername, _smtpPassword); + client.EnableSsl = _enableSsl; + + var mailMessage = new MailMessage + { + From = new MailAddress(_smtpUsername, "PathFinder Support"), + Subject = subject, + Body = body, + IsBodyHtml = true + }; + mailMessage.To.Add(toEmail); + + await client.SendMailAsync(mailMessage); + _logger.LogInformation($"Company approval email sent to {toEmail}"); return true; } catch (Exception ex) { - _logger.LogError(ex, $"Failed to send email to {toEmail}"); + _logger.LogError(ex, $"Failed to send company approval email to {toEmail}"); return false; } } diff --git a/Services/IEmailService.cs b/Services/IEmailService.cs index 60092ee..918655f 100644 --- a/Services/IEmailService.cs +++ b/Services/IEmailService.cs @@ -3,5 +3,8 @@ namespace PATHFINDER_BACKEND.Services public interface IEmailService { Task SendCompanyApprovalEmailAsync(string toEmail, string companyName, string status, string? rejectionReason = null); + + // Add this new method for password reset + Task SendPasswordResetEmailAsync(string toEmail, string resetToken, string userType); } } \ No newline at end of file diff --git a/Services/PasswordResetService.cs b/Services/PasswordResetService.cs new file mode 100644 index 0000000..7455f33 --- /dev/null +++ b/Services/PasswordResetService.cs @@ -0,0 +1,221 @@ +using System.Security.Cryptography; +using PATHFINDER_BACKEND.DTOs; +using PATHFINDER_BACKEND.Models; +using PATHFINDER_BACKEND.Repositories; + +namespace PATHFINDER_BACKEND.Services +{ + public class PasswordResetService + { + private readonly PasswordResetRepository _resetRepo; + private readonly StudentRepository _studentRepo; + private readonly CompanyRepository _companyRepo; + private readonly PasswordService _passwordService; + private readonly IEmailService _emailService; + private readonly ILogger _logger; + + public PasswordResetService( + PasswordResetRepository resetRepo, + StudentRepository studentRepo, + CompanyRepository companyRepo, + PasswordService passwordService, + IEmailService emailService, + ILogger logger) + { + _resetRepo = resetRepo; + _studentRepo = studentRepo; + _companyRepo = companyRepo; + _passwordService = passwordService; + _emailService = emailService; + _logger = logger; + } + + public async Task ForgotPasswordAsync(ForgotPasswordRequest request) + { + try + { + var email = request.Email.Trim().ToLowerInvariant(); + var userType = request.UserType.ToUpperInvariant(); + + // Verify user exists + bool userExists = false; + if (userType == "STUDENT") + { + var student = await _studentRepo.GetByEmailAsync(email); + userExists = student != null; + } + else if (userType == "COMPANY") + { + var company = await _companyRepo.GetByEmailAsync(email); + userExists = company != null; + } + else + { + return new PasswordResetResponse + { + Success = false, + Message = "Invalid user type" + }; + } + + if (!userExists) + { + // Don't reveal that user doesn't exist for security reasons + _logger.LogWarning($"Password reset requested for non-existent email: {email}"); + return new PasswordResetResponse + { + Success = true, // Still return true to prevent email enumeration + Message = "If an account exists with this email, you will receive a password reset link." + }; + } + + // Generate secure token + var token = GenerateSecureToken(); + + // Invalidate old tokens for this email + await _resetRepo.InvalidateAllTokensForEmailAsync(email, userType); + + // Save new token + var resetToken = new PasswordResetToken + { + Email = email, + Token = token, + UserType = userType, + Used = false, + ExpiresAt = DateTime.UtcNow.AddHours(1), // Token valid for 1 hour + CreatedAt = DateTime.UtcNow + }; + await _resetRepo.SaveResetTokenAsync(resetToken); + + // Send email + var emailSent = await _emailService.SendPasswordResetEmailAsync(email, token, userType); + + if (!emailSent) + { + _logger.LogError($"Failed to send password reset email to {email}"); + return new PasswordResetResponse + { + Success = false, + Message = "Failed to send reset email. Please try again later." + }; + } + + return new PasswordResetResponse + { + Success = true, + Message = "Password reset link has been sent to your email.", + ResetToken = token, // Only for testing - remove in production + ExpiresAt = resetToken.ExpiresAt + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in forgot password process"); + return new PasswordResetResponse + { + Success = false, + Message = "An error occurred. Please try again later." + }; + } + } + + public async Task ResetPasswordAsync(ResetPasswordRequest request) + { + try + { + // Validate token + var resetToken = await _resetRepo.GetValidTokenAsync(request.Token); + + if (resetToken == null) + { + return new PasswordResetResponse + { + Success = false, + Message = "Invalid or expired reset token. Please request a new password reset." + }; + } + + // Hash new password + var newPasswordHash = _passwordService.Hash(request.NewPassword); + + // Update password based on user type + bool passwordUpdated = false; + if (resetToken.UserType == "STUDENT") + { + var student = await _studentRepo.GetByEmailAsync(resetToken.Email); + if (student != null) + { + passwordUpdated = await _studentRepo.UpdatePasswordHashAsync(student.Id, newPasswordHash); + } + } + else if (resetToken.UserType == "COMPANY") + { + var company = await _companyRepo.GetByEmailAsync(resetToken.Email); + if (company != null) + { + passwordUpdated = await _companyRepo.UpdatePasswordHashAsync(company.Id, newPasswordHash); + } + } + + if (!passwordUpdated) + { + return new PasswordResetResponse + { + Success = false, + Message = "Failed to reset password. User not found." + }; + } + + // Mark token as used + await _resetRepo.MarkTokenAsUsedAsync(resetToken.Id); + + // Invalidate all other tokens for this email + await _resetRepo.InvalidateAllTokensForEmailAsync(resetToken.Email, resetToken.UserType); + + _logger.LogInformation($"Password reset successfully for {resetToken.Email} ({resetToken.UserType})"); + + return new PasswordResetResponse + { + Success = true, + Message = "Password has been reset successfully. You can now log in with your new password." + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in reset password process"); + return new PasswordResetResponse + { + Success = false, + Message = "An error occurred. Please try again later." + }; + } + } + + /// + /// Validates if a reset token is still valid (for frontend) + /// + public async Task ValidateTokenAsync(string token) + { + try + { + return await _resetRepo.GetValidTokenAsync(token); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error validating token"); + return null; + } + } + + private string GenerateSecureToken() + { + var randomNumber = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + return Convert.ToBase64String(randomNumber) + .Replace("+", "-") + .Replace("/", "_") + .Replace("=", ""); + } + } +} \ No newline at end of file diff --git a/appsettings.json b/appsettings.json index 974f85c..2cb382d 100644 --- a/appsettings.json +++ b/appsettings.json @@ -36,5 +36,15 @@ "ApiKey": "", "Model": "gemini-2.5-flash-lite" }, + "Email": { + "SmtpHost": "smtp.gmail.com", + "SmtpPort": 587, + "SmtpUsername": "seprojecttest.23@gmail.com", + "SmtpPassword": "yyngbzcshoqspdeu", + "EnableSsl": true + }, + "App": { + "FrontendUrl": "https://pathfinder-frontend-navy.vercel.app" + }, "AllowedHosts": "*" } \ No newline at end of file