- 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/
180 lines
7.3 KiB
C#
180 lines
7.3 KiB
C#
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);
|