feat: replace Redis with PostgreSQL for caching, rate limiting, SMS codes, and token blacklist

- Add 4 PG entities: VerificationCode, RateLimitEntry, TokenBlacklistEntry, CacheEntry
- Add 4 services: VerificationService, RateLimitService, TokenBlacklistService, CacheService
- Add CleanupBackgroundService for periodic expired data cleanup
- Add MigrationHelper for safe schema migration without data loss
- Update AuthController: real SMS code generation, rate limiting, logout endpoint with JWT blacklist
- Update JwtProvider: add JTI claim for token revocation
- Update Program.cs: register new services, JWT blacklist validation, DB migration
- Remove StackExchange.Redis NuGet package and all Redis config references
- Update start-dev.bat: 6→5 services, remove Redis startup
- Update docs: remove Redis references from all documentation
- Fix: logout button spacing on profile page
- Fix: .gitignore data/→/data/ to not ignore Infrastructure/Data/
This commit is contained in:
MingNian
2026-05-26 13:48:53 +08:00
parent 39ab6062b5
commit d5f167167a
25 changed files with 613 additions and 47 deletions

View File

@@ -1,3 +1,4 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using HealthManager.Application.DTOs.Auth;
using HealthManager.Domain.Interfaces;
@@ -13,25 +14,34 @@ namespace HealthManager.WebApi.Controllers;
[Route("api/auth")]
public class AuthController(
AuthService authService,
IJwtProvider jwtProvider) : ControllerBase
IJwtProvider jwtProvider,
VerificationService verificationService,
RateLimitService rateLimit,
TokenBlacklistService tokenBlacklist) : ControllerBase
{
[HttpPost("send-sms")]
public IActionResult SendSms([FromBody] SendSmsRequest request)
public async Task<IActionResult> SendSms([FromBody] SendSmsRequest request)
{
// Demo: always succeed
if (!await rateLimit.CheckAsync($"sms:{request.Phone}", 1, 60))
return StatusCode(429, new { message = "发送过于频繁请60秒后重试" });
var code = await verificationService.GenerateAsync(request.Phone, "login");
await rateLimit.IncrementAsync($"sms:{request.Phone}", 60);
// Demo: log code to console since no real SMS gateway
Console.WriteLine($"[SMS] Phone: {request.Phone}, Code: {code}");
return Ok(new { message = "验证码已发送" });
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
// Demo: skip SMS verification, accept any code
var user = await authService.GetUserByPhoneAsync(request.Phone);
if (user == null)
{
// Demo: auto-register new users
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
// Check if this phone was soft-deleted — restore instead of creating duplicate
var deleted = await db.Users.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Phone == request.Phone && u.IsDeleted);
if (deleted != null)
@@ -77,7 +87,6 @@ public class AuthController(
PasswordHash = AuthService.HashPassword("demo123"),
};
// Access DbContext via DI
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
db.Users.Add(user);
await db.SaveChangesAsync();
@@ -89,6 +98,24 @@ public class AuthController(
return Ok(new AuthResponse(user.Id, user.Name, user.Role, accessToken, refreshToken));
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var jti = User.FindFirstValue(JwtRegisteredClaimNames.Jti);
var expClaim = User.FindFirstValue(JwtRegisteredClaimNames.Exp);
if (!string.IsNullOrEmpty(jti) && !string.IsNullOrEmpty(expClaim))
{
var exp = DateTimeOffset.FromUnixTimeSeconds(long.Parse(expClaim)).UtcDateTime;
await tokenBlacklist.AddAsync(jti, userId, exp);
}
await authService.RevokeRefreshTokenAsync(userId);
return Ok(new { message = "已登出" });
}
[HttpPost("refresh")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
{

View File

@@ -10,6 +10,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.OpenApi" Version="2.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>

View File

@@ -1,9 +1,11 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using HealthManager.Domain.Interfaces;
using HealthManager.Application.Services;
using HealthManager.Infrastructure.Data;
using HealthManager.Infrastructure.Services;
using HealthManager.WebApi.Hubs;
using HealthManager.WebApi.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
@@ -39,6 +41,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
context.Token = accessToken;
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
var blacklist = context.HttpContext.RequestServices
.GetRequiredService<TokenBlacklistService>();
var jti = context.Principal!.FindFirst(JwtRegisteredClaimNames.Jti)?.Value;
if (!string.IsNullOrEmpty(jti) && await blacklist.IsBlacklistedAsync(jti))
context.Fail("token已注销");
}
};
});
@@ -55,6 +65,13 @@ builder.Services.AddScoped<FollowUpService>();
builder.Services.AddScoped<PatientService>();
builder.Services.AddScoped<NotificationService>();
// PG-based replacements for Redis
builder.Services.AddScoped<VerificationService>();
builder.Services.AddScoped<RateLimitService>();
builder.Services.AddScoped<TokenBlacklistService>();
builder.Services.AddScoped<CacheService>();
builder.Services.AddHostedService<CleanupBackgroundService>();
// SignalR
builder.Services.AddSignalR();
@@ -99,6 +116,7 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
await MigrationHelper.EnsureNewTablesAsync(db);
await DataSeeder.SeedAsync(db);
}

View File

@@ -0,0 +1,31 @@
using HealthManager.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.WebApi.Services;
public class CleanupBackgroundService(
IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var verification = scope.ServiceProvider.GetRequiredService<VerificationService>();
var tokenBlacklist = scope.ServiceProvider.GetRequiredService<TokenBlacklistService>();
await verification.CleanupExpiredAsync();
await tokenBlacklist.CleanupExpiredAsync();
var db = scope.ServiceProvider.GetRequiredService<Infrastructure.Data.AppDbContext>();
await db.RateLimitEntries.Where(r => r.ExpiresAt < DateTime.UtcNow).ExecuteDeleteAsync(stoppingToken);
await db.CacheEntries.Where(c => c.ExpiresAt < DateTime.UtcNow).ExecuteDeleteAsync(stoppingToken);
}
catch { /* skip cleanup errors */ }
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}

View File

@@ -14,9 +14,6 @@
"Issuer": "HealthManager",
"Audience": "HealthManagerApp"
},
"Redis": {
"Connection": "localhost:6379"
},
"MinIO": {
"Endpoint": "localhost:9000",
"AccessKey": "minioadmin",