From 9fae113f80b1b29c4464d4c834aa741d7db518fa Mon Sep 17 00:00:00 2001 From: nutanAhir688 <23cp042@bvmengineering.ac.in> Date: Mon, 22 Jun 2026 20:29:35 +0530 Subject: [PATCH 1/2] feat: add refresh token & refactor codebase --- Backend/Controllers/AuthController.cs | 37 +++- Backend/Controllers/BaseApiController.cs | 36 ++++ Backend/Controllers/ChecklistController.cs | 55 +----- Backend/Controllers/CommentController.cs | 81 +-------- Backend/Controllers/DashboardController.cs | 2 +- Backend/Controllers/LabelController.cs | 18 +- Backend/Controllers/NotificationController.cs | 9 +- Backend/Controllers/ProfileController.cs | 13 +- Backend/Controllers/ProjectController.cs | 34 +--- Backend/Controllers/SettingsController.cs | 13 +- Backend/Controllers/TaskAccessController.cs | 61 +++++++ .../Controllers/TaskAttachmentController.cs | 83 +-------- Backend/Controllers/TaskController.cs | 59 +------ Backend/Controllers/TaskWatcherController.cs | 18 +- Backend/Controllers/UserController.cs | 18 +- Backend/Data/AppDbContext.cs | 18 ++ Backend/Data/JwtSettingsOptions.cs | 10 +- Backend/Extensions/DatabaseExtensions.cs | 16 +- Backend/Models/DTOs/AuthResponseDto.cs | 2 + Backend/Models/DTOs/RefreshTokenDto.cs | 6 + Backend/Models/Entities/RefreshToken.cs | 31 ++++ .../Services/Implementations/AuthService.cs | 167 ++++++++++++++++-- Backend/Services/Interfaces/IAuthService.cs | 3 + Backend/appsettings.json | 3 +- 24 files changed, 389 insertions(+), 404 deletions(-) create mode 100644 Backend/Controllers/BaseApiController.cs create mode 100644 Backend/Controllers/TaskAccessController.cs create mode 100644 Backend/Models/DTOs/RefreshTokenDto.cs create mode 100644 Backend/Models/Entities/RefreshToken.cs diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 4d8f1c2..72ff663 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -4,12 +4,12 @@ namespace Backend.Controllers; using Microsoft.AspNetCore.Mvc; using Backend.Models.DTOs; using Backend.Services.Interfaces; +using System.Security.Claims; [ApiController] [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/auth")] -[AllowAnonymous] -public class AuthController : ControllerBase +public class AuthController : BaseApiController { private readonly IAuthService _authService; @@ -19,6 +19,7 @@ public AuthController(IAuthService authService) } [HttpPost("register")] + [AllowAnonymous] public async Task Register([FromBody] RegisterDto dto) { var result = await _authService.Register(dto); @@ -32,6 +33,7 @@ public async Task Register([FromBody] RegisterDto dto) } [HttpPost("login")] + [AllowAnonymous] public async Task Login([FromBody] LoginDto dto) { var result = await _authService.Login(dto); @@ -43,4 +45,35 @@ public async Task Login([FromBody] LoginDto dto) return Ok(ApiResponseDto.Ok(result, result.Message)); } + + [HttpPost("refresh")] + [AllowAnonymous] + public async Task RefreshToken([FromBody] RefreshTokenDto dto) + { + var result = await _authService.RefreshTokenAsync(dto.RefreshToken); + + if (!result.Success) + { + return Unauthorized(ApiResponseDto.Fail(result.Message)); + } + + return Ok(ApiResponseDto.Ok(result, result.Message)); + } + + [HttpPost("logout")] + [Authorize] + public async Task Logout([FromBody] RefreshTokenDto dto) + { + await _authService.LogoutAsync(dto.RefreshToken); + return Ok(ApiResponseDto.Ok(null, "Logged out successfully")); + } + + [HttpPost("revoke-all")] + [Authorize] + public async Task RevokeAll() + { + var userId = GetCurrentUserId(); + await _authService.RevokeAllTokensAsync(userId); + return Ok(ApiResponseDto.Ok(null, "All sessions have been revoked")); + } } diff --git a/Backend/Controllers/BaseApiController.cs b/Backend/Controllers/BaseApiController.cs new file mode 100644 index 0000000..a3183c9 --- /dev/null +++ b/Backend/Controllers/BaseApiController.cs @@ -0,0 +1,36 @@ +namespace Backend.Controllers; + +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +/// +/// Base controller providing common helper methods shared across all API controllers. +/// Eliminates duplication of GetCurrentUserId() and HasElevatedAccess() across the codebase. +/// +public abstract class BaseApiController : ControllerBase +{ + /// + /// Extracts the current authenticated user's ID from the JWT claims. + /// + protected Guid GetCurrentUserId() + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + + if (!Guid.TryParse(userIdClaim, out var userId)) + throw new UnauthorizedAccessException("Invalid user context"); + + return userId; + } + + /// + /// Checks whether the current user has elevated access (Admin or Manager roles). + /// Set to false for Admin-only checks. + /// + protected bool HasElevatedAccess(bool includeManager = true) + { + if (User.IsInRole("Admin")) + return true; + + return includeManager && User.IsInRole("Manager"); + } +} diff --git a/Backend/Controllers/ChecklistController.cs b/Backend/Controllers/ChecklistController.cs index 850cc3e..74752f7 100644 --- a/Backend/Controllers/ChecklistController.cs +++ b/Backend/Controllers/ChecklistController.cs @@ -4,23 +4,19 @@ namespace Backend.Controllers; using Microsoft.AspNetCore.Mvc; using Backend.Models.DTOs; using Backend.Services.Interfaces; -using System.Security.Claims; [ApiController] [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/tasks/{taskId}/checklist")] [Authorize] -public class ChecklistController : ControllerBase +public class ChecklistController : TaskAccessController { private readonly IChecklistService _service; - private readonly ITaskService _taskService; - private readonly IProjectService _projectService; public ChecklistController(IChecklistService service, ITaskService taskService, IProjectService projectService) + : base(taskService, projectService) { _service = service; - _taskService = taskService; - _projectService = projectService; } /// @@ -155,51 +151,4 @@ public async Task ReorderChecklist(Guid taskId, [FromBody] List.Fail(ex.Message)); } } - - private bool HasElevatedAccess() - { - return User.IsInRole("Admin") || User.IsInRole("Manager"); - } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (!Guid.TryParse(userIdClaim, out var userId)) - throw new UnauthorizedAccessException("Invalid user context"); - - return userId; - } - - private async Task EnsureTaskReadAccessAsync(Guid taskId) - { - var task = await _taskService.GetById(taskId); - - if (HasElevatedAccess()) - return task; - - var currentUserId = GetCurrentUserId(); - var canRead = await _projectService.HasReadAccess(task.ProjectId, currentUserId, elevatedAccess: false); - - if (!canRead) - throw new UnauthorizedAccessException("You do not have read access to this task"); - - return task; - } - - private async Task EnsureTaskWriteAccessAsync(Guid taskId) - { - var task = await _taskService.GetById(taskId); - - if (HasElevatedAccess()) - return task; - - var currentUserId = GetCurrentUserId(); - var canWrite = await _projectService.HasWriteAccess(task.ProjectId, currentUserId, elevatedAccess: false); - - if (!canWrite) - throw new UnauthorizedAccessException("You do not have write access to this task"); - - return task; - } } diff --git a/Backend/Controllers/CommentController.cs b/Backend/Controllers/CommentController.cs index 36e6382..f9f9726 100644 --- a/Backend/Controllers/CommentController.cs +++ b/Backend/Controllers/CommentController.cs @@ -5,29 +5,19 @@ namespace Backend.Controllers; using Microsoft.Extensions.DependencyInjection; using Backend.Models.DTOs; using Backend.Services.Interfaces; -using System.Security.Claims; [ApiController] [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/tasks/{taskId}/comments")] [Authorize] -public class CommentController : ControllerBase +public class CommentController : TaskAccessController { private readonly ICommentService _service; - private readonly ITaskService _taskService; - private readonly IProjectService? _projectService; - public CommentController(ICommentService service, ITaskService taskService) - : this(service, taskService, null) - { - } - - [ActivatorUtilitiesConstructor] - public CommentController(ICommentService service, ITaskService taskService, IProjectService? projectService) + public CommentController(ICommentService service, ITaskService taskService, IProjectService projectService) + : base(taskService, projectService) { _service = service; - _taskService = taskService; - _projectService = projectService; } /// @@ -130,69 +120,4 @@ public async Task DeleteComment(Guid taskId, Guid commentId) return Forbid(); } } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (!Guid.TryParse(userIdClaim, out var userId)) - throw new UnauthorizedAccessException("Invalid user context"); - - return userId; - } - - private bool HasElevatedAccess() - { - return User.IsInRole("Admin") || User.IsInRole("Manager"); - } - - private async Task EnsureTaskReadAccessAsync(Guid taskId) - { - var task = await _taskService.GetById(taskId); - - if (HasElevatedAccess()) - return task; - - var currentUserId = GetCurrentUserId(); - - if (_projectService != null) - { - var canRead = await _projectService.HasReadAccess(task.ProjectId, currentUserId, elevatedAccess: false); - - if (!canRead) - throw new UnauthorizedAccessException("You do not have read access to this task"); - - return task; - } - - if (task.AssignedUserId != currentUserId) - throw new UnauthorizedAccessException("You can only access your own tasks"); - - return task; - } - - private async Task EnsureTaskWriteAccessAsync(Guid taskId) - { - var task = await _taskService.GetById(taskId); - - if (HasElevatedAccess()) - return task; - - var currentUserId = GetCurrentUserId(); - - if (_projectService != null) - { - var canWrite = await _projectService.HasWriteAccess(task.ProjectId, currentUserId, elevatedAccess: false); - - if (!canWrite) - throw new UnauthorizedAccessException("You do not have write access to this task"); - - return task; - } - - if (task.AssignedUserId != currentUserId) - throw new UnauthorizedAccessException("You can only access your own tasks"); - - return task; - } } diff --git a/Backend/Controllers/DashboardController.cs b/Backend/Controllers/DashboardController.cs index 902c852..6e404d7 100644 --- a/Backend/Controllers/DashboardController.cs +++ b/Backend/Controllers/DashboardController.cs @@ -8,7 +8,7 @@ namespace Backend.Controllers; [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/dashboard")] [Authorize] -public class DashboardController : ControllerBase +public class DashboardController : BaseApiController { private readonly IDashboardService _service; diff --git a/Backend/Controllers/LabelController.cs b/Backend/Controllers/LabelController.cs index 5b2cf75..9a8e2e6 100644 --- a/Backend/Controllers/LabelController.cs +++ b/Backend/Controllers/LabelController.cs @@ -4,13 +4,12 @@ namespace Backend.Controllers; using Microsoft.AspNetCore.Mvc; using Backend.Models.DTOs; using Backend.Services.Interfaces; -using System.Security.Claims; [ApiController] [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/projects/{projectId}/labels")] [Authorize] -public class LabelController : ControllerBase +public class LabelController : BaseApiController { private readonly ILabelService _labelService; private readonly IProjectService _projectService; @@ -257,21 +256,6 @@ public async Task GetTaskLabels(Guid projectId, Guid taskId) } } - private bool HasElevatedAccess() - { - return User.IsInRole("Admin") || User.IsInRole("Manager"); - } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (!Guid.TryParse(userIdClaim, out var userId)) - throw new UnauthorizedAccessException("Invalid user context"); - - return userId; - } - private async Task EnsureTaskAccessInProjectAsync(Guid projectId, Guid taskId) { var task = await _taskService.GetById(taskId); diff --git a/Backend/Controllers/NotificationController.cs b/Backend/Controllers/NotificationController.cs index eefca36..e2252e1 100644 --- a/Backend/Controllers/NotificationController.cs +++ b/Backend/Controllers/NotificationController.cs @@ -4,13 +4,12 @@ namespace Backend.Controllers; using Microsoft.AspNetCore.Mvc; using Backend.Models.DTOs; using Backend.Services.Interfaces; -using System.Security.Claims; [ApiController] [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/notifications")] [Authorize] -public class NotificationController : ControllerBase +public class NotificationController : BaseApiController { private readonly INotificationService _notificationService; @@ -150,10 +149,4 @@ public async Task DeleteAllNotifications() return StatusCode(500, ApiResponseDto.Fail($"Internal server error: {ex.Message}")); } } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier); - return Guid.Parse(userIdClaim?.Value ?? throw new UnauthorizedAccessException("User ID not found in token")); - } } diff --git a/Backend/Controllers/ProfileController.cs b/Backend/Controllers/ProfileController.cs index 0fe653c..300d6e2 100644 --- a/Backend/Controllers/ProfileController.cs +++ b/Backend/Controllers/ProfileController.cs @@ -1,6 +1,5 @@ namespace Backend.Controllers; -using System.Security.Claims; using Backend.Models.DTOs; using Backend.Services.Interfaces; using Microsoft.AspNetCore.Authorization; @@ -10,7 +9,7 @@ namespace Backend.Controllers; [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/profile")] [Authorize] -public class ProfileController : ControllerBase +public class ProfileController : BaseApiController { private readonly IProfileService _profileService; @@ -46,14 +45,4 @@ public async Task DeleteAccount([FromBody] DeleteAccountDto dto) await _profileService.DeleteAccountAsync(GetCurrentUserId(), dto); return Ok(ApiResponseDto.Ok(null, "Account deleted")); } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (!Guid.TryParse(userIdClaim, out var userId)) - throw new UnauthorizedAccessException("Invalid user context"); - - return userId; - } } diff --git a/Backend/Controllers/ProjectController.cs b/Backend/Controllers/ProjectController.cs index 740a6ca..dd2398c 100644 --- a/Backend/Controllers/ProjectController.cs +++ b/Backend/Controllers/ProjectController.cs @@ -4,13 +4,12 @@ namespace Backend.Controllers; using Microsoft.AspNetCore.Authorization; using Backend.Models.DTOs; using Backend.Services.Interfaces; -using System.Security.Claims; [ApiController] [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/projects")] [Authorize] -public class ProjectController : ControllerBase +public class ProjectController : BaseApiController { private readonly IProjectService _service; @@ -23,7 +22,7 @@ public ProjectController(IProjectService service) [Authorize(Policy = "ProjectRead")] public async Task GetAll() { - var projects = await _service.GetAccessibleProjects(GetCurrentUserId(), HasElevatedAccess()); + var projects = await _service.GetAccessibleProjects(GetCurrentUserId(), HasElevatedAccess(includeManager: false)); return Ok(ApiResponseDto>.Ok(projects, "Projects retrieved")); } @@ -42,7 +41,7 @@ public async Task GetById(Guid id) if (!await _service.ProjectExists(id)) return NotFound(ApiResponseDto.Fail("Project not found")); - var canRead = await _service.HasReadAccess(id, GetCurrentUserId(), HasElevatedAccess()); + var canRead = await _service.HasReadAccess(id, GetCurrentUserId(), HasElevatedAccess(includeManager: false)); if (!canRead) return Forbid(); @@ -60,7 +59,7 @@ public async Task Update(Guid id, [FromBody] ProjectDto dto) if (!await _service.ProjectExists(id)) return NotFound(ApiResponseDto.Fail("Project not found")); - var canWrite = await _service.HasWriteAccess(id, GetCurrentUserId(), HasElevatedAccess()); + var canWrite = await _service.HasWriteAccess(id, GetCurrentUserId(), HasElevatedAccess(includeManager: false)); if (!canWrite) return Forbid(); @@ -78,7 +77,7 @@ public async Task Delete(Guid id) if (!await _service.ProjectExists(id)) return NotFound(ApiResponseDto.Fail("Project not found")); - var canWrite = await _service.HasWriteAccess(id, GetCurrentUserId(), HasElevatedAccess()); + var canWrite = await _service.HasWriteAccess(id, GetCurrentUserId(), HasElevatedAccess(includeManager: false)); if (!canWrite) return Forbid(); @@ -96,7 +95,7 @@ public async Task GetMembers(Guid id) if (!await _service.ProjectExists(id)) return NotFound(ApiResponseDto.Fail("Project not found")); - var canRead = await _service.HasReadAccess(id, GetCurrentUserId(), HasElevatedAccess()); + var canRead = await _service.HasReadAccess(id, GetCurrentUserId(), HasElevatedAccess(includeManager: false)); if (!canRead) return Forbid(); @@ -112,7 +111,7 @@ public async Task AddMember(Guid id, [FromBody] AddProjectMemberD return NotFound(ApiResponseDto.Fail("Project not found")); var currentUserId = GetCurrentUserId(); - var canManage = await _service.HasManageAccess(id, currentUserId, HasElevatedAccess()); + var canManage = await _service.HasManageAccess(id, currentUserId, HasElevatedAccess(includeManager: false)); if (!canManage) return Forbid(); @@ -139,7 +138,7 @@ public async Task GetInvitations(Guid id) return NotFound(ApiResponseDto.Fail("Project not found")); var currentUserId = GetCurrentUserId(); - var canManage = await _service.HasManageAccess(id, currentUserId, HasElevatedAccess()); + var canManage = await _service.HasManageAccess(id, currentUserId, HasElevatedAccess(includeManager: false)); if (!canManage) return Forbid(); @@ -155,7 +154,7 @@ public async Task CreateInvitation(Guid id, [FromBody] CreateProj return NotFound(ApiResponseDto.Fail("Project not found")); var currentUserId = GetCurrentUserId(); - var canManage = await _service.HasManageAccess(id, currentUserId, HasElevatedAccess()); + var canManage = await _service.HasManageAccess(id, currentUserId, HasElevatedAccess(includeManager: false)); if (!canManage) return Forbid(); @@ -225,19 +224,4 @@ public async Task AcceptInvitation(Guid invitationId) return BadRequest(ApiResponseDto.Fail(ex.Message)); } } - - private bool HasElevatedAccess() - { - return User.IsInRole("Admin"); - } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (!Guid.TryParse(userIdClaim, out var userId)) - throw new UnauthorizedAccessException("Invalid user context"); - - return userId; - } } diff --git a/Backend/Controllers/SettingsController.cs b/Backend/Controllers/SettingsController.cs index 83b4412..4680359 100644 --- a/Backend/Controllers/SettingsController.cs +++ b/Backend/Controllers/SettingsController.cs @@ -1,6 +1,5 @@ namespace Backend.Controllers; -using System.Security.Claims; using Backend.Models.DTOs; using Backend.Services.Interfaces; using Microsoft.AspNetCore.Authorization; @@ -10,7 +9,7 @@ namespace Backend.Controllers; [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/settings")] [Authorize] -public class SettingsController : ControllerBase +public class SettingsController : BaseApiController { private readonly ISettingsService _settingsService; @@ -34,14 +33,4 @@ public async Task UpdateMySettings([FromBody] UpdateUserSettingsD var settings = await _settingsService.UpdateCurrentUserSettingsAsync(userId, dto); return Ok(ApiResponseDto.Ok(settings, "Settings updated")); } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (!Guid.TryParse(userIdClaim, out var userId)) - throw new UnauthorizedAccessException("Invalid user context"); - - return userId; - } } diff --git a/Backend/Controllers/TaskAccessController.cs b/Backend/Controllers/TaskAccessController.cs new file mode 100644 index 0000000..4775d45 --- /dev/null +++ b/Backend/Controllers/TaskAccessController.cs @@ -0,0 +1,61 @@ +namespace Backend.Controllers; + +using Backend.Services.Interfaces; + +/// +/// Base controller for task-scoped endpoints that need read/write access checks. +/// Provides EnsureTaskReadAccessAsync and EnsureTaskWriteAccessAsync, eliminating +/// their duplication across TaskController, CommentController, ChecklistController, +/// and TaskAttachmentController. +/// +public abstract class TaskAccessController : BaseApiController +{ + protected readonly ITaskService TaskService; + protected readonly IProjectService ProjectService; + + protected TaskAccessController(ITaskService taskService, IProjectService projectService) + { + TaskService = taskService; + ProjectService = projectService; + } + + /// + /// Ensures the current user has read access to the task's project. + /// Admins and Managers bypass the check. + /// + protected async Task EnsureTaskReadAccessAsync(Guid taskId) + { + var task = await TaskService.GetById(taskId); + + if (HasElevatedAccess()) + return task; + + var currentUserId = GetCurrentUserId(); + var canRead = await ProjectService.HasReadAccess(task.ProjectId, currentUserId, elevatedAccess: false); + + if (!canRead) + throw new UnauthorizedAccessException("You do not have read access to this task"); + + return task; + } + + /// + /// Ensures the current user has write access to the task's project. + /// Admins and Managers bypass the check. + /// + protected async Task EnsureTaskWriteAccessAsync(Guid taskId) + { + var task = await TaskService.GetById(taskId); + + if (HasElevatedAccess()) + return task; + + var currentUserId = GetCurrentUserId(); + var canWrite = await ProjectService.HasWriteAccess(task.ProjectId, currentUserId, elevatedAccess: false); + + if (!canWrite) + throw new UnauthorizedAccessException("You do not have write access to this task"); + + return task; + } +} diff --git a/Backend/Controllers/TaskAttachmentController.cs b/Backend/Controllers/TaskAttachmentController.cs index 4406532..25da254 100644 --- a/Backend/Controllers/TaskAttachmentController.cs +++ b/Backend/Controllers/TaskAttachmentController.cs @@ -5,36 +5,26 @@ namespace Backend.Controllers; using Microsoft.Extensions.DependencyInjection; using Backend.Models.DTOs; using Backend.Services.Interfaces; -using System.Security.Claims; using Microsoft.AspNetCore.StaticFiles; [ApiController] [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/tasks/{taskId}/attachments")] [Authorize] -public class TaskAttachmentController : ControllerBase +public class TaskAttachmentController : TaskAccessController { private readonly ITaskAttachmentService _attachmentService; - private readonly ITaskService _taskService; - private readonly IProjectService? _projectService; private readonly IWebHostEnvironment? _hostEnvironment; private const long MaxUploadFileSizeBytes = 10 * 1024 * 1024; - public TaskAttachmentController(ITaskAttachmentService attachmentService, ITaskService taskService) - : this(attachmentService, taskService, null, null) - { - } - - [ActivatorUtilitiesConstructor] public TaskAttachmentController( ITaskAttachmentService attachmentService, ITaskService taskService, - IProjectService? projectService, - IWebHostEnvironment? hostEnvironment) + IProjectService projectService, + IWebHostEnvironment? hostEnvironment = null) + : base(taskService, projectService) { _attachmentService = attachmentService; - _taskService = taskService; - _projectService = projectService; _hostEnvironment = hostEnvironment; } @@ -265,21 +255,6 @@ public async Task DeleteAttachment(Guid taskId, Guid attachmentId } } - private bool HasElevatedAccess() - { - return User.IsInRole("Admin") || User.IsInRole("Manager"); - } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (!Guid.TryParse(userIdClaim, out var userId)) - throw new UnauthorizedAccessException("Invalid user context"); - - return userId; - } - private string GetTaskAttachmentDirectoryPath(Guid taskId) { var basePath = _hostEnvironment?.ContentRootPath ?? Directory.GetCurrentDirectory(); @@ -316,54 +291,4 @@ private void TryDeletePhysicalFile(string storagePath) // Physical file cleanup should not fail the API flow. } } - - private async Task EnsureTaskReadAccessAsync(Guid taskId) - { - var task = await _taskService.GetById(taskId); - - if (HasElevatedAccess()) - return task; - - var currentUserId = GetCurrentUserId(); - - if (_projectService != null) - { - var canRead = await _projectService.HasReadAccess(task.ProjectId, currentUserId, elevatedAccess: false); - - if (!canRead) - throw new UnauthorizedAccessException("You do not have read access to this task"); - - return task; - } - - if (task.AssignedUserId != currentUserId) - throw new UnauthorizedAccessException("You can only access your own tasks"); - - return task; - } - - private async Task EnsureTaskWriteAccessAsync(Guid taskId) - { - var task = await _taskService.GetById(taskId); - - if (HasElevatedAccess()) - return task; - - var currentUserId = GetCurrentUserId(); - - if (_projectService != null) - { - var canWrite = await _projectService.HasWriteAccess(task.ProjectId, currentUserId, elevatedAccess: false); - - if (!canWrite) - throw new UnauthorizedAccessException("You do not have write access to this task"); - - return task; - } - - if (task.AssignedUserId != currentUserId) - throw new UnauthorizedAccessException("You can only access your own tasks"); - - return task; - } } diff --git a/Backend/Controllers/TaskController.cs b/Backend/Controllers/TaskController.cs index 8666a8c..4aa8294 100644 --- a/Backend/Controllers/TaskController.cs +++ b/Backend/Controllers/TaskController.cs @@ -5,21 +5,19 @@ namespace Backend.Controllers; using Backend.Models.DTOs; using Backend.Services.Interfaces; using Backend.Models.Entities; -using System.Security.Claims; [ApiController] [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/tasks")] [Authorize] -public class TaskController : ControllerBase +public class TaskController : TaskAccessController { private readonly ITaskService _service; - private readonly IProjectService _projectService; public TaskController(ITaskService service, IProjectService projectService) + : base(service, projectService) { _service = service; - _projectService = projectService; } [HttpPost] @@ -28,10 +26,10 @@ public async Task Create([FromBody] CreateTaskDto dto) { var currentUserId = GetCurrentUserId(); - if (!await _projectService.ProjectExists(dto.ProjectId)) + if (!await ProjectService.ProjectExists(dto.ProjectId)) return NotFound(ApiResponseDto.Fail("Project not found")); - var canWrite = await _projectService.HasWriteAccess(dto.ProjectId, currentUserId, HasElevatedAccess()); + var canWrite = await ProjectService.HasWriteAccess(dto.ProjectId, currentUserId, HasElevatedAccess()); if (!canWrite) return Forbid(); @@ -54,7 +52,7 @@ public async Task GetAll( if (!HasElevatedAccess()) { var currentUserId = GetCurrentUserId(); - var accessibleProjects = await _projectService.GetAccessibleProjects(currentUserId, elevatedAccess: false); + var accessibleProjects = await ProjectService.GetAccessibleProjects(currentUserId, elevatedAccess: false); projectIds = accessibleProjects.Select(project => project.Id).ToList(); } @@ -139,51 +137,4 @@ public async Task UpdateChecklistItemCompletion(Guid id, Guid che var item = await _service.UpdateChecklistItemCompletion(id, checklistItemId, dto.IsCompleted ?? false); return Ok(ApiResponseDto.Ok(item, "Checklist item updated")); } - - private bool HasElevatedAccess() - { - return User.IsInRole("Admin") || User.IsInRole("Manager"); - } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (!Guid.TryParse(userIdClaim, out var userId)) - throw new UnauthorizedAccessException("Invalid user context"); - - return userId; - } - - private async Task EnsureTaskReadAccessAsync(Guid taskId) - { - var task = await _service.GetById(taskId); - - if (HasElevatedAccess()) - return task; - - var currentUserId = GetCurrentUserId(); - var canRead = await _projectService.HasReadAccess(task.ProjectId, currentUserId, elevatedAccess: false); - - if (!canRead) - throw new UnauthorizedAccessException("You do not have read access to this task"); - - return task; - } - - private async Task EnsureTaskWriteAccessAsync(Guid taskId) - { - var task = await _service.GetById(taskId); - - if (HasElevatedAccess()) - return task; - - var currentUserId = GetCurrentUserId(); - var canWrite = await _projectService.HasWriteAccess(task.ProjectId, currentUserId, elevatedAccess: false); - - if (!canWrite) - throw new UnauthorizedAccessException("You do not have write access to this task"); - - return task; - } } diff --git a/Backend/Controllers/TaskWatcherController.cs b/Backend/Controllers/TaskWatcherController.cs index 6e8f85c..5e2704c 100644 --- a/Backend/Controllers/TaskWatcherController.cs +++ b/Backend/Controllers/TaskWatcherController.cs @@ -4,13 +4,12 @@ namespace Backend.Controllers; using Microsoft.AspNetCore.Mvc; using Backend.Models.DTOs; using Backend.Services.Interfaces; -using System.Security.Claims; [ApiController] [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/tasks/{taskId}/watchers")] [Authorize] -public class TaskWatcherController : ControllerBase +public class TaskWatcherController : BaseApiController { private readonly ITaskWatcherService _watcherService; private readonly ITaskService _taskService; @@ -209,21 +208,6 @@ public async Task IsWatching(Guid taskId) } } - private bool HasElevatedAccess() - { - return User.IsInRole("Admin") || User.IsInRole("Manager"); - } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (!Guid.TryParse(userIdClaim, out var userId)) - throw new UnauthorizedAccessException("Invalid user context"); - - return userId; - } - private async Task EnsureTaskAccessAsync(Guid taskId) { var task = await _taskService.GetById(taskId); diff --git a/Backend/Controllers/UserController.cs b/Backend/Controllers/UserController.cs index 6f9a6d9..e69f6b0 100644 --- a/Backend/Controllers/UserController.cs +++ b/Backend/Controllers/UserController.cs @@ -4,13 +4,12 @@ namespace Backend.Controllers; using Microsoft.AspNetCore.Mvc; using Backend.Models.DTOs; using Backend.Services.Interfaces; -using System.Security.Claims; [ApiController] [Asp.Versioning.ApiVersion("1.0")] [Route("api/v{version:apiVersion}/users")] [Authorize] -public class UserController : ControllerBase +public class UserController : BaseApiController { private readonly IUserService _service; @@ -37,19 +36,4 @@ public async Task GetById(Guid id) var user = await _service.GetById(id); return Ok(ApiResponseDto.Ok(user, "User retrieved")); } - - private bool HasElevatedAccess() - { - return User.IsInRole("Admin") || User.IsInRole("Manager"); - } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (!Guid.TryParse(userIdClaim, out var userId)) - throw new UnauthorizedAccessException("Invalid user context"); - - return userId; - } } \ No newline at end of file diff --git a/Backend/Data/AppDbContext.cs b/Backend/Data/AppDbContext.cs index 46c3154..7dc100e 100644 --- a/Backend/Data/AppDbContext.cs +++ b/Backend/Data/AppDbContext.cs @@ -19,6 +19,7 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Attachments { get; set; } public DbSet TaskWatchers { get; set; } public DbSet Notifications { get; set; } + public DbSet RefreshTokens { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -162,6 +163,23 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(tw => tw.UserId) .OnDelete(DeleteBehavior.Cascade); + // RefreshToken Configuration + modelBuilder.Entity() + .HasIndex(rt => rt.Token) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(rt => rt.User) + .WithMany() + .HasForeignKey(rt => rt.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(rt => rt.ReplacedByToken) + .WithMany() + .HasForeignKey(rt => rt.ReplacedByTokenId) + .OnDelete(DeleteBehavior.SetNull); + // Notification Configuration modelBuilder.Entity().HasQueryFilter(n => !n.IsDeleted); modelBuilder.Entity() diff --git a/Backend/Data/JwtSettingsOptions.cs b/Backend/Data/JwtSettingsOptions.cs index 5e98856..a91ec33 100644 --- a/Backend/Data/JwtSettingsOptions.cs +++ b/Backend/Data/JwtSettingsOptions.cs @@ -9,7 +9,8 @@ public class JwtSettingsOptions public string Secret { get; set; } = string.Empty; public string Issuer { get; set; } = string.Empty; public string Audience { get; set; } = string.Empty; - public int ExpirationHours { get; set; } = 24; + public int AccessTokenExpirationMinutes { get; set; } = 15; + public int RefreshTokenExpirationDays { get; set; } = 7; public IEnumerable Validate() { @@ -24,7 +25,10 @@ public IEnumerable Validate() if (string.IsNullOrWhiteSpace(Audience)) yield return $"{SectionName}:Audience is required"; - if (ExpirationHours <= 0) - yield return $"{SectionName}:ExpirationHours must be greater than 0"; + if (AccessTokenExpirationMinutes <= 0) + yield return $"{SectionName}:AccessTokenExpirationMinutes must be greater than 0"; + + if (RefreshTokenExpirationDays <= 0) + yield return $"{SectionName}:RefreshTokenExpirationDays must be greater than 0"; } } diff --git a/Backend/Extensions/DatabaseExtensions.cs b/Backend/Extensions/DatabaseExtensions.cs index 9cf7add..3f0da53 100644 --- a/Backend/Extensions/DatabaseExtensions.cs +++ b/Backend/Extensions/DatabaseExtensions.cs @@ -1,20 +1,6 @@ using Backend.Data; -using Backend.Middleware; -using Backend.Services.Interfaces; -using Backend.Services.Implementations; -using Backend.Models.DTOs; -using Backend.Validation; -using FluentValidation; -using FluentValidation.AspNetCore; -using Microsoft.IdentityModel.Tokens; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Mvc; -using Microsoft.OpenApi.Models; -using System.Text; -using AspNetCoreRateLimit; -using Microsoft.Extensions.Options; -using Asp.Versioning; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Npgsql; namespace Backend.Extensions; diff --git a/Backend/Models/DTOs/AuthResponseDto.cs b/Backend/Models/DTOs/AuthResponseDto.cs index c4a6c20..98be927 100644 --- a/Backend/Models/DTOs/AuthResponseDto.cs +++ b/Backend/Models/DTOs/AuthResponseDto.cs @@ -5,6 +5,8 @@ public class AuthResponseDto public bool Success { get; set; } public string Message { get; set; } = string.Empty; public string? Token { get; set; } + public string? RefreshToken { get; set; } + public DateTime? RefreshTokenExpiry { get; set; } public UserDto? User { get; set; } } diff --git a/Backend/Models/DTOs/RefreshTokenDto.cs b/Backend/Models/DTOs/RefreshTokenDto.cs new file mode 100644 index 0000000..dcf9a98 --- /dev/null +++ b/Backend/Models/DTOs/RefreshTokenDto.cs @@ -0,0 +1,6 @@ +namespace Backend.Models.DTOs; + +public class RefreshTokenDto +{ + public string RefreshToken { get; set; } = string.Empty; +} diff --git a/Backend/Models/Entities/RefreshToken.cs b/Backend/Models/Entities/RefreshToken.cs new file mode 100644 index 0000000..84786fb --- /dev/null +++ b/Backend/Models/Entities/RefreshToken.cs @@ -0,0 +1,31 @@ +namespace Backend.Models.Entities; + +public class RefreshToken +{ + public Guid Id { get; set; } + + public string Token { get; set; } = string.Empty; + + public Guid UserId { get; set; } + + public User User { get; set; } = null!; + + public DateTime ExpiresAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? RevokedAt { get; set; } + + /// + /// When this token is rotated, points to the replacement token for audit trail. + /// + public Guid? ReplacedByTokenId { get; set; } + + public RefreshToken? ReplacedByToken { get; set; } + + public bool IsRevoked => RevokedAt != null; + + public bool IsExpired => DateTime.UtcNow >= ExpiresAt; + + public bool IsActive => !IsRevoked && !IsExpired; +} diff --git a/Backend/Services/Implementations/AuthService.cs b/Backend/Services/Implementations/AuthService.cs index 2b4bf06..748f735 100644 --- a/Backend/Services/Implementations/AuthService.cs +++ b/Backend/Services/Implementations/AuthService.cs @@ -1,16 +1,17 @@ namespace Backend.Services.Implementations; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using BCrypt.Net; -using Backend.Models.DTOs; using Backend.Data; +using Backend.Models.DTOs; using Backend.Models.Entities; using Backend.Services.Interfaces; using Microsoft.EntityFrameworkCore; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; public class AuthService : IAuthService { @@ -51,10 +52,16 @@ public async Task Register(RegisterDto dto) _context.Users.Add(user); await _context.SaveChangesAsync(); + var accessToken = GenerateJwtToken(user); + var refreshToken = await CreateRefreshTokenAsync(user.Id); + return new AuthResponseDto { Success = true, Message = "User registered successfully", + Token = accessToken, + RefreshToken = refreshToken.Token, + RefreshTokenExpiry = refreshToken.ExpiresAt, User = new UserDto { Id = user.Id, @@ -80,13 +87,16 @@ public async Task Login(LoginDto dto) }; } - var token = GenerateJwtToken(user); + var accessToken = GenerateJwtToken(user); + var refreshToken = await CreateRefreshTokenAsync(user.Id); return new AuthResponseDto { Success = true, Message = "Login successful", - Token = token, + Token = accessToken, + RefreshToken = refreshToken.Token, + RefreshTokenExpiry = refreshToken.ExpiresAt, User = new UserDto { Id = user.Id, @@ -96,6 +106,121 @@ public async Task Login(LoginDto dto) }; } + public async Task RefreshTokenAsync(string refreshToken) + { + var storedToken = await _context.RefreshTokens + .Include(rt => rt.User) + .FirstOrDefaultAsync(rt => rt.Token == refreshToken); + + if (storedToken == null) + { + return new AuthResponseDto + { + Success = false, + Message = "Invalid refresh token" + }; + } + + if (storedToken.IsRevoked) + { + // Possible token reuse detected — revoke the entire family + _logger.LogWarning( + "Revoked refresh token reuse detected for UserId={UserId}, Token={TokenPrefix}...", + storedToken.UserId, + storedToken.Token[..8]); + + await RevokeAllTokensAsync(storedToken.UserId); + + return new AuthResponseDto + { + Success = false, + Message = "Token has been revoked. All sessions have been invalidated for security." + }; + } + + if (storedToken.IsExpired) + { + return new AuthResponseDto + { + Success = false, + Message = "Refresh token has expired" + }; + } + + if (storedToken.User.IsDeleted) + { + return new AuthResponseDto + { + Success = false, + Message = "User account has been deleted" + }; + } + + // Rotate: revoke old token and create new one + storedToken.RevokedAt = DateTime.UtcNow; + var newRefreshToken = await CreateRefreshTokenAsync(storedToken.UserId); + + storedToken.ReplacedByTokenId = newRefreshToken.Id; + await _context.SaveChangesAsync(); + + var accessToken = GenerateJwtToken(storedToken.User); + + _logger.LogInformation( + "Rotated refresh token for UserId={UserId}", + storedToken.UserId); + + return new AuthResponseDto + { + Success = true, + Message = "Token refreshed successfully", + Token = accessToken, + RefreshToken = newRefreshToken.Token, + RefreshTokenExpiry = newRefreshToken.ExpiresAt, + User = new UserDto + { + Id = storedToken.User.Id, + Name = storedToken.User.Name, + Email = storedToken.User.Email + } + }; + } + + public async Task LogoutAsync(string refreshToken) + { + var storedToken = await _context.RefreshTokens + .FirstOrDefaultAsync(rt => rt.Token == refreshToken); + + if (storedToken == null || storedToken.IsRevoked) + return; // Idempotent — no error for already-revoked or missing tokens + + storedToken.RevokedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + _logger.LogInformation( + "Revoked refresh token for UserId={UserId} (logout)", + storedToken.UserId); + } + + public async Task RevokeAllTokensAsync(Guid userId) + { + var activeTokens = await _context.RefreshTokens + .Where(rt => rt.UserId == userId && rt.RevokedAt == null) + .ToListAsync(); + + var now = DateTime.UtcNow; + foreach (var token in activeTokens) + { + token.RevokedAt = now; + } + + await _context.SaveChangesAsync(); + + _logger.LogInformation( + "Revoked {Count} active refresh token(s) for UserId={UserId}", + activeTokens.Count, + userId); + } + private string GenerateJwtToken(User user) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)); @@ -113,20 +238,42 @@ private string GenerateJwtToken(User user) issuer: _jwtSettings.Issuer, audience: _jwtSettings.Audience, claims: claims, - expires: DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours), + expires: DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpirationMinutes), signingCredentials: credentials ); _logger.LogInformation( - "Generated JWT token for UserId={UserId} with issuer={Issuer}, audience={Audience}, expirationHours={ExpirationHours}", + "Generated JWT token for UserId={UserId} with issuer={Issuer}, audience={Audience}, expirationMinutes={ExpirationMinutes}", user.Id, _jwtSettings.Issuer, _jwtSettings.Audience, - _jwtSettings.ExpirationHours); + _jwtSettings.AccessTokenExpirationMinutes); return new JwtSecurityTokenHandler().WriteToken(token); } + private async Task CreateRefreshTokenAsync(Guid userId) + { + var refreshToken = new RefreshToken + { + Token = GenerateRefreshTokenString(), + UserId = userId, + ExpiresAt = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpirationDays), + CreatedAt = DateTime.UtcNow + }; + + _context.RefreshTokens.Add(refreshToken); + await _context.SaveChangesAsync(); + + return refreshToken; + } + + private static string GenerateRefreshTokenString() + { + var randomBytes = RandomNumberGenerator.GetBytes(64); + return Convert.ToBase64String(randomBytes); + } + private static string NormalizeEmail(string email) { return email.Trim().ToLowerInvariant(); diff --git a/Backend/Services/Interfaces/IAuthService.cs b/Backend/Services/Interfaces/IAuthService.cs index 6a38d02..3152ccf 100644 --- a/Backend/Services/Interfaces/IAuthService.cs +++ b/Backend/Services/Interfaces/IAuthService.cs @@ -7,4 +7,7 @@ public interface IAuthService { Task Register(RegisterDto dto); Task Login(LoginDto dto); + Task RefreshTokenAsync(string refreshToken); + Task LogoutAsync(string refreshToken); + Task RevokeAllTokensAsync(Guid userId); } diff --git a/Backend/appsettings.json b/Backend/appsettings.json index 69c6c19..33cb965 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -21,7 +21,8 @@ "JwtSettings": { "Issuer": "GDG-TaskFlow", "Audience": "GDG-TaskFlow-Users", - "ExpirationHours": 24, + "AccessTokenExpirationMinutes": 15, + "RefreshTokenExpirationDays": 7, "Secret": "super-secret-key-that-should-be-replaced-in-production" }, "Redis": { From f47f451a1741b2f8047587c5321cbb2b0af91e41 Mon Sep 17 00:00:00 2001 From: nutanAhir688 <23cp042@bvmengineering.ac.in> Date: Mon, 22 Jun 2026 20:29:42 +0530 Subject: [PATCH 2/2] Test: update auth test case --- .../Controllers/CommentControllerTests.cs | 34 +++- Backend.Tests/Services/AuthServiceTests.cs | 152 +++++++++++++++++- 2 files changed, 182 insertions(+), 4 deletions(-) diff --git a/Backend.Tests/Controllers/CommentControllerTests.cs b/Backend.Tests/Controllers/CommentControllerTests.cs index 26b1d80..64ba861 100644 --- a/Backend.Tests/Controllers/CommentControllerTests.cs +++ b/Backend.Tests/Controllers/CommentControllerTests.cs @@ -16,7 +16,7 @@ public async Task GetComments_WhenUserIsNotTaskOwner_ReturnsForbid() var currentUserId = Guid.NewGuid(); var task = CreateTask(assignedUserId: Guid.NewGuid()); var commentService = new FakeCommentService(); - var controller = CreateController(task, commentService, currentUserId, "User"); + var controller = CreateController(task, commentService, currentUserId, "User", grantAccess: false); var result = await controller.GetComments(task.Id); @@ -92,9 +92,9 @@ public async Task UpdateComment_PassesTaskAndUserIdsToService() Assert.Equal(currentUserId, commentService.LastUpdateUserId); } - private static CommentController CreateController(TaskItem task, FakeCommentService commentService, Guid currentUserId, string role) + private static CommentController CreateController(TaskItem task, FakeCommentService commentService, Guid currentUserId, string role, bool grantAccess = true) { - var controller = new CommentController(commentService, new FakeTaskService(task)); + var controller = new CommentController(commentService, new FakeTaskService(task), new FakeProjectService(grantAccess)); controller.ControllerContext = new ControllerContext { @@ -204,4 +204,32 @@ public Task DeleteCommentAsync(Guid taskId, Guid commentId, Guid userId) return Task.CompletedTask; } } + + private sealed class FakeProjectService : IProjectService + { + private readonly bool _grantAccess; + + public FakeProjectService(bool grantAccess = true) + { + _grantAccess = grantAccess; + } + + public Task> GetAll() => throw new NotImplementedException(); + public Task> GetAccessibleProjects(Guid userId, bool elevatedAccess) => throw new NotImplementedException(); + public Task Create(ProjectDto dto, Guid creatorUserId) => throw new NotImplementedException(); + public Task GetById(Guid id) => throw new NotImplementedException(); + public Task Update(Guid id, ProjectDto dto) => throw new NotImplementedException(); + public Task Delete(Guid id) => throw new NotImplementedException(); + public Task ProjectExists(Guid id) => Task.FromResult(true); + public Task HasReadAccess(Guid projectId, Guid userId, bool elevatedAccess) => Task.FromResult(_grantAccess); + public Task HasWriteAccess(Guid projectId, Guid userId, bool elevatedAccess) => Task.FromResult(_grantAccess); + public Task HasManageAccess(Guid projectId, Guid userId, bool elevatedAccess) => throw new NotImplementedException(); + public Task> GetMembers(Guid projectId) => throw new NotImplementedException(); + public Task AddMember(Guid projectId, AddProjectMemberDto dto, Guid actorUserId) => throw new NotImplementedException(); + public Task> GetInvitations(Guid projectId) => throw new NotImplementedException(); + public Task CreateInvitation(Guid projectId, CreateProjectInvitationDto dto, Guid actorUserId) => throw new NotImplementedException(); + public Task> GetInvitationsForUser(Guid userId) => throw new NotImplementedException(); + public Task GetInvitationById(Guid invitationId) => throw new NotImplementedException(); + public Task AcceptInvitation(Guid invitationId, Guid actorUserId) => throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/Backend.Tests/Services/AuthServiceTests.cs b/Backend.Tests/Services/AuthServiceTests.cs index 56abf70..e1245fc 100644 --- a/Backend.Tests/Services/AuthServiceTests.cs +++ b/Backend.Tests/Services/AuthServiceTests.cs @@ -76,6 +76,155 @@ await service.Register(new RegisterDto Assert.Equal("Email already registered", duplicateResult.Message); } + [Fact] + public async Task Register_ReturnsRefreshToken() + { + await using var context = CreateContext(); + var service = CreateService(context); + + var result = await service.Register(new RegisterDto + { + Name = "Test User", + Email = "test@example.com", + Password = "Password123!" + }); + + Assert.True(result.Success); + Assert.False(string.IsNullOrWhiteSpace(result.Token)); + Assert.False(string.IsNullOrWhiteSpace(result.RefreshToken)); + Assert.NotNull(result.RefreshTokenExpiry); + Assert.True(result.RefreshTokenExpiry > DateTime.UtcNow); + } + + [Fact] + public async Task Login_ReturnsRefreshToken() + { + await using var context = CreateContext(); + var service = CreateService(context); + + await service.Register(new RegisterDto + { + Name = "Test User", + Email = "test@example.com", + Password = "Password123!" + }); + + var result = await service.Login(new LoginDto + { + Email = "test@example.com", + Password = "Password123!" + }); + + Assert.True(result.Success); + Assert.False(string.IsNullOrWhiteSpace(result.Token)); + Assert.False(string.IsNullOrWhiteSpace(result.RefreshToken)); + Assert.NotNull(result.RefreshTokenExpiry); + } + + [Fact] + public async Task RefreshToken_ReturnsNewAccessAndRefreshToken() + { + await using var context = CreateContext(); + var service = CreateService(context); + + var loginResult = await service.Login(await RegisterAndLogin(service)); + + var refreshResult = await service.RefreshTokenAsync(loginResult.RefreshToken!); + + Assert.True(refreshResult.Success); + Assert.False(string.IsNullOrWhiteSpace(refreshResult.Token)); + Assert.False(string.IsNullOrWhiteSpace(refreshResult.RefreshToken)); + Assert.NotEqual(loginResult.RefreshToken, refreshResult.RefreshToken); + Assert.NotNull(refreshResult.User); + } + + [Fact] + public async Task RefreshToken_RevokesOldToken() + { + await using var context = CreateContext(); + var service = CreateService(context); + + var loginResult = await service.Login(await RegisterAndLogin(service)); + var oldRefreshToken = loginResult.RefreshToken!; + + // First refresh should succeed + var refreshResult = await service.RefreshTokenAsync(oldRefreshToken); + Assert.True(refreshResult.Success); + + // Reusing the old token should fail (token reuse detection) + var reuseResult = await service.RefreshTokenAsync(oldRefreshToken); + Assert.False(reuseResult.Success); + } + + [Fact] + public async Task RefreshToken_RejectsInvalidToken() + { + await using var context = CreateContext(); + var service = CreateService(context); + + var result = await service.RefreshTokenAsync("totally-invalid-token"); + + Assert.False(result.Success); + Assert.Equal("Invalid refresh token", result.Message); + } + + [Fact] + public async Task Logout_RevokesRefreshToken() + { + await using var context = CreateContext(); + var service = CreateService(context); + + var loginResult = await service.Login(await RegisterAndLogin(service)); + + await service.LogoutAsync(loginResult.RefreshToken!); + + // Trying to use the revoked token should fail + var refreshResult = await service.RefreshTokenAsync(loginResult.RefreshToken!); + Assert.False(refreshResult.Success); + } + + [Fact] + public async Task RevokeAll_RevokesAllUserTokens() + { + await using var context = CreateContext(); + var service = CreateService(context); + + var dto = await RegisterAndLogin(service); + + // Login multiple times to create multiple refresh tokens + var login1 = await service.Login(dto); + var login2 = await service.Login(dto); + + // Get the user ID + var userId = login1.User!.Id; + + // Revoke all + await service.RevokeAllTokensAsync(userId); + + // Both tokens should be revoked + var refresh1 = await service.RefreshTokenAsync(login1.RefreshToken!); + var refresh2 = await service.RefreshTokenAsync(login2.RefreshToken!); + + Assert.False(refresh1.Success); + Assert.False(refresh2.Success); + } + + private static async Task RegisterAndLogin(AuthService service) + { + await service.Register(new RegisterDto + { + Name = "Test User", + Email = "test@example.com", + Password = "Password123!" + }); + + return new LoginDto + { + Email = "test@example.com", + Password = "Password123!" + }; + } + private static AppDbContext CreateContext() { var options = new DbContextOptionsBuilder() @@ -92,7 +241,8 @@ private static AuthService CreateService(AppDbContext context) Secret = "test-secret-key-that-is-long-enough-for-hmac", Issuer = "TestIssuer", Audience = "TestAudience", - ExpirationHours = 24 + AccessTokenExpirationMinutes = 15, + RefreshTokenExpirationDays = 7 }); return new AuthService(context, jwtOptions, NullLogger.Instance);