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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,6 @@
|
||||
"Issuer": "HealthManager",
|
||||
"Audience": "HealthManagerApp"
|
||||
},
|
||||
"Redis": {
|
||||
"Connection": "localhost:6379"
|
||||
},
|
||||
"MinIO": {
|
||||
"Endpoint": "localhost:9000",
|
||||
"AccessKey": "minioadmin",
|
||||
|
||||
Reference in New Issue
Block a user