Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions Controllers/PasswordResetController.cs
Original file line number Diff line number Diff line change
@@ -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<PasswordResetController> _logger;

public PasswordResetController(
PasswordResetService passwordResetService,
ILogger<PasswordResetController> logger)
{
_passwordResetService = passwordResetService;
_logger = logger;
}

/// <summary>
/// POST /api/passwordreset/forgot
/// Sends a password reset link to the user's email
/// </summary>
[HttpPost("forgot")]
public async Task<IActionResult> 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
});
}

/// <summary>
/// POST /api/passwordreset/reset
/// Resets the user's password using a valid token
/// </summary>
[HttpPost("reset")]
public async Task<IActionResult> 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 });
}

/// <summary>
/// POST /api/passwordreset/validate-token
/// Validates if a reset token is still valid (for frontend)
/// </summary>
[HttpPost("validate-token")]
public async Task<IActionResult> 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; } = "";
}
}
15 changes: 15 additions & 0 deletions DTOs/ForgotPasswordRequest.cs
Original file line number Diff line number Diff line change
@@ -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; } = "";
}
}
10 changes: 10 additions & 0 deletions DTOs/PasswordResetResponse.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
18 changes: 18 additions & 0 deletions DTOs/ResetPasswordRequest.cs
Original file line number Diff line number Diff line change
@@ -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; } = "";
}
}
13 changes: 13 additions & 0 deletions Models/PasswordResetToken.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
builder.Services.AddScoped<LocalFileStorageService>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<CvTextExtractorService>();
builder.Services.AddScoped<PasswordResetRepository>();
builder.Services.AddScoped<PasswordResetService>();

//AI Services
builder.Services.AddMemoryCache();
Expand Down Expand Up @@ -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))
Expand Down
123 changes: 123 additions & 0 deletions Repositories/PasswordResetRepository.cs
Original file line number Diff line number Diff line change
@@ -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<PasswordResetToken?> 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();
}
}
}
Loading
Loading