Handler.cs 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  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.GoogleLogin;
  13. internal sealed class Handler(
  14. IAppDbContext db,
  15. IJwtTokenProvider jwtTokenProvider,
  16. IGoogleTokenValidator googleTokenValidator,
  17. IOptions<AppSettings> options,
  18. ILogger<Handler> logger,
  19. ICacheService cache
  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.Credential))
  26. {
  27. return Result.Failure<Response>(Error.Problem("Auth.CredentialRequired", "Google 인증 정보가 필요합니다."));
  28. }
  29. // Config에서 GoogleClientId 조회
  30. var config = await db.Config.AsNoTracking().OrderByDescending(x => x.ID).FirstOrDefaultAsync(ct);
  31. // 관리자 on/off (Admin /Config/External 체크박스). Config 행이 없으면 도메인 기본(true) 취급.
  32. var googleEnabled = config?.External.GoogleLoginEnabled ?? true;
  33. if (!googleEnabled)
  34. {
  35. return Result.Failure<Response>(Error.Problem("Auth.SocialDisabled", "구글 로그인을 사용할 수 없습니다."));
  36. }
  37. var googleClientId = config?.External.GoogleClientId;
  38. if (string.IsNullOrWhiteSpace(googleClientId))
  39. {
  40. return Result.Failure<Response>(Error.Problem("Auth.GoogleNotConfigured", "Google 로그인이 설정되지 않았습니다."));
  41. }
  42. // Google ID Token 검증
  43. var googleUser = await googleTokenValidator.ValidateAsync(request.Credential, googleClientId, ct);
  44. if (googleUser is null)
  45. {
  46. return Result.Failure<Response>(Error.Unauthorized("Auth.GoogleTokenInvalid", "Google 인증에 실패했습니다."));
  47. }
  48. var email = googleUser.Email.Trim().ToLower();
  49. // Member 조회
  50. var member = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct);
  51. if (member is null)
  52. {
  53. // 자동 회원가입
  54. var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct);
  55. if (accountConfig.IsRegisterBlock)
  56. {
  57. return Result.Failure<Response>(Error.Problem("Auth.RegisterBlocked", "현재 회원가입이 차단되어 있습니다."));
  58. }
  59. member = Domain.Entities.Members.Member.Create(email);
  60. await db.Member.AddAsync(member, ct);
  61. await db.SaveChangesAsync(ct);
  62. // 연관 엔티티 생성
  63. await db.MemberApprove.AddAsync(
  64. Domain.Entities.Members.MemberApprove.Create(member.ID), ct
  65. );
  66. await db.MemberStats.AddAsync(
  67. Domain.Entities.Members.MemberStats.Create(member.ID), ct
  68. );
  69. await db.Wallet.AddAsync(
  70. Domain.Entities.Wallets.Wallet.Create(member.ID), ct
  71. );
  72. // 구글 인증이므로 이메일 인증 완료 처리
  73. member.MarkEmailVerified();
  74. await db.SaveChangesAsync(ct);
  75. // 가입 축하 보상 (멱등 — RefID "signup:{memberID}")
  76. await SignupRewardGranter.GrantAsync(db, cache, member.ID, ct);
  77. await db.SaveChangesAsync(ct);
  78. logger.LogInformation("Google 자동 회원가입: {Email}", email);
  79. }
  80. // 탈퇴 회원 거부
  81. if (member.IsWithdraw)
  82. {
  83. await LogLoginAttempt(member.ID, false, email, "탈퇴 회원 (Google)", request, ct);
  84. return Result.Failure<Response>(Error.Problem("Auth.MemberWithdrawn", "탈퇴한 회원은 이용할 수 없습니다."));
  85. }
  86. // 차단 회원 거부
  87. if (member.IsDenied)
  88. {
  89. await LogLoginAttempt(member.ID, false, email, "차단 회원 (Google)", request, ct);
  90. return Result.Failure<Response>(Error.Problem("Auth.MemberDenied", "차단된 회원이므로 이용할 수 없습니다."));
  91. }
  92. // JWT 토큰 생성
  93. var accessToken = jwtTokenProvider.CreateAccessToken(member.ID, member.Email, member.Name);
  94. var refreshToken = jwtTokenProvider.CreateRefreshToken();
  95. var expiresAt = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiration);
  96. // RefreshToken 저장
  97. var refreshTokenEntity = Domain.Entities.Members.RefreshToken.Create(
  98. member.ID,
  99. refreshToken,
  100. DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiration)
  101. );
  102. await db.RefreshToken.AddAsync(refreshTokenEntity, ct);
  103. // 로그인 횟수 증가
  104. var memberStats = await db.MemberStats.FirstOrDefaultAsync(x => x.MemberID == member.ID, ct);
  105. if (memberStats is not null)
  106. {
  107. memberStats.LoginCount++;
  108. }
  109. // 최종 접속 정보 갱신
  110. member.RecordLogin(request.IpAddress, request.UserAgent);
  111. // 성공 로그 기록
  112. await LogLoginAttempt(member.ID, true, email, "Google", request, ct);
  113. await db.SaveChangesAsync(ct);
  114. logger.LogInformation("{Email} Google 로그인", member.Email);
  115. return Result.Success(new Response(accessToken, refreshToken, expiresAt));
  116. }
  117. private async Task LogLoginAttempt(int? memberID, bool success, string account, string? reason, Command request, CancellationToken ct)
  118. {
  119. var log = MemberLoginLog.Create(
  120. memberID,
  121. success,
  122. account,
  123. reason,
  124. ipAddress: request.IpAddress,
  125. userAgent: request.UserAgent
  126. );
  127. await db.MemberLoginLog.AddAsync(log, ct);
  128. await db.SaveChangesAsync(ct);
  129. }
  130. }