fix: No token refresh or revocation mechanism~
This commit is contained in:
@@ -5,5 +5,6 @@ public class JwtOptions
|
||||
public string Issuer { get; set; } = "VigilCareClinical";
|
||||
public string Audience { get; set; } = "VigilCareClinical.Dashboard";
|
||||
public string SigningKey { get; set; } = null!;
|
||||
public int ExpirationMinutes { get; set; } = 480;
|
||||
public int ExpirationMinutes { get; set; } = 15;
|
||||
public int RefreshTokenExpirationDays { get; set; } = 7;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// JWT authentication: login and current-user profile.
|
||||
/// JWT authentication: login, token refresh, logout, and current-user profile.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/auth")]
|
||||
@@ -24,6 +24,29 @@ public class AuthController : ControllerBase
|
||||
return Ok(ApiResponse<LoginResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>Exchange a refresh token for a new access + refresh token pair.</summary>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<RefreshResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req)
|
||||
{
|
||||
var result = await _auth.RefreshAsync(req.RefreshToken);
|
||||
return Ok(ApiResponse<RefreshResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>Revoke the refresh token and end the session.</summary>
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Logout(
|
||||
[FromBody] LogoutRequest req,
|
||||
[FromServices] ICurrentUserService currentUser)
|
||||
{
|
||||
await _auth.LogoutAsync(req.RefreshToken, currentUser.UserId!.Value);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Returns the authenticated user's profile.</summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
|
||||
@@ -36,6 +36,7 @@ public class AppDbContext : DbContext
|
||||
public DbSet<ClinicalSyncConflict> ClinicalSyncConflicts => Set<ClinicalSyncConflict>();
|
||||
public DbSet<AlertFeedback> AlertFeedbacks => Set<AlertFeedback>();
|
||||
public DbSet<AlertQualityMetric> AlertQualityMetrics => Set<AlertQualityMetric>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RefreshToken> 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.Token).HasColumnName("token").HasMaxLength(256).IsRequired();
|
||||
builder.Property(t => t.UserId).HasColumnName("user_id").IsRequired();
|
||||
builder.Property(t => t.ExpiresAt).HasColumnName("expires_at").IsRequired();
|
||||
builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(t => t.RevokedAt).HasColumnName("revoked_at");
|
||||
|
||||
builder.HasIndex(t => t.Token).IsUnique();
|
||||
builder.HasIndex(t => t.UserId);
|
||||
|
||||
builder.HasOne(t => t.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
public class RefreshToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Token { get; set; } = null!;
|
||||
public Guid UserId { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
public ClinicalUser User { get; set; } = null!;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ public enum AuditAction
|
||||
UserLogin,
|
||||
AuthorizationDenied,
|
||||
AlertFeedbackSubmitted,
|
||||
UserLogout,
|
||||
TokenRefreshed,
|
||||
}
|
||||
|
||||
public static class AuditActionExtensions
|
||||
@@ -30,6 +32,8 @@ public static class AuditActionExtensions
|
||||
AuditAction.UserLogin => "USER_LOGIN",
|
||||
AuditAction.AuthorizationDenied => "AUTHORIZATION_DENIED",
|
||||
AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED",
|
||||
AuditAction.UserLogout => "USER_LOGOUT",
|
||||
AuditAction.TokenRefreshed => "TOKEN_REFRESHED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(a))
|
||||
};
|
||||
|
||||
@@ -47,6 +51,8 @@ public static class AuditActionExtensions
|
||||
"USER_LOGIN" => AuditAction.UserLogin,
|
||||
"AUTHORIZATION_DENIED" => AuditAction.AuthorizationDenied,
|
||||
"ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted,
|
||||
"USER_LOGOUT" => AuditAction.UserLogout,
|
||||
"TOKEN_REFRESHED" => AuditAction.TokenRefreshed,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v))
|
||||
};
|
||||
}
|
||||
+1923
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRefreshTokens : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "refresh_tokens",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
token = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
revoked_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_refresh_tokens", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_refresh_tokens_clinical_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "clinical_users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_refresh_tokens_token",
|
||||
table: "refresh_tokens",
|
||||
column: "token",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_refresh_tokens_user_id",
|
||||
table: "refresh_tokens",
|
||||
column: "user_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "refresh_tokens");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1377,6 +1377,48 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("token");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("refresh_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SepsisBundle", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1769,6 +1811,17 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("ClinicalUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SepsisBundle", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
public record LoginResponse(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
DateTimeOffset ExpiresAt,
|
||||
Guid UserId,
|
||||
string Username,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
public record LogoutRequest(string RefreshToken);
|
||||
@@ -0,0 +1 @@
|
||||
public record RefreshRequest(string RefreshToken);
|
||||
@@ -0,0 +1,4 @@
|
||||
public record RefreshResponse(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
DateTimeOffset ExpiresAt);
|
||||
@@ -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;
|
||||
@@ -40,10 +41,12 @@ public class AuthService : IAuthService
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes);
|
||||
var token = GenerateToken(user, expires);
|
||||
var accessToken = GenerateAccessToken(user, expires);
|
||||
var refreshToken = await CreateRefreshTokenAsync(user.Id);
|
||||
|
||||
return new LoginResponse(
|
||||
token,
|
||||
accessToken,
|
||||
refreshToken.Token,
|
||||
expires,
|
||||
user.Id,
|
||||
user.Username,
|
||||
@@ -51,7 +54,88 @@ public class AuthService : IAuthService
|
||||
user.Role.ToDbString());
|
||||
}
|
||||
|
||||
private string GenerateToken(ClinicalUser user, DateTimeOffset expires)
|
||||
public async Task<RefreshResponse> RefreshAsync(string refreshToken)
|
||||
{
|
||||
var stored = await _db.RefreshTokens
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Token == refreshToken);
|
||||
|
||||
if (stored is null || stored.RevokedAt is not null || stored.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
throw new ValidationException("Invalid or expired refresh token.", "INVALID_REFRESH_TOKEN");
|
||||
|
||||
if (!stored.User.IsActive)
|
||||
throw new ValidationException("Account is deactivated.", "ACCOUNT_DEACTIVATED");
|
||||
|
||||
stored.RevokedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var newRefreshToken = await CreateRefreshTokenAsync(stored.UserId);
|
||||
|
||||
var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes);
|
||||
var accessToken = GenerateAccessToken(stored.User, expires);
|
||||
|
||||
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Action = AuditAction.TokenRefreshed,
|
||||
EntityType = "ClinicalUser",
|
||||
EntityId = stored.UserId,
|
||||
UserId = stored.UserId,
|
||||
UserDisplayName = stored.User.DisplayName,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new RefreshResponse(accessToken, newRefreshToken.Token, expires);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(string refreshToken, Guid userId)
|
||||
{
|
||||
var stored = await _db.RefreshTokens
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Token == refreshToken && t.UserId == userId);
|
||||
|
||||
if (stored is not null && stored.RevokedAt is null)
|
||||
{
|
||||
stored.RevokedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Action = AuditAction.UserLogout,
|
||||
EntityType = "ClinicalUser",
|
||||
EntityId = userId,
|
||||
UserId = userId,
|
||||
UserDisplayName = stored.User.DisplayName,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RefreshToken> CreateRefreshTokenAsync(Guid userId)
|
||||
{
|
||||
var token = new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Token = GenerateOpaqueToken(),
|
||||
UserId = userId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(_jwt.RefreshTokenExpirationDays),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_db.RefreshTokens.Add(token);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private static string GenerateOpaqueToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(64);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private string GenerateAccessToken(ClinicalUser user, DateTimeOffset expires)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<LoginResponse> LoginAsync(LoginRequest req);
|
||||
Task<RefreshResponse> RefreshAsync(string refreshToken);
|
||||
Task LogoutAsync(string refreshToken, Guid userId);
|
||||
}
|
||||
@@ -195,7 +195,8 @@
|
||||
"Issuer": "VigilCareClinical",
|
||||
"Audience": "VigilCareClinical.Dashboard",
|
||||
"SigningKey": "DEV-ONLY-REPLACE-WITH-256-BIT-SECRET-IN-PRODUCTION-abc123xyz",
|
||||
"ExpirationMinutes": 480
|
||||
"ExpirationMinutes": 15,
|
||||
"RefreshTokenExpirationDays": 7
|
||||
},
|
||||
"PhiEncryption": {
|
||||
"ProtectorPurpose": "VigilCare.PatientPhi.v1",
|
||||
|
||||
Reference in New Issue
Block a user