using Application.Abstractions.Authentication; using Application.Abstractions.Cache; using Application.Abstractions.Crypto; using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Helpers; using Domain.Entities.Members; using Domain.Entities.Members.Logs; using SharedKernel; using SharedKernel.Results; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Application.Features.Api.Auth.SocialLogin; /// /// Naver/Kakao 소셜 로그인 (d5 §③④ — d0 확정 반영). /// 매칭 정책: 이메일로 기존 회원 조회 → 해당 provider 연동(MemberOAuthToken) 있으면 로그인, /// 없으면 Auth.AccountExists 실패 (자동 계정연결 금지 — 연결은 로그인 후 sns-link 흐름). /// 기존 회원이 없으면 자동가입 + 연동 행 생성. 카카오 이메일 미동의는 Auth.EmailRequired 실패. /// internal sealed class Handler( IAppDbContext db, IJwtTokenProvider jwtTokenProvider, IEnumerable socialProviders, IFieldEncryptor fieldEncryptor, IOptions options, ILogger logger, ICacheService cache ) : ICommandHandler> { private const string NaverProvider = "Naver"; private const string KakaoProvider = "Kakao"; private readonly AppSettings.JwtSection _jwt = options.Value.JWT; private readonly AppSettings.OAuthSection _oauth = options.Value.OAuth; public async Task> Handle(Command request, CancellationToken ct) { // provider 화이트리스트 (Google 은 기존 google-login 경로 유지 — 여기 미포함) var providerName = NormalizeProvider(request.Provider); if (providerName is null) { return Result.Failure(Error.Problem("Auth.UnsupportedProvider", "지원하지 않는 소셜 로그인입니다.")); } if (string.IsNullOrWhiteSpace(request.Code)) { return Result.Failure(Error.Problem("Auth.CodeRequired", "Authorization code 가 필요합니다.")); } // 네이버는 state 가 스펙상 필수 (CSRF 방어) if (providerName == NaverProvider && string.IsNullOrWhiteSpace(request.State)) { return Result.Failure(Error.Problem("Auth.StateRequired", "state 값이 필요합니다.")); } var oauthConfig = providerName == NaverProvider ? _oauth.Naver : _oauth.Kakao; if (string.IsNullOrWhiteSpace(oauthConfig.ClientId)) { return Result.Failure(Error.Problem("Auth.SocialNotConfigured", $"{providerName} 로그인이 설정되지 않았습니다.")); } // 네이버는 client_secret 필수, 카카오는 콘솔 활성화 시에만 전송 if (providerName == NaverProvider && string.IsNullOrWhiteSpace(oauthConfig.ClientSecret)) { return Result.Failure(Error.Problem("Auth.SocialNotConfigured", $"{providerName} 로그인이 설정되지 않았습니다.")); } var redirectUri = string.IsNullOrWhiteSpace(request.RedirectUri) ? oauthConfig.RedirectUri : request.RedirectUri; if (string.IsNullOrWhiteSpace(redirectUri)) { return Result.Failure(Error.Problem("Auth.RedirectUriRequired", "Redirect URI 가 필요합니다.")); } var provider = socialProviders.FirstOrDefault(c => c.Provider == providerName); if (provider is null) { return Result.Failure(Error.Problem("Auth.UnsupportedProvider", "지원하지 않는 소셜 로그인입니다.")); } // 승인 코드 → 토큰 교환 var clientSecret = string.IsNullOrWhiteSpace(oauthConfig.ClientSecret) ? null : oauthConfig.ClientSecret; var tokens = await provider.ExchangeCodeAsync(request.Code, redirectUri, request.State, oauthConfig.ClientId, clientSecret, ct); if (tokens is null) { return Result.Failure(Error.Unauthorized("Auth.SocialTokenExchangeFailed", $"{providerName} 인증 코드 교환에 실패했습니다.")); } // 프로필 조회 var userInfo = await provider.GetUserInfoAsync(tokens.AccessToken, ct); if (userInfo is null) { return Result.Failure(Error.Unauthorized("Auth.SocialUserInfoFailed", $"{providerName} 프로필 조회에 실패했습니다.")); } // 카카오 이메일 선택동의 미동의 → 프론트가 이메일 보완 화면으로 유도할 수 있게 에러 코드 분리 if (string.IsNullOrWhiteSpace(userInfo.Email)) { return Result.Failure(Error.Problem("Auth.EmailRequired", "이메일 제공에 동의해야 가입할 수 있습니다.")); } var email = userInfo.Email.Trim().ToLower(); var member = await db.Member.FirstOrDefaultAsync(c => c.Email == email, ct); if (member is null) { // 자동 회원가입 (GoogleLogin 시퀀스 재사용) var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct); if (accountConfig.IsRegisterBlock) { return Result.Failure(Error.Problem("Auth.RegisterBlocked", "현재 회원가입이 차단되어 있습니다.")); } member = Domain.Entities.Members.Member.Create(email); await db.Member.AddAsync(member, ct); await db.SaveChangesAsync(ct); await db.MemberApprove.AddAsync(MemberApprove.Create(member.ID), ct); await db.MemberStats.AddAsync(MemberStats.Create(member.ID), ct); await db.Wallet.AddAsync(Domain.Entities.Wallets.Wallet.Create(member.ID), ct); // provider 가 검증을 보장하는 이메일만 인증 완료 처리 (카카오 is_email_verified) // 네이버는 연락처 이메일이라 미검증 유지 → 기존 이메일 인증(resend-email) 흐름으로 보완 if (userInfo.EmailVerified) { member.MarkEmailVerified(); } await db.SaveChangesAsync(ct); // 가입 축하 보상 (멱등 — RefID "signup:{memberID}") await SignupRewardGranter.GrantAsync(db, cache, member.ID, ct); await db.SaveChangesAsync(ct); logger.LogInformation("{Provider} 자동 회원가입: {Email}", providerName, email); } else { // 탈퇴 회원 거부 if (member.IsWithdraw) { await LogLoginAttempt(member.ID, false, email, $"탈퇴 회원 ({providerName})", request, ct); return Result.Failure(Error.Problem("Auth.MemberWithdrawn", "탈퇴한 회원은 이용할 수 없습니다.")); } // 차단 회원 거부 if (member.IsDenied) { await LogLoginAttempt(member.ID, false, email, $"차단 회원 ({providerName})", request, ct); return Result.Failure(Error.Problem("Auth.MemberDenied", "차단된 회원이므로 이용할 수 없습니다.")); } // 자동 계정연결 금지 (d0 §5-⑦): 같은 이메일의 기존 계정이 있어도 해당 provider 연동이 없으면 로그인 불가 var hasLink = await db.MemberOAuthToken.AnyAsync(c => c.MemberID == member.ID && c.Provider == providerName, ct); if (!hasLink) { await LogLoginAttempt(member.ID, false, email, $"미연동 소셜 로그인 시도 ({providerName})", request, ct); return Result.Failure(Error.Conflict("Auth.AccountExists", "이미 가입된 이메일입니다. 기존 계정으로 로그인 후 SNS 연동을 이용해주세요.")); } } // 연동 토큰 upsert (신규 저장분은 IFieldEncryptor 암호화 — d5 R2) // ProviderUserKey(카카오 id, 네이버 response.id) 를 함께 저장/backfill — disconnect webhook 매칭용 await UpsertOAuthToken(member.ID, providerName, tokens, userInfo.ProviderUserKey, ct); // 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(c => c.MemberID == member.ID, ct); if (memberStats is not null) { memberStats.LoginCount++; } // 최종 접속 정보 갱신 member.RecordLogin(request.IpAddress, request.UserAgent); // 성공 로그 기록 await LogLoginAttempt(member.ID, true, email, providerName, request, ct); await db.SaveChangesAsync(ct); logger.LogInformation("{Email} {Provider} 로그인", member.Email, providerName); return Result.Success(new Response(accessToken, refreshToken, expiresAt)); } private static string? NormalizeProvider(string? provider) { return provider?.Trim().ToLowerInvariant() switch { "naver" => NaverProvider, "kakao" => KakaoProvider, _ => null }; } private async Task UpsertOAuthToken(int memberID, string providerName, SocialTokens tokens, string? providerUserKey, CancellationToken ct) { var accessTokenEnc = fieldEncryptor.Encrypt(tokens.AccessToken); var refreshTokenEnc = tokens.RefreshToken is null ? null : fieldEncryptor.Encrypt(tokens.RefreshToken); var scopes = string.Join(" ", tokens.Scopes); var existing = await db.MemberOAuthToken.FirstOrDefaultAsync(c => c.MemberID == memberID && c.Provider == providerName, ct); if (existing is null) { var token = MemberOAuthToken.Create( memberID, providerName, accessTokenEnc, refreshTokenEnc, tokens.ExpiresAt, scopes, providerUserKey ); db.MemberOAuthToken.Add(token); } else { existing.UpdateTokens(accessTokenEnc, refreshTokenEnc, tokens.ExpiresAt, scopes, providerUserKey); } } 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); } }