Handler.cs 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. using Application.Abstractions.Authentication;
  2. using Application.Abstractions.Cache;
  3. using Application.Abstractions.Data;
  4. using Application.Abstractions.Messaging;
  5. using Application.Helpers;
  6. using Domain.Entities.Members.Logs;
  7. using SharedKernel;
  8. using SharedKernel.Results;
  9. using Microsoft.EntityFrameworkCore;
  10. using Microsoft.Extensions.Logging;
  11. using Microsoft.Extensions.Options;
  12. namespace Application.Features.Api.Auth.Login;
  13. internal sealed class Handler(
  14. IAppDbContext db,
  15. IJwtTokenProvider jwtTokenProvider,
  16. IOptions<AppSettings> options,
  17. ILogger<Handler> logger,
  18. ICacheService cache,
  19. ILegacyPasswordVerifier legacyVerifier
  20. ) : ICommandHandler<Command, Result<Response>> {
  21. private readonly AppSettings.JwtSection _jwt = options.Value.JWT;
  22. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  23. {
  24. // 유효성 검사
  25. if (string.IsNullOrWhiteSpace(request.Email))
  26. {
  27. return Result.Failure<Response>(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다."));
  28. }
  29. if (string.IsNullOrWhiteSpace(request.Password))
  30. {
  31. return Result.Failure<Response>(Error.Problem("Auth.PasswordRequired", "비밀번호는 필수입니다."));
  32. }
  33. // Config 로드
  34. var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct);
  35. // Member 조회 (비밀번호 검증을 위해 Tracking 모드)
  36. var email = request.Email.Trim().ToLower();
  37. var member = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct);
  38. if (member is null)
  39. {
  40. // 실패 로그 기록 (회원 없음)
  41. await LogLoginAttempt(null, false, email, "회원 정보 없음", request, ct);
  42. return Result.Failure<Response>(Error.Unauthorized("Auth.InvalidCredentials", "이메일 또는 비밀번호가 올바르지 않습니다."));
  43. }
  44. // 탈퇴 회원 거부
  45. if (member.IsWithdraw)
  46. {
  47. await LogLoginAttempt(member.ID, false, email, "탈퇴 회원", request, ct);
  48. return Result.Failure<Response>(Error.Problem("Auth.MemberWithdrawn", "탈퇴한 회원은 이용할 수 없습니다."));
  49. }
  50. // 차단 회원 거부
  51. if (member.IsDenied)
  52. {
  53. await LogLoginAttempt(member.ID, false, email, "차단 회원", request, ct);
  54. return Result.Failure<Response>(Error.Problem("Auth.MemberDenied", "차단된 회원이므로 이용할 수 없습니다."));
  55. }
  56. // 로그인 시도 횟수 제한 확인
  57. if (accountConfig.MaxLoginTryCount is > 0 && accountConfig.MaxLoginTryLimitSecond is > 0)
  58. {
  59. var limitTime = DateTime.UtcNow.AddSeconds(-accountConfig.MaxLoginTryLimitSecond.Value);
  60. var failedCount = await db.MemberLoginLog.CountAsync(l => l.MemberID == member.ID && !l.Success && l.CreatedAt >= limitTime, ct);
  61. if (failedCount >= accountConfig.MaxLoginTryCount.Value)
  62. {
  63. await LogLoginAttempt(member.ID, false, email, "로그인 시도 횟수 초과", request, ct);
  64. return Result.Failure<Response>(Error.Problem("Auth.LoginTryExceeded", $"로그인 시도 횟수를 초과하였습니다. {accountConfig.MaxLoginTryLimitSecond}초 후에 다시 시도해주세요."));
  65. }
  66. }
  67. // 비밀번호 검증 (현행 PBKDF2 → 실패 시 legacy bcrypt fallback + PBKDF2 lazy upgrade)
  68. var passwordOk = member.VerifyPassword(request.Password);
  69. if (!passwordOk && legacyVerifier.IsLegacyHash(member.Password) && legacyVerifier.Verify(request.Password, member.Password!))
  70. {
  71. // dpot 1.0 bcrypt 검증 성공 → 현행 PBKDF2(PasswordHash)로 재해싱하여 이후 표준 경로 사용
  72. member.SetPassword(request.Password);
  73. passwordOk = true;
  74. }
  75. if (!passwordOk)
  76. {
  77. await LogLoginAttempt(member.ID, false, email, "비밀번호 불일치", request, ct);
  78. return Result.Failure<Response>(Error.Unauthorized("Auth.InvalidCredentials", "이메일 또는 비밀번호가 올바르지 않습니다."));
  79. }
  80. // 로그인 시 이메일 인증자 여부 확인
  81. if (accountConfig.IsLoginEmailVerifiedOnly && !member.IsEmailVerified)
  82. {
  83. await LogLoginAttempt(member.ID, false, email, "이메일 미인증", request, ct);
  84. return Result.Failure<Response>(Error.Unauthorized("Auth.EmailNotVerified", "이메일 인증이 완료되지 않은 사용자는 로그인할 수 없습니다."));
  85. }
  86. // JWT 토큰 생성
  87. var accessToken = jwtTokenProvider.CreateAccessToken(member.ID, member.Email, member.Name);
  88. var refreshToken = jwtTokenProvider.CreateRefreshToken();
  89. var expiresAt = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiration);
  90. // RefreshToken 저장
  91. var refreshTokenEntity = Domain.Entities.Members.RefreshToken.Create(
  92. member.ID,
  93. refreshToken,
  94. DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiration)
  95. );
  96. await db.RefreshToken.AddAsync(refreshTokenEntity, ct);
  97. // 로그인 횟수 증가
  98. var memberStats = await db.MemberStats.FirstOrDefaultAsync(x => x.MemberID == member.ID, ct);
  99. if (memberStats is not null)
  100. {
  101. memberStats.LoginCount++;
  102. }
  103. // 최종 접속 정보 갱신
  104. member.RecordLogin(request.IpAddress, request.UserAgent);
  105. // 성공 로그 기록
  106. await LogLoginAttempt(member.ID, true, email, null, request, ct);
  107. await db.SaveChangesAsync(ct);
  108. logger.LogInformation("{0} 로그인", member.Email);
  109. return Result.Success(new Response(accessToken, refreshToken, expiresAt));
  110. }
  111. private async Task LogLoginAttempt(int? memberID, bool success, string account, string? reason, Command request, CancellationToken ct)
  112. {
  113. var log = MemberLoginLog.Create(
  114. memberID,
  115. success,
  116. account,
  117. reason,
  118. ipAddress: request.IpAddress,
  119. userAgent: request.UserAgent
  120. );
  121. await db.MemberLoginLog.AddAsync(log, ct);
  122. await db.SaveChangesAsync(ct);
  123. }
  124. }