fix: No token refresh or revocation mechanism~

This commit is contained in:
voltsrage
2026-06-25 14:14:20 +08:00
parent a8964381a2
commit fdcc646fae
26 changed files with 3453 additions and 55 deletions
@@ -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))
};
}
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);
+87 -3
View File
@@ -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);
}
+2 -1
View File
@@ -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",