KakaoAuthProvider.cs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. using System.Text.Json;
  2. using Application.Abstractions.Authentication;
  3. using Microsoft.Extensions.Logging;
  4. namespace Infrastructure.Authentication;
  5. /// <summary>
  6. /// 카카오 로그인 (OAuth 2.0 authorization code). client_id 는 REST API 키.
  7. /// account_email 은 기본 선택동의 (필수동의 전환은 비즈앱 필요 — d0 §5-⑦ 추후) → email null 가능.
  8. /// client_secret 은 콘솔에서 활성화한 경우에만 필수 — 미설정(빈 값)이면 전송하지 않는다.
  9. /// </summary>
  10. public sealed class KakaoAuthProvider(
  11. HttpClient httpClient,
  12. ILogger<KakaoAuthProvider> logger
  13. ) : ISocialAuthProvider
  14. {
  15. public const string ProviderName = "Kakao";
  16. private const string AuthEndpoint = "https://kauth.kakao.com/oauth/authorize";
  17. private const string TokenEndpoint = "https://kauth.kakao.com/oauth/token";
  18. private const string UserInfoEndpoint = "https://kapi.kakao.com/v2/user/me";
  19. private const string Scope = "account_email profile_nickname profile_image";
  20. public string Provider => ProviderName;
  21. public string GetAuthorizationUrl(string clientId, string redirectUri, string state)
  22. {
  23. var parameters = new Dictionary<string, string>
  24. {
  25. ["response_type"] = "code",
  26. ["client_id"] = clientId,
  27. ["redirect_uri"] = redirectUri,
  28. ["state"] = state, // 스펙상 선택이나 CSRF 방어 위해 항상 전송 (d5 §③)
  29. ["scope"] = Scope
  30. };
  31. var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
  32. return $"{AuthEndpoint}?{query}";
  33. }
  34. public async Task<SocialTokens?> ExchangeCodeAsync(string code, string redirectUri, string? state, string clientId, string? clientSecret, CancellationToken ct)
  35. {
  36. try
  37. {
  38. var form = new Dictionary<string, string>
  39. {
  40. ["grant_type"] = "authorization_code",
  41. ["client_id"] = clientId,
  42. ["redirect_uri"] = redirectUri,
  43. ["code"] = code
  44. };
  45. if (!string.IsNullOrWhiteSpace(clientSecret))
  46. {
  47. form["client_secret"] = clientSecret;
  48. }
  49. var response = await httpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(form), ct);
  50. var json = await response.Content.ReadAsStringAsync(ct);
  51. if (!response.IsSuccessStatusCode)
  52. {
  53. logger.LogWarning("Kakao OAuth code exchange failed: {StatusCode} {Body}", response.StatusCode, json);
  54. return null;
  55. }
  56. var tokens = ParseTokens(json);
  57. if (tokens is null)
  58. {
  59. logger.LogWarning("Kakao OAuth token response invalid: {Body}", json);
  60. }
  61. return tokens;
  62. }
  63. catch (Exception ex)
  64. {
  65. logger.LogError(ex, "Kakao OAuth code exchange error");
  66. return null;
  67. }
  68. }
  69. public async Task<SocialUserInfo?> GetUserInfoAsync(string accessToken, CancellationToken ct)
  70. {
  71. try
  72. {
  73. using var request = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint);
  74. request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
  75. var response = await httpClient.SendAsync(request, ct);
  76. var json = await response.Content.ReadAsStringAsync(ct);
  77. if (!response.IsSuccessStatusCode)
  78. {
  79. logger.LogWarning("Kakao user info failed: {StatusCode} {Body}", response.StatusCode, json);
  80. return null;
  81. }
  82. var userInfo = ParseUserInfo(json);
  83. if (userInfo is null)
  84. {
  85. logger.LogWarning("Kakao user info response invalid: {Body}", json);
  86. }
  87. return userInfo;
  88. }
  89. catch (Exception ex)
  90. {
  91. logger.LogError(ex, "Kakao user info error");
  92. return null;
  93. }
  94. }
  95. /// <summary>토큰 응답 파싱 — scope 는 공백 구분 문자열</summary>
  96. public static SocialTokens? ParseTokens(string json)
  97. {
  98. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  99. if (doc.ValueKind != JsonValueKind.Object || doc.TryGetProperty("error", out _))
  100. {
  101. return null;
  102. }
  103. if (!doc.TryGetProperty("access_token", out var accessTokenProp) || accessTokenProp.GetString() is not { Length: > 0 } accessToken)
  104. {
  105. return null;
  106. }
  107. var refreshToken = doc.TryGetProperty("refresh_token", out var rtProp) ? rtProp.GetString() : null;
  108. var expiresIn = NaverAuthProvider.ReadExpiresIn(doc);
  109. var scope = doc.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? string.Empty : string.Empty;
  110. return new SocialTokens(
  111. accessToken,
  112. refreshToken,
  113. DateTime.UtcNow.AddSeconds(expiresIn - 60), // 60초 여유
  114. scope.Split(' ', StringSplitOptions.RemoveEmptyEntries)
  115. );
  116. }
  117. /// <summary>
  118. /// 프로필 응답 파싱 — id 는 int64 (문자열화하여 ProviderUserKey 로 사용).
  119. /// email 은 선택동의 미동의 시 부재 → null. EmailVerified 는 is_email_verified 그대로.
  120. /// </summary>
  121. public static SocialUserInfo? ParseUserInfo(string json)
  122. {
  123. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  124. if (doc.ValueKind != JsonValueKind.Object || !doc.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.Number)
  125. {
  126. return null;
  127. }
  128. var id = idProp.GetInt64().ToString();
  129. string? email = null;
  130. var emailVerified = false;
  131. string? nickname = null;
  132. string? profileImage = null;
  133. if (doc.TryGetProperty("kakao_account", out var account) && account.ValueKind == JsonValueKind.Object)
  134. {
  135. if (account.TryGetProperty("email", out var emailProp))
  136. {
  137. email = emailProp.GetString();
  138. }
  139. if (account.TryGetProperty("is_email_verified", out var verifiedProp))
  140. {
  141. emailVerified = verifiedProp.ValueKind == JsonValueKind.True;
  142. }
  143. if (account.TryGetProperty("profile", out var profile) && profile.ValueKind == JsonValueKind.Object)
  144. {
  145. nickname = profile.TryGetProperty("nickname", out var nickProp) ? nickProp.GetString() : null;
  146. profileImage = profile.TryGetProperty("profile_image_url", out var imgProp) ? imgProp.GetString() : null;
  147. }
  148. }
  149. return new SocialUserInfo(id, email, email is not null && emailVerified, nickname, profileImage);
  150. }
  151. }