NiceAuthClient.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. using System.Net.Http.Headers;
  2. using System.Net.Http.Json;
  3. using System.Security.Cryptography;
  4. using System.Text;
  5. using System.Text.Json;
  6. using Application.Abstractions.Cache;
  7. using Application.Abstractions.Kyc;
  8. using Infrastructure.Kyc.Nice.Dto;
  9. using Microsoft.Extensions.Logging;
  10. using Microsoft.Extensions.Options;
  11. using SharedKernel.Results;
  12. namespace Infrastructure.Kyc.Nice;
  13. /// <summary>
  14. /// NICE 통합인증 API HTTP 클라이언트.
  15. ///
  16. /// 가이드: .claude/plan/nice-auth-integration.md
  17. /// </summary>
  18. internal sealed class NiceAuthClient(
  19. HttpClient http,
  20. IOptions<NiceAuthOptions> options,
  21. ICacheService cache,
  22. ILogger<NiceAuthClient> logger) : INiceAuthClient
  23. {
  24. private const string TokenPath = "/ido/intc/v1.0/auth/token";
  25. private const string UrlPath = "/ido/intc/v1.0/auth/url";
  26. private const string ResultPath = "/ido/intc/v1.0/auth/result";
  27. private const string SuccessCode = "0000";
  28. private readonly NiceAuthOptions _opt = options.Value;
  29. // ─────────────────────────────────────────────────────────────
  30. // 1) Token
  31. // ─────────────────────────────────────────────────────────────
  32. public async Task<Result<NiceTokenInfo>> GetTokenAsync(CancellationToken ct)
  33. {
  34. if (!_opt.IsConfigured)
  35. {
  36. return Result.Failure<NiceTokenInfo>(Error.Problem(
  37. "Nice.NotConfigured",
  38. "본인인증 서비스가 아직 준비되지 않았습니다. 관리자에게 문의해 주세요."));
  39. }
  40. var cacheKey = _opt.TokenCacheKeyPrefix + ":current";
  41. // 캐시 hit 확인
  42. var cached = await cache.GetAsync<NiceTokenInfo>(cacheKey, ct);
  43. if (cached is not null && cached.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5))
  44. {
  45. return Result.Success(cached);
  46. }
  47. var requestNo = BuildRequestNo();
  48. var basic = NiceCrypto.Base64UrlEncode(
  49. Encoding.UTF8.GetBytes($"{_opt.ClientId}:{_opt.ClientSecret}"));
  50. using var req = new HttpRequestMessage(HttpMethod.Post, TokenPath);
  51. req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);
  52. req.Content = JsonContent.Create(new
  53. {
  54. grant_type = "client_credentials",
  55. request_no = requestNo
  56. });
  57. NiceTokenResponse? body;
  58. try
  59. {
  60. using var res = await http.SendAsync(req, ct);
  61. body = await res.Content.ReadFromJsonAsync<NiceTokenResponse>(ct);
  62. }
  63. catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
  64. {
  65. logger.LogError(ex, "NICE token 요청 타임아웃");
  66. return Result.Failure<NiceTokenInfo>(NiceErrorMap.Map(null, "token"));
  67. }
  68. catch (HttpRequestException ex)
  69. {
  70. logger.LogError(ex, "NICE token 요청 실패");
  71. return Result.Failure<NiceTokenInfo>(NiceErrorMap.Map(null, "token"));
  72. }
  73. if (body is null || body.ResultCode != SuccessCode || string.IsNullOrEmpty(body.AccessToken)
  74. || string.IsNullOrEmpty(body.Ticket))
  75. {
  76. logger.LogWarning(
  77. "NICE token 실패: code={Code} msg={Msg}",
  78. body?.ResultCode, body?.ResultMessage);
  79. return Result.Failure<NiceTokenInfo>(NiceErrorMap.Map(body?.ResultCode, "token"));
  80. }
  81. var info = new NiceTokenInfo(
  82. body.AccessToken,
  83. body.Ticket,
  84. body.Iterators,
  85. DateTimeOffset.FromUnixTimeMilliseconds(body.ExpiresIn));
  86. await cache.SetAsync(cacheKey, info, TimeSpan.FromSeconds(_opt.TokenCacheTtlSeconds), ct);
  87. return Result.Success(info);
  88. }
  89. // ─────────────────────────────────────────────────────────────
  90. // 2) Auth URL
  91. // ─────────────────────────────────────────────────────────────
  92. public async Task<Result<NiceUrlInfo>> RequestAuthUrlAsync(string sessionToken, CancellationToken ct)
  93. {
  94. ArgumentException.ThrowIfNullOrEmpty(sessionToken);
  95. var requestNo = BuildRequestNo();
  96. // NICE return_url path segment 로 sessionToken embed → callback 매칭에 사용
  97. var returnUrl = _opt.ReturnUrl.TrimEnd('/') + "/" + sessionToken;
  98. var tokenRes = await GetTokenAsync(ct);
  99. if (tokenRes.IsFailure)
  100. {
  101. return Result.Failure<NiceUrlInfo>(tokenRes.Error);
  102. }
  103. var token = tokenRes.Value;
  104. var bodyResult = await SendUrlRequestAsync(requestNo, returnUrl, token.AccessToken, ct);
  105. // 토큰 만료 시 캐시 무효화 후 1회 재시도
  106. if (bodyResult.IsFailure && IsTokenExpiredCode(bodyResult.Error.Code))
  107. {
  108. await InvalidateTokenCacheAsync(ct);
  109. tokenRes = await GetTokenAsync(ct);
  110. if (tokenRes.IsFailure)
  111. {
  112. return Result.Failure<NiceUrlInfo>(tokenRes.Error);
  113. }
  114. token = tokenRes.Value;
  115. bodyResult = await SendUrlRequestAsync(requestNo, returnUrl, token.AccessToken, ct);
  116. }
  117. if (bodyResult.IsFailure)
  118. {
  119. return Result.Failure<NiceUrlInfo>(bodyResult.Error);
  120. }
  121. var body = bodyResult.Value;
  122. return Result.Success(new NiceUrlInfo(
  123. body.AuthUrl!,
  124. body.TransactionId!,
  125. requestNo,
  126. token.Ticket,
  127. token.Iterators));
  128. }
  129. private async Task<Result<NiceUrlResponse>> SendUrlRequestAsync(
  130. string requestNo, string returnUrl, string accessToken, CancellationToken ct)
  131. {
  132. using var req = new HttpRequestMessage(HttpMethod.Post, UrlPath);
  133. req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
  134. req.Content = JsonContent.Create(new
  135. {
  136. request_no = requestNo,
  137. return_url = returnUrl,
  138. close_url = _opt.CloseUrl,
  139. svc_types = _opt.DefaultSvcTypes,
  140. method_type = _opt.MethodType,
  141. exp_mods = new[] { "closeButtonOn" }
  142. });
  143. NiceUrlResponse? body;
  144. try
  145. {
  146. using var res = await http.SendAsync(req, ct);
  147. body = await res.Content.ReadFromJsonAsync<NiceUrlResponse>(ct);
  148. }
  149. catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
  150. {
  151. logger.LogError(ex, "NICE url 요청 타임아웃");
  152. return Result.Failure<NiceUrlResponse>(NiceErrorMap.Map(null, "url"));
  153. }
  154. catch (HttpRequestException ex)
  155. {
  156. logger.LogError(ex, "NICE url 요청 실패");
  157. return Result.Failure<NiceUrlResponse>(NiceErrorMap.Map(null, "url"));
  158. }
  159. if (body is null || body.ResultCode != SuccessCode
  160. || string.IsNullOrEmpty(body.AuthUrl)
  161. || string.IsNullOrEmpty(body.TransactionId))
  162. {
  163. logger.LogWarning(
  164. "NICE url 실패: code={Code} msg={Msg}",
  165. body?.ResultCode, body?.ResultMessage);
  166. return Result.Failure<NiceUrlResponse>(NiceErrorMap.Map(body?.ResultCode, "url"));
  167. }
  168. return Result.Success(body);
  169. }
  170. // ─────────────────────────────────────────────────────────────
  171. // 3) Result + 복호화
  172. // ─────────────────────────────────────────────────────────────
  173. public async Task<Result<NiceUserInfo>> GetResultAsync(
  174. string requestNo,
  175. string transactionId,
  176. string webTransactionId,
  177. string ticket,
  178. int iterators,
  179. CancellationToken ct)
  180. {
  181. ArgumentException.ThrowIfNullOrEmpty(requestNo);
  182. ArgumentException.ThrowIfNullOrEmpty(transactionId);
  183. ArgumentException.ThrowIfNullOrEmpty(webTransactionId);
  184. ArgumentException.ThrowIfNullOrEmpty(ticket);
  185. var tokenRes = await GetTokenAsync(ct);
  186. if (tokenRes.IsFailure)
  187. {
  188. return Result.Failure<NiceUserInfo>(tokenRes.Error);
  189. }
  190. var token = tokenRes.Value;
  191. var bodyResult = await SendResultRequestAsync(requestNo, transactionId, webTransactionId, token.AccessToken, ct);
  192. if (bodyResult.IsFailure && IsTokenExpiredCode(bodyResult.Error.Code))
  193. {
  194. await InvalidateTokenCacheAsync(ct);
  195. tokenRes = await GetTokenAsync(ct);
  196. if (tokenRes.IsFailure)
  197. {
  198. return Result.Failure<NiceUserInfo>(tokenRes.Error);
  199. }
  200. token = tokenRes.Value;
  201. bodyResult = await SendResultRequestAsync(requestNo, transactionId, webTransactionId, token.AccessToken, ct);
  202. }
  203. if (bodyResult.IsFailure)
  204. {
  205. return Result.Failure<NiceUserInfo>(bodyResult.Error);
  206. }
  207. var body = bodyResult.Value;
  208. // HMAC 무결성 + AES-GCM 복호화 (NiceAuthSession 의 ticket/iterators 사용)
  209. byte[] aesKey, hmacKey;
  210. try
  211. {
  212. (aesKey, hmacKey) = NiceCrypto.DeriveKeys(ticket, transactionId, iterators);
  213. }
  214. catch (Exception ex)
  215. {
  216. logger.LogError(ex, "NICE KDF 실패 tx={Tx}", transactionId);
  217. return Result.Failure<NiceUserInfo>(Error.Problem(
  218. "Nice.KdfFailed",
  219. "본인인증 응답 처리 중 오류가 발생했습니다. 다시 시도해 주세요."));
  220. }
  221. if (!NiceCrypto.VerifyIntegrity(hmacKey, body.EncData!, body.IntegrityValue!))
  222. {
  223. logger.LogError("NICE integrity 검증 실패 tx={Tx}", transactionId);
  224. return Result.Failure<NiceUserInfo>(NiceErrorMap.Map("9402", "result"));
  225. }
  226. string plaintextJson;
  227. try
  228. {
  229. plaintextJson = NiceCrypto.DecryptAesGcm(aesKey, body.EncData!);
  230. }
  231. catch (CryptographicException ex)
  232. {
  233. logger.LogError(ex, "NICE 복호화 실패 tx={Tx}", transactionId);
  234. return Result.Failure<NiceUserInfo>(NiceErrorMap.Map("3025", "result"));
  235. }
  236. NiceResultDecryptedDto? dto;
  237. try
  238. {
  239. dto = JsonSerializer.Deserialize<NiceResultDecryptedDto>(plaintextJson);
  240. }
  241. catch (JsonException ex)
  242. {
  243. logger.LogError(ex, "NICE 복호화 JSON 파싱 실패 tx={Tx}", transactionId);
  244. return Result.Failure<NiceUserInfo>(Error.Problem(
  245. "Nice.ParseFailed",
  246. "본인인증 응답 처리 중 오류가 발생했습니다."));
  247. }
  248. if (dto is null || string.IsNullOrEmpty(dto.Name) || string.IsNullOrEmpty(dto.Ci) || string.IsNullOrEmpty(dto.Di))
  249. {
  250. logger.LogError("NICE 복호화 결과 필수 필드 누락 tx={Tx}", transactionId);
  251. return Result.Failure<NiceUserInfo>(Error.Problem(
  252. "Nice.ParseFailed",
  253. "본인인증 응답 처리 중 오류가 발생했습니다."));
  254. }
  255. var user = new NiceUserInfo(
  256. dto.Name!,
  257. dto.Birthdate ?? string.Empty,
  258. dto.Gender ?? string.Empty,
  259. dto.NationalInfo ?? string.Empty,
  260. dto.Ci!,
  261. dto.Ci2,
  262. dto.Di!,
  263. dto.MobileCo,
  264. dto.MobileNo);
  265. return Result.Success(user);
  266. }
  267. private async Task<Result<NiceResultResponse>> SendResultRequestAsync(
  268. string requestNo,
  269. string transactionId,
  270. string webTransactionId,
  271. string accessToken,
  272. CancellationToken ct)
  273. {
  274. using var req = new HttpRequestMessage(HttpMethod.Post, ResultPath);
  275. req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
  276. req.Content = JsonContent.Create(new
  277. {
  278. web_transaction_id = webTransactionId,
  279. transaction_id = transactionId,
  280. request_no = requestNo
  281. });
  282. NiceResultResponse? body;
  283. try
  284. {
  285. using var res = await http.SendAsync(req, ct);
  286. body = await res.Content.ReadFromJsonAsync<NiceResultResponse>(ct);
  287. }
  288. catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
  289. {
  290. logger.LogError(ex, "NICE result 요청 타임아웃");
  291. return Result.Failure<NiceResultResponse>(NiceErrorMap.Map(null, "result"));
  292. }
  293. catch (HttpRequestException ex)
  294. {
  295. logger.LogError(ex, "NICE result 요청 실패");
  296. return Result.Failure<NiceResultResponse>(NiceErrorMap.Map(null, "result"));
  297. }
  298. if (body is null || body.ResultCode != SuccessCode
  299. || string.IsNullOrEmpty(body.EncData)
  300. || string.IsNullOrEmpty(body.IntegrityValue))
  301. {
  302. logger.LogWarning(
  303. "NICE result 실패: code={Code} msg={Msg}",
  304. body?.ResultCode, body?.ResultMessage);
  305. return Result.Failure<NiceResultResponse>(NiceErrorMap.Map(body?.ResultCode, "result"));
  306. }
  307. return Result.Success(body);
  308. }
  309. // ─────────────────────────────────────────────────────────────
  310. // helpers
  311. // ─────────────────────────────────────────────────────────────
  312. private static bool IsTokenExpiredCode(string code)
  313. {
  314. return code is "Nice.TokenInvalid";
  315. }
  316. private async Task InvalidateTokenCacheAsync(CancellationToken ct)
  317. {
  318. await cache.RemoveAsync(_opt.TokenCacheKeyPrefix + ":current", ct);
  319. }
  320. /// <summary>
  321. /// 가맹점 측 고유번호. NICE 요구 길이: 20~50 byte.
  322. /// 형식: "DPOT" + yyyyMMddHHmmssfff (17 char) + 8 hex char = 29 char.
  323. /// </summary>
  324. private static string BuildRequestNo()
  325. {
  326. var ts = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff");
  327. var rand = Convert.ToHexString(RandomNumberGenerator.GetBytes(4));
  328. return $"DPOT{ts}{rand}";
  329. }
  330. }