Files
soft/backend/src/HealthManager.WebApi/Controllers/AuthController.cs
MingNian d5f167167a 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/
2026-05-26 13:48:53 +08:00

180 lines
7.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using HealthManager.Application.DTOs.Auth;
using HealthManager.Domain.Interfaces;
using HealthManager.Application.Services;
using HealthManager.Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController(
AuthService authService,
IJwtProvider jwtProvider,
VerificationService verificationService,
RateLimitService rateLimit,
TokenBlacklistService tokenBlacklist) : ControllerBase
{
[HttpPost("send-sms")]
public async Task<IActionResult> SendSms([FromBody] SendSmsRequest request)
{
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)
{
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
var deleted = await db.Users.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Phone == request.Phone && u.IsDeleted);
if (deleted != null)
{
deleted.IsDeleted = false;
deleted.DeletedAt = null;
deleted.UpdatedAt = DateTime.UtcNow;
user = deleted;
}
else
{
user = new User
{
Phone = request.Phone,
Name = "用户" + request.Phone[^4..],
Role = "patient",
PasswordHash = AuthService.HashPassword("demo123"),
};
db.Users.Add(user);
}
await db.SaveChangesAsync();
}
var accessToken = jwtProvider.GenerateAccessToken(user.Id, user.Name, user.Role);
var refreshToken = jwtProvider.GenerateRefreshToken();
await authService.SaveRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7));
return Ok(new AuthResponse(user.Id, user.Name, user.Role, accessToken, refreshToken));
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var existing = await authService.GetUserByPhoneAsync(request.Phone);
if (existing != null)
return Conflict(new { message = "该手机号已注册" });
var user = new User
{
Phone = request.Phone,
Name = request.Name,
Role = "patient",
PasswordHash = AuthService.HashPassword("demo123"),
};
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
db.Users.Add(user);
await db.SaveChangesAsync();
var accessToken = jwtProvider.GenerateAccessToken(user.Id, user.Name, user.Role);
var refreshToken = jwtProvider.GenerateRefreshToken();
await authService.SaveRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7));
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)
{
var saved = await authService.GetRefreshTokenAsync(request.RefreshToken);
if (saved == null)
return Unauthorized(new { message = "无效的刷新令牌" });
await authService.RevokeRefreshTokenAsync(saved.UserId);
var accessToken = jwtProvider.GenerateAccessToken(saved.User.Id, saved.User.Name, saved.User.Role);
var refreshToken = jwtProvider.GenerateRefreshToken();
await authService.SaveRefreshTokenAsync(saved.UserId, refreshToken, DateTime.UtcNow.AddDays(7));
return Ok(new AuthResponse(saved.User.Id, saved.User.Name, saved.User.Role, accessToken, refreshToken));
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> GetProfile()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
var user = await db.Users.FindAsync(userId);
if (user == null) return NotFound();
return Ok(new UserProfileResponse(
user.Id, user.Name, user.Phone, user.Role,
user.Gender, user.Birthday, user.HeightCm, user.WeightKg,
user.MedicalHistory, user.StentDate, user.StentType,
user.Department, user.Title, user.Specialty, user.Introduction));
}
[HttpPut("me")]
[Authorize]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
var user = await db.Users.FindAsync(userId);
if (user == null) return NotFound();
if (request.Name != null) user.Name = request.Name;
if (request.Gender != null) user.Gender = request.Gender;
if (request.Birthday.HasValue) user.Birthday = request.Birthday;
if (request.HeightCm.HasValue) user.HeightCm = request.HeightCm;
if (request.WeightKg.HasValue) user.WeightKg = request.WeightKg;
if (request.MedicalHistory != null) user.MedicalHistory = request.MedicalHistory;
if (request.StentDate.HasValue) user.StentDate = request.StentDate;
if (request.StentType != null) user.StentType = request.StentType;
if (request.Department != null) user.Department = request.Department;
if (request.Title != null) user.Title = request.Title;
if (request.Introduction != null) user.Introduction = request.Introduction;
if (request.Specialty != null) user.Specialty = request.Specialty;
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { message = "更新成功" });
}
}
public record RefreshTokenRequest(string RefreshToken);