using Application.Abstractions.Authentication; using Application.Abstractions.Cache; using Application.Abstractions.Data; using Application.Helpers; using Domain.Entities.Members.Logs; using MediatR; using SharedKernel; using SharedKernel.Results; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Application.Features.Api.Auth.Login; internal sealed class Handler( IAppDbContext db, IJwtTokenProvider jwtTokenProvider, IOptions options, ILogger logger, ICacheService cache ) : IRequestHandler> { private readonly AppSettings.JwtSection _jwt = options.Value.JWT; public async Task> Handle(Command request, CancellationToken ct) { // 유효성 검사 if (string.IsNullOrWhiteSpace(request.Email)) { return Result.Failure(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다.")); } if (string.IsNullOrWhiteSpace(request.Password)) { return Result.Failure(Error.Problem("Auth.PasswordRequired", "비밀번호는 필수입니다.")); } // Config 로드 var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct); // Member 조회 (비밀번호 검증을 위해 Tracking 모드) var email = request.Email.Trim().ToLower(); var member = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct); if (member is null) { // 실패 로그 기록 (회원 없음) await LogLoginAttempt(null, false, email, "회원 정보 없음", request, ct); return Result.Failure(Error.Unauthorized("Auth.InvalidCredentials", "이메일 또는 비밀번호가 올바르지 않습니다.")); } // 탈퇴 회원 거부 if (member.IsWithdraw) { await LogLoginAttempt(member.ID, false, email, "탈퇴 회원", request, ct); return Result.Failure(Error.Problem("Auth.MemberWithdrawn", "탈퇴한 회원은 이용할 수 없습니다.")); } // 차단 회원 거부 if (member.IsDenied) { await LogLoginAttempt(member.ID, false, email, "차단 회원", request, ct); return Result.Failure(Error.Problem("Auth.MemberDenied", "차단된 회원이므로 이용할 수 없습니다.")); } // 로그인 시도 횟수 제한 확인 if (accountConfig.MaxLoginTryCount is > 0 && accountConfig.MaxLoginTryLimitSecond is > 0) { var limitTime = DateTime.UtcNow.AddSeconds(-accountConfig.MaxLoginTryLimitSecond.Value); var failedCount = await db.MemberLoginLog.CountAsync(l => l.MemberID == member.ID && !l.Success && l.CreatedAt >= limitTime, ct); if (failedCount >= accountConfig.MaxLoginTryCount.Value) { await LogLoginAttempt(member.ID, false, email, "로그인 시도 횟수 초과", request, ct); return Result.Failure(Error.Problem("Auth.LoginTryExceeded", $"로그인 시도 횟수를 초과하였습니다. {accountConfig.MaxLoginTryLimitSecond}초 후에 다시 시도해주세요.")); } } // 비밀번호 검증 if (!member.VerifyPassword(request.Password)) { await LogLoginAttempt(member.ID, false, email, "비밀번호 불일치", request, ct); return Result.Failure(Error.Unauthorized("Auth.InvalidCredentials", "이메일 또는 비밀번호가 올바르지 않습니다.")); } // 로그인 시 이메일 인증자 여부 확인 if (accountConfig.IsLoginEmailVerifiedOnly && !member.IsEmailVerified) { await LogLoginAttempt(member.ID, false, email, "이메일 미인증", request, ct); return Result.Failure(Error.Unauthorized("Auth.EmailNotVerified", "이메일 인증이 완료되지 않은 사용자는 로그인할 수 없습니다.")); } // JWT 토큰 생성 var accessToken = jwtTokenProvider.CreateAccessToken(member.ID, member.Email, member.Name); var refreshToken = jwtTokenProvider.CreateRefreshToken(); var expiresAt = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiration); // RefreshToken 저장 var refreshTokenEntity = Domain.Entities.Members.RefreshToken.Create( member.ID, refreshToken, DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiration) ); await db.RefreshToken.AddAsync(refreshTokenEntity, ct); // 로그인 횟수 증가 var memberStats = await db.MemberStats.FirstOrDefaultAsync(x => x.MemberID == member.ID, ct); if (memberStats is not null) { memberStats.LoginCount++; } // 성공 로그 기록 await LogLoginAttempt(member.ID, true, email, null, request, ct); await db.SaveChangesAsync(ct); logger.LogInformation("{0} 로그인", member.Email); return Result.Success(new Response(accessToken, refreshToken, expiresAt)); } private async Task LogLoginAttempt(int? memberID, bool success, string account, string? reason, Command request, CancellationToken ct) { var log = MemberLoginLog.Create( memberID, success, account, reason, ipAddress: request.IpAddress, userAgent: request.UserAgent ); await db.MemberLoginLog.AddAsync(log, ct); await db.SaveChangesAsync(ct); } }