NaverAuthProvider.cs 6.6 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). OIDC(id_token) 미지원이라 code → 토큰 → 프로필 API 경로 사용.
  7. /// state 는 스펙상 필수 (CSRF 방어) — 호출자(핸들러)가 검증하고 토큰 교환에도 전달한다.
  8. /// email 은 "연락처 이메일"이라 타사 주소 가능 + 검증 플래그 없음 → EmailVerified 항상 false (d5 §③).
  9. /// </summary>
  10. public sealed class NaverAuthProvider(
  11. HttpClient httpClient,
  12. ILogger<NaverAuthProvider> logger
  13. ) : ISocialAuthProvider
  14. {
  15. public const string ProviderName = "Naver";
  16. private const string AuthEndpoint = "https://nid.naver.com/oauth2.0/authorize";
  17. private const string TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
  18. private const string UserInfoEndpoint = "https://openapi.naver.com/v1/nid/me";
  19. public string Provider => ProviderName;
  20. public string GetAuthorizationUrl(string clientId, string redirectUri, string state)
  21. {
  22. var parameters = new Dictionary<string, string>
  23. {
  24. ["response_type"] = "code",
  25. ["client_id"] = clientId,
  26. ["redirect_uri"] = redirectUri,
  27. ["state"] = state
  28. };
  29. var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
  30. return $"{AuthEndpoint}?{query}";
  31. }
  32. public async Task<SocialTokens?> ExchangeCodeAsync(string code, string redirectUri, string? state, string clientId, string? clientSecret, CancellationToken ct)
  33. {
  34. try
  35. {
  36. var form = new Dictionary<string, string>
  37. {
  38. ["grant_type"] = "authorization_code",
  39. ["client_id"] = clientId,
  40. ["client_secret"] = clientSecret ?? string.Empty,
  41. ["code"] = code,
  42. ["redirect_uri"] = redirectUri
  43. };
  44. if (!string.IsNullOrWhiteSpace(state))
  45. {
  46. form["state"] = state;
  47. }
  48. var response = await httpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(form), ct);
  49. var json = await response.Content.ReadAsStringAsync(ct);
  50. // 네이버 토큰 엔드포인트는 오류도 200 으로 내려줄 수 있음 (error 필드) → 파서가 함께 거른다
  51. if (!response.IsSuccessStatusCode)
  52. {
  53. logger.LogWarning("Naver 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("Naver OAuth token response invalid: {Body}", json);
  60. }
  61. return tokens;
  62. }
  63. catch (Exception ex)
  64. {
  65. logger.LogError(ex, "Naver 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("Naver 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("Naver user info response invalid: {Body}", json);
  86. }
  87. return userInfo;
  88. }
  89. catch (Exception ex)
  90. {
  91. logger.LogError(ex, "Naver user info error");
  92. return null;
  93. }
  94. }
  95. /// <summary>토큰 응답 파싱 — 네이버는 expires_in 을 문자열("3600")로 내려준다</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 = ReadExpiresIn(doc);
  109. return new SocialTokens(
  110. accessToken,
  111. refreshToken,
  112. DateTime.UtcNow.AddSeconds(expiresIn - 60), // 60초 여유
  113. []
  114. );
  115. }
  116. /// <summary>프로필 응답 파싱 — resultcode "00" 외에는 실패. id 는 앱 단위 유니크 문자열(≤64자)</summary>
  117. public static SocialUserInfo? ParseUserInfo(string json)
  118. {
  119. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  120. if (doc.ValueKind != JsonValueKind.Object || !doc.TryGetProperty("resultcode", out var resultCodeProp) || resultCodeProp.GetString() != "00")
  121. {
  122. return null;
  123. }
  124. if (!doc.TryGetProperty("response", out var res) || res.ValueKind != JsonValueKind.Object)
  125. {
  126. return null;
  127. }
  128. if (!res.TryGetProperty("id", out var idProp) || idProp.GetString() is not { Length: > 0 } id)
  129. {
  130. return null;
  131. }
  132. var email = res.TryGetProperty("email", out var emailProp) ? emailProp.GetString() : null;
  133. var nickname = res.TryGetProperty("nickname", out var nickProp) ? nickProp.GetString() : null;
  134. var profileImage = res.TryGetProperty("profile_image", out var imgProp) ? imgProp.GetString() : null;
  135. // 네이버 email 은 연락처 이메일 (타사 주소 가능, 검증 플래그 없음) → 항상 미검증 취급
  136. return new SocialUserInfo(id, email, EmailVerified: false, nickname, profileImage);
  137. }
  138. internal static int ReadExpiresIn(JsonElement doc)
  139. {
  140. if (!doc.TryGetProperty("expires_in", out var expProp))
  141. {
  142. return 3600;
  143. }
  144. return expProp.ValueKind switch
  145. {
  146. JsonValueKind.Number => expProp.GetInt32(),
  147. JsonValueKind.String when int.TryParse(expProp.GetString(), out var parsed) => parsed,
  148. _ => 3600
  149. };
  150. }
  151. }