From abb33de5c19c3889fc1fe94962add71155b4ae19 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Fri, 26 Jun 2026 04:32:01 +0800 Subject: [PATCH] update auth for refresh tokens and token invalidation --- .../Configurations/JwtOptions.cs | 3 +- .../Controllers/AuthController.cs | 32 +- VigilCareRecordsAPI/Data/AppDbContext.cs | 2 + .../AuthAuditEventConfiguration.cs | 30 + .../RefreshTokenConfiguration.cs | 25 + ...7_AddRefreshTokensAndAuthAudit.Designer.cs | 713 ++++++++++++++++++ ...0625202947_AddRefreshTokensAndAuthAudit.cs | 97 +++ .../Migrations/AppDbContextModelSnapshot.cs | 115 +++ .../Domain/Entities/AuthAuditEvent.cs | 10 + .../Domain/Entities/RefreshToken.cs | 13 + .../Domain/Enums/AuthAuditEventType.cs | 22 + .../Models/Records/Auth/LoginResponse.cs | 8 +- .../Models/Records/Auth/LogoutRequest.cs | 1 + .../Models/Records/Auth/RefreshRequest.cs | 1 + .../Models/Records/Auth/TokenResponse.cs | 1 + VigilCareRecordsAPI/Program.cs | 8 +- VigilCareRecordsAPI/Services/AuthService.cs | 102 ++- .../Services/Interfaces/IAuthService.cs | 4 +- VigilCareRecordsAPI/appsettings.json | 3 +- docs/vigilcare-records-prd.md | 5 +- 20 files changed, 1183 insertions(+), 12 deletions(-) create mode 100644 VigilCareRecordsAPI/Data/Configurations/AuthAuditEventConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/RefreshTokenConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.Designer.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/AuthAuditEvent.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/RefreshToken.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/AuthAuditEventType.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Auth/LogoutRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Auth/RefreshRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Auth/TokenResponse.cs diff --git a/VigilCareRecordsAPI/Configurations/JwtOptions.cs b/VigilCareRecordsAPI/Configurations/JwtOptions.cs index 95f7c06..e723f4a 100644 --- a/VigilCareRecordsAPI/Configurations/JwtOptions.cs +++ b/VigilCareRecordsAPI/Configurations/JwtOptions.cs @@ -4,5 +4,6 @@ public class JwtOptions public string Secret { get; set; } = null!; public string Issuer { get; set; } = null!; public string Audience { get; set; } = null!; - public int ExpiryMinutes { get; set; } = 480; + public int ExpiryMinutes { get; set; } = 15; + public int RefreshTokenExpiryDays { get; set; } = 7; } \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/AuthController.cs b/VigilCareRecordsAPI/Controllers/AuthController.cs index 9c8c4e1..8dd68fa 100644 --- a/VigilCareRecordsAPI/Controllers/AuthController.cs +++ b/VigilCareRecordsAPI/Controllers/AuthController.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc; /// -/// JWT authentication and current user info. +/// JWT authentication, token refresh, logout, and current user info. /// [ApiController] [Route("api/v1/auth")] @@ -16,7 +16,7 @@ public class AuthController : ControllerBase public AuthController(IAuthService auth) => _auth = auth; /// - /// Authenticates a user and returns a JWT with role claims. + /// Authenticates a user and returns a JWT access token, refresh token, and profile. /// [HttpPost("login")] [AllowAnonymous] @@ -28,6 +28,32 @@ public class AuthController : ControllerBase return Ok(ApiResponse.Ok(result)); } + /// + /// Rotates the refresh token and issues a new access token. + /// + [HttpPost("refresh")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task Refresh([FromBody] RefreshRequest req) + { + var result = await _auth.RefreshAsync(req); + return Ok(ApiResponse.Ok(result)); + } + + /// + /// Revokes the refresh token server-side. + /// + [HttpPost("logout")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task Logout([FromBody] LogoutRequest req) + { + await _auth.LogoutAsync(req); + return NoContent(); + } + /// /// Returns the current authenticated user's profile. /// @@ -42,4 +68,4 @@ public class AuthController : ControllerBase return Ok(ApiResponse.Ok(new UserProfileResponse( user.Id, user.Username, user.FullName, user.Role.ToDbString()))); } -} \ No newline at end of file +} diff --git a/VigilCareRecordsAPI/Data/AppDbContext.cs b/VigilCareRecordsAPI/Data/AppDbContext.cs index 1f5c91e..c50750d 100644 --- a/VigilCareRecordsAPI/Data/AppDbContext.cs +++ b/VigilCareRecordsAPI/Data/AppDbContext.cs @@ -11,6 +11,8 @@ public class AppDbContext : DbContext public DbSet DraftEncounters => Set(); public DbSet DraftObservations => Set(); public DbSet DigitizationEvents => Set(); + public DbSet RefreshTokens => Set(); + public DbSet AuthAuditEvents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/VigilCareRecordsAPI/Data/Configurations/AuthAuditEventConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/AuthAuditEventConfiguration.cs new file mode 100644 index 0000000..8fa1ca5 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/AuthAuditEventConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class AuthAuditEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("auth_audit_events", t => + { + t.HasCheckConstraint("chk_auth_audit_events_event_type", + "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + }); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(e => e.UserId).HasColumnName("user_id").IsRequired(); + builder.Property(e => e.EventType) + .HasColumnName("event_type") + .HasMaxLength(30) + .HasConversion( + v => v.ToDbString(), + v => AuthAuditEventTypeExtensions.FromDbString(v)) + .IsRequired(); + builder.Property(e => e.OccurredAt).HasColumnName("occurred_at").HasDefaultValueSql("NOW()"); + builder.Property(e => e.MetadataJson).HasColumnName("metadata_json").HasColumnType("jsonb"); + + builder.HasOne(e => e.User).WithMany().HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(e => new { e.UserId, e.OccurredAt }); + } +} diff --git a/VigilCareRecordsAPI/Data/Configurations/RefreshTokenConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..9b8c5b7 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("refresh_tokens"); + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(t => t.UserId).HasColumnName("user_id").IsRequired(); + builder.Property(t => t.TokenHash).HasColumnName("token_hash").HasMaxLength(64).IsRequired(); + builder.Property(t => t.ExpiresAt).HasColumnName("expires_at").IsRequired(); + builder.Property(t => t.RevokedAt).HasColumnName("revoked_at"); + builder.Property(t => t.ReplacedByTokenId).HasColumnName("replaced_by_token_id"); + builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + + builder.HasOne(t => t.User).WithMany().HasForeignKey(t => t.UserId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(t => t.ReplacedByToken).WithMany().HasForeignKey(t => t.ReplacedByTokenId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(t => t.TokenHash).IsUnique(); + builder.HasIndex(t => new { t.UserId, t.RevokedAt }); + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.Designer.cs new file mode 100644 index 0000000..f2cf9fa --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.Designer.cs @@ -0,0 +1,713 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260625202947_AddRefreshTokensAndAuthAudit")] + partial class AddRefreshTokensAndAuthAudit + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "OccurredAt"); + + b.ToTable("auth_audit_events", null, t => + { + t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + }); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_token_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.HasOne("User", "ApprovedByUser") + .WithMany() + .HasForeignKey("ApprovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "EnteredByUser") + .WithMany() + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "VerifiedByUser") + .WithMany() + .HasForeignKey("VerifiedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApprovedByUser"); + + b.Navigation("EnteredByUser"); + + b.Navigation("VerifiedByUser"); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.HasOne("User", "Actor") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany("Events") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Actor"); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftEncounter") + .HasForeignKey("DraftEncounter", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany("DraftObservations") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftPatient") + .HasForeignKey("DraftPatient", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.HasOne("RefreshToken", "ReplacedByToken") + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplacedByToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("Document") + .HasForeignKey("ScannedDocument", "BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Navigation("Document"); + + b.Navigation("DraftEncounter"); + + b.Navigation("DraftObservations"); + + b.Navigation("DraftPatient"); + + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.cs b/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.cs new file mode 100644 index 0000000..64de243 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.cs @@ -0,0 +1,97 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + /// + public partial class AddRefreshTokensAndAuthAudit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "auth_audit_events", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + user_id = table.Column(type: "uuid", nullable: false), + event_type = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + occurred_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + metadata_json = table.Column(type: "jsonb", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_auth_audit_events", x => x.id); + table.CheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + table.ForeignKey( + name: "FK_auth_audit_events_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "refresh_tokens", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + user_id = table.Column(type: "uuid", nullable: false), + token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true), + replaced_by_token_id = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_refresh_tokens", x => x.id); + table.ForeignKey( + name: "FK_refresh_tokens_refresh_tokens_replaced_by_token_id", + column: x => x.replaced_by_token_id, + principalTable: "refresh_tokens", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_refresh_tokens_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_auth_audit_events_user_id_occurred_at", + table: "auth_audit_events", + columns: new[] { "user_id", "occurred_at" }); + + migrationBuilder.CreateIndex( + name: "IX_refresh_tokens_replaced_by_token_id", + table: "refresh_tokens", + column: "replaced_by_token_id"); + + migrationBuilder.CreateIndex( + name: "IX_refresh_tokens_token_hash", + table: "refresh_tokens", + column: "token_hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_refresh_tokens_user_id_revoked_at", + table: "refresh_tokens", + columns: new[] { "user_id", "revoked_at" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "auth_audit_events"); + + migrationBuilder.DropTable( + name: "refresh_tokens"); + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs index df20805..3e01b44 100644 --- a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs @@ -21,6 +21,44 @@ namespace VigilCareRecordsAPI.Data.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("AuthAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "OccurredAt"); + + b.ToTable("auth_audit_events", null, t => + { + t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + }); + }); + modelBuilder.Entity("DigitizationBatch", b => { b.Property("Id") @@ -385,6 +423,54 @@ namespace VigilCareRecordsAPI.Data.Migrations }); }); + modelBuilder.Entity("RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_token_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("refresh_tokens", (string)null); + }); + modelBuilder.Entity("ScannedDocument", b => { b.Property("Id") @@ -490,6 +576,17 @@ namespace VigilCareRecordsAPI.Data.Migrations }); }); + modelBuilder.Entity("AuthAuditEvent", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("DigitizationBatch", b => { b.HasOne("User", "ApprovedByUser") @@ -566,6 +663,24 @@ namespace VigilCareRecordsAPI.Data.Migrations b.Navigation("Batch"); }); + modelBuilder.Entity("RefreshToken", b => + { + b.HasOne("RefreshToken", "ReplacedByToken") + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplacedByToken"); + + b.Navigation("User"); + }); + modelBuilder.Entity("ScannedDocument", b => { b.HasOne("DigitizationBatch", "Batch") diff --git a/VigilCareRecordsAPI/Domain/Entities/AuthAuditEvent.cs b/VigilCareRecordsAPI/Domain/Entities/AuthAuditEvent.cs new file mode 100644 index 0000000..85d7a82 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/AuthAuditEvent.cs @@ -0,0 +1,10 @@ +public class AuthAuditEvent +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public AuthAuditEventType EventType { get; set; } + public DateTimeOffset OccurredAt { get; set; } + public string? MetadataJson { get; set; } + + public User User { get; set; } = null!; +} diff --git a/VigilCareRecordsAPI/Domain/Entities/RefreshToken.cs b/VigilCareRecordsAPI/Domain/Entities/RefreshToken.cs new file mode 100644 index 0000000..9f9aef0 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/RefreshToken.cs @@ -0,0 +1,13 @@ +public class RefreshToken +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string TokenHash { get; set; } = null!; + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + public Guid? ReplacedByTokenId { get; set; } + public DateTimeOffset CreatedAt { get; set; } + + public User User { get; set; } = null!; + public RefreshToken? ReplacedByToken { get; set; } +} diff --git a/VigilCareRecordsAPI/Domain/Enums/AuthAuditEventType.cs b/VigilCareRecordsAPI/Domain/Enums/AuthAuditEventType.cs new file mode 100644 index 0000000..081940c --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/AuthAuditEventType.cs @@ -0,0 +1,22 @@ +public enum AuthAuditEventType +{ + UserLogout, + TokenRefreshed +} + +public static class AuthAuditEventTypeExtensions +{ + public static string ToDbString(this AuthAuditEventType t) => t switch + { + AuthAuditEventType.UserLogout => "USER_LOGOUT", + AuthAuditEventType.TokenRefreshed => "TOKEN_REFRESHED", + _ => throw new ArgumentOutOfRangeException(nameof(t)) + }; + + public static AuthAuditEventType FromDbString(string v) => v switch + { + "USER_LOGOUT" => AuthAuditEventType.UserLogout, + "TOKEN_REFRESHED" => AuthAuditEventType.TokenRefreshed, + _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown auth audit event type: '{v}'") + }; +} diff --git a/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs b/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs index 0aa7446..bbbe12d 100644 --- a/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs +++ b/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs @@ -1 +1,7 @@ -public record LoginResponse(string Token, Guid UserId, string Username, string FullName, string Role); \ No newline at end of file +public record LoginResponse( + string Token, + string RefreshToken, + Guid UserId, + string Username, + string FullName, + string Role); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Auth/LogoutRequest.cs b/VigilCareRecordsAPI/Models/Records/Auth/LogoutRequest.cs new file mode 100644 index 0000000..89ec01d --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Auth/LogoutRequest.cs @@ -0,0 +1 @@ +public record LogoutRequest(string RefreshToken); diff --git a/VigilCareRecordsAPI/Models/Records/Auth/RefreshRequest.cs b/VigilCareRecordsAPI/Models/Records/Auth/RefreshRequest.cs new file mode 100644 index 0000000..02d785e --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Auth/RefreshRequest.cs @@ -0,0 +1 @@ +public record RefreshRequest(string RefreshToken); diff --git a/VigilCareRecordsAPI/Models/Records/Auth/TokenResponse.cs b/VigilCareRecordsAPI/Models/Records/Auth/TokenResponse.cs new file mode 100644 index 0000000..43fd9cf --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Auth/TokenResponse.cs @@ -0,0 +1 @@ +public record TokenResponse(string Token, string RefreshToken); diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs index 771cb83..609a5e6 100644 --- a/VigilCareRecordsAPI/Program.cs +++ b/VigilCareRecordsAPI/Program.cs @@ -1,8 +1,10 @@ -using System.Text;using Microsoft.AspNetCore.Authentication.JwtBearer; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Minio; using Serilog; +using StackExchange.Redis; try { @@ -19,6 +21,10 @@ try builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + // Redis + builder.Services.AddSingleton( + ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!)); + // MinIO var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get()!; builder.Services.AddSingleton(new MinioClient() diff --git a/VigilCareRecordsAPI/Services/AuthService.cs b/VigilCareRecordsAPI/Services/AuthService.cs index eff01b8..21032a4 100644 --- a/VigilCareRecordsAPI/Services/AuthService.cs +++ b/VigilCareRecordsAPI/Services/AuthService.cs @@ -1,5 +1,6 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -25,8 +26,37 @@ public class AuthService : IAuthService if (!user.IsActive) throw new ConflictException("Account is disabled.", "ACCOUNT_DISABLED"); - var token = GenerateJwt(user); - return new LoginResponse(token, user.Id, user.Username, user.FullName, user.Role.ToDbString()); + var (accessToken, refreshToken) = await IssueTokenPairAsync(user); + return new LoginResponse( + accessToken, + refreshToken, + user.Id, + user.Username, + user.FullName, + user.Role.ToDbString()); + } + + public async Task RefreshAsync(RefreshRequest req) + { + var storedToken = await FindValidRefreshTokenAsync(req.RefreshToken); + var user = await _db.Users.FindAsync(storedToken.UserId); + if (user is null || !user.IsActive) + throw new ValidationException("Invalid refresh token.", "INVALID_REFRESH_TOKEN"); + + storedToken.RevokedAt = DateTimeOffset.UtcNow; + + var (accessToken, refreshToken) = await IssueTokenPairAsync(user, storedToken); + await WriteAuthAuditEventAsync(user.Id, AuthAuditEventType.TokenRefreshed); + + return new TokenResponse(accessToken, refreshToken); + } + + public async Task LogoutAsync(LogoutRequest req) + { + var storedToken = await FindValidRefreshTokenAsync(req.RefreshToken); + storedToken.RevokedAt = DateTimeOffset.UtcNow; + await WriteAuthAuditEventAsync(storedToken.UserId, AuthAuditEventType.UserLogout); + await _db.SaveChangesAsync(); } public async Task GetCurrentUserAsync(Guid userId) @@ -37,6 +67,60 @@ public class AuthService : IAuthService return user; } + private async Task<(string AccessToken, string RefreshToken)> IssueTokenPairAsync( + User user, + RefreshToken? replacedToken = null) + { + var accessToken = GenerateJwt(user); + var refreshTokenValue = GenerateRefreshToken(); + var refreshTokenEntity = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = user.Id, + TokenHash = HashToken(refreshTokenValue), + ExpiresAt = DateTimeOffset.UtcNow.AddDays(_jwtOptions.RefreshTokenExpiryDays), + CreatedAt = DateTimeOffset.UtcNow + }; + + if (replacedToken is not null) + { + replacedToken.ReplacedByTokenId = refreshTokenEntity.Id; + } + + _db.RefreshTokens.Add(refreshTokenEntity); + await _db.SaveChangesAsync(); + + return (accessToken, refreshTokenValue); + } + + private async Task FindValidRefreshTokenAsync(string refreshToken) + { + var tokenHash = HashToken(refreshToken); + var storedToken = await _db.RefreshTokens + .FirstOrDefaultAsync(t => t.TokenHash == tokenHash); + + if (storedToken is null + || storedToken.RevokedAt is not null + || storedToken.ExpiresAt <= DateTimeOffset.UtcNow) + { + throw new ValidationException("Invalid refresh token.", "INVALID_REFRESH_TOKEN"); + } + + return storedToken; + } + + private async Task WriteAuthAuditEventAsync(Guid userId, AuthAuditEventType eventType) + { + _db.AuthAuditEvents.Add(new AuthAuditEvent + { + Id = Guid.NewGuid(), + UserId = userId, + EventType = eventType, + OccurredAt = DateTimeOffset.UtcNow + }); + await _db.SaveChangesAsync(); + } + private string GenerateJwt(User user) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret)); @@ -59,4 +143,16 @@ public class AuthService : IAuthService return new JwtSecurityTokenHandler().WriteToken(token); } -} \ No newline at end of file + + private static string GenerateRefreshToken() + { + var bytes = RandomNumberGenerator.GetBytes(64); + return Convert.ToBase64String(bytes); + } + + private static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs b/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs index afea19b..d0fb8c4 100644 --- a/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs +++ b/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs @@ -1,5 +1,7 @@ public interface IAuthService { Task LoginAsync(LoginRequest req); + Task RefreshAsync(RefreshRequest req); + Task LogoutAsync(LogoutRequest req); Task GetCurrentUserAsync(Guid userId); -} \ No newline at end of file +} diff --git a/VigilCareRecordsAPI/appsettings.json b/VigilCareRecordsAPI/appsettings.json index 95294a6..d85fca3 100644 --- a/VigilCareRecordsAPI/appsettings.json +++ b/VigilCareRecordsAPI/appsettings.json @@ -20,7 +20,8 @@ "Secret": "VigilCareRecordsDevSecretKeyAtLeast32Chars!", "Issuer": "VigilCareRecords", "Audience": "VigilCareRecords", - "ExpiryMinutes": 480 + "ExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 }, "Serilog": { "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq" ], diff --git a/docs/vigilcare-records-prd.md b/docs/vigilcare-records-prd.md index 2b9daa3..d0bc428 100644 --- a/docs/vigilcare-records-prd.md +++ b/docs/vigilcare-records-prd.md @@ -464,10 +464,13 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap **Description:** JWT auth with role claims. Every state transition writes a `DigitizationEvent`. Document access logged. **Endpoints:** -- `POST /api/v1/auth/login` +- `POST /api/v1/auth/login` — returns access token (15 min), refresh token (7 days), and user profile +- `POST /api/v1/auth/refresh` — rotates refresh token and issues new access token +- `POST /api/v1/auth/logout` — revokes refresh token server-side - `GET /api/v1/auth/me` **Audit requirements:** +- Auth events (`USER_LOGOUT`, `TOKEN_REFRESHED`) persisted in `auth_audit_events` - Who viewed a scan and when - Who changed which draft field (field-level diff in event metadata on save) - Who approved promotion and which live record IDs were created