using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Application.Abstractions.Cache; using Application.Abstractions.Kyc; using Infrastructure.Kyc.Nice.Dto; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharedKernel.Results; namespace Infrastructure.Kyc.Nice; /// /// NICE 통합인증 API HTTP 클라이언트. /// /// 가이드: .claude/plan/nice-auth-integration.md /// internal sealed class NiceAuthClient( HttpClient http, IOptions options, ICacheService cache, ILogger logger) : INiceAuthClient { private const string TokenPath = "/ido/intc/v1.0/auth/token"; private const string UrlPath = "/ido/intc/v1.0/auth/url"; private const string ResultPath = "/ido/intc/v1.0/auth/result"; private const string SuccessCode = "0000"; private readonly NiceAuthOptions _opt = options.Value; // ───────────────────────────────────────────────────────────── // 1) Token // ───────────────────────────────────────────────────────────── public async Task> GetTokenAsync(CancellationToken ct) { if (!_opt.IsConfigured) { return Result.Failure(Error.Problem( "Nice.NotConfigured", "본인인증 서비스가 아직 준비되지 않았습니다. 관리자에게 문의해 주세요.")); } var cacheKey = _opt.TokenCacheKeyPrefix + ":current"; // 캐시 hit 확인 var cached = await cache.GetAsync(cacheKey, ct); if (cached is not null && cached.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5)) { return Result.Success(cached); } var requestNo = BuildRequestNo(); var basic = NiceCrypto.Base64UrlEncode( Encoding.UTF8.GetBytes($"{_opt.ClientId}:{_opt.ClientSecret}")); using var req = new HttpRequestMessage(HttpMethod.Post, TokenPath); req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); req.Content = JsonContent.Create(new { grant_type = "client_credentials", request_no = requestNo }); NiceTokenResponse? body; try { using var res = await http.SendAsync(req, ct); body = await res.Content.ReadFromJsonAsync(ct); } catch (TaskCanceledException ex) when (!ct.IsCancellationRequested) { logger.LogError(ex, "NICE token 요청 타임아웃"); return Result.Failure(NiceErrorMap.Map(null, "token")); } catch (HttpRequestException ex) { logger.LogError(ex, "NICE token 요청 실패"); return Result.Failure(NiceErrorMap.Map(null, "token")); } if (body is null || body.ResultCode != SuccessCode || string.IsNullOrEmpty(body.AccessToken) || string.IsNullOrEmpty(body.Ticket)) { logger.LogWarning( "NICE token 실패: code={Code} msg={Msg}", body?.ResultCode, body?.ResultMessage); return Result.Failure(NiceErrorMap.Map(body?.ResultCode, "token")); } var info = new NiceTokenInfo( body.AccessToken, body.Ticket, body.Iterators, DateTimeOffset.FromUnixTimeMilliseconds(body.ExpiresIn)); await cache.SetAsync(cacheKey, info, TimeSpan.FromSeconds(_opt.TokenCacheTtlSeconds), ct); return Result.Success(info); } // ───────────────────────────────────────────────────────────── // 2) Auth URL // ───────────────────────────────────────────────────────────── public async Task> RequestAuthUrlAsync(string sessionToken, CancellationToken ct) { ArgumentException.ThrowIfNullOrEmpty(sessionToken); var requestNo = BuildRequestNo(); // NICE return_url path segment 로 sessionToken embed → callback 매칭에 사용 var returnUrl = _opt.ReturnUrl.TrimEnd('/') + "/" + sessionToken; var tokenRes = await GetTokenAsync(ct); if (tokenRes.IsFailure) { return Result.Failure(tokenRes.Error); } var token = tokenRes.Value; var bodyResult = await SendUrlRequestAsync(requestNo, returnUrl, token.AccessToken, ct); // 토큰 만료 시 캐시 무효화 후 1회 재시도 if (bodyResult.IsFailure && IsTokenExpiredCode(bodyResult.Error.Code)) { await InvalidateTokenCacheAsync(ct); tokenRes = await GetTokenAsync(ct); if (tokenRes.IsFailure) { return Result.Failure(tokenRes.Error); } token = tokenRes.Value; bodyResult = await SendUrlRequestAsync(requestNo, returnUrl, token.AccessToken, ct); } if (bodyResult.IsFailure) { return Result.Failure(bodyResult.Error); } var body = bodyResult.Value; return Result.Success(new NiceUrlInfo( body.AuthUrl!, body.TransactionId!, requestNo, token.Ticket, token.Iterators)); } private async Task> SendUrlRequestAsync( string requestNo, string returnUrl, string accessToken, CancellationToken ct) { using var req = new HttpRequestMessage(HttpMethod.Post, UrlPath); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); req.Content = JsonContent.Create(new { request_no = requestNo, return_url = returnUrl, close_url = _opt.CloseUrl, svc_types = _opt.DefaultSvcTypes, method_type = _opt.MethodType, exp_mods = new[] { "closeButtonOn" } }); NiceUrlResponse? body; try { using var res = await http.SendAsync(req, ct); body = await res.Content.ReadFromJsonAsync(ct); } catch (TaskCanceledException ex) when (!ct.IsCancellationRequested) { logger.LogError(ex, "NICE url 요청 타임아웃"); return Result.Failure(NiceErrorMap.Map(null, "url")); } catch (HttpRequestException ex) { logger.LogError(ex, "NICE url 요청 실패"); return Result.Failure(NiceErrorMap.Map(null, "url")); } if (body is null || body.ResultCode != SuccessCode || string.IsNullOrEmpty(body.AuthUrl) || string.IsNullOrEmpty(body.TransactionId)) { logger.LogWarning( "NICE url 실패: code={Code} msg={Msg}", body?.ResultCode, body?.ResultMessage); return Result.Failure(NiceErrorMap.Map(body?.ResultCode, "url")); } return Result.Success(body); } // ───────────────────────────────────────────────────────────── // 3) Result + 복호화 // ───────────────────────────────────────────────────────────── public async Task> GetResultAsync( string requestNo, string transactionId, string webTransactionId, string ticket, int iterators, CancellationToken ct) { ArgumentException.ThrowIfNullOrEmpty(requestNo); ArgumentException.ThrowIfNullOrEmpty(transactionId); ArgumentException.ThrowIfNullOrEmpty(webTransactionId); ArgumentException.ThrowIfNullOrEmpty(ticket); var tokenRes = await GetTokenAsync(ct); if (tokenRes.IsFailure) { return Result.Failure(tokenRes.Error); } var token = tokenRes.Value; var bodyResult = await SendResultRequestAsync(requestNo, transactionId, webTransactionId, token.AccessToken, ct); if (bodyResult.IsFailure && IsTokenExpiredCode(bodyResult.Error.Code)) { await InvalidateTokenCacheAsync(ct); tokenRes = await GetTokenAsync(ct); if (tokenRes.IsFailure) { return Result.Failure(tokenRes.Error); } token = tokenRes.Value; bodyResult = await SendResultRequestAsync(requestNo, transactionId, webTransactionId, token.AccessToken, ct); } if (bodyResult.IsFailure) { return Result.Failure(bodyResult.Error); } var body = bodyResult.Value; // HMAC 무결성 + AES-GCM 복호화 (NiceAuthSession 의 ticket/iterators 사용) byte[] aesKey, hmacKey; try { (aesKey, hmacKey) = NiceCrypto.DeriveKeys(ticket, transactionId, iterators); } catch (Exception ex) { logger.LogError(ex, "NICE KDF 실패 tx={Tx}", transactionId); return Result.Failure(Error.Problem( "Nice.KdfFailed", "본인인증 응답 처리 중 오류가 발생했습니다. 다시 시도해 주세요.")); } if (!NiceCrypto.VerifyIntegrity(hmacKey, body.EncData!, body.IntegrityValue!)) { logger.LogError("NICE integrity 검증 실패 tx={Tx}", transactionId); return Result.Failure(NiceErrorMap.Map("9402", "result")); } string plaintextJson; try { plaintextJson = NiceCrypto.DecryptAesGcm(aesKey, body.EncData!); } catch (CryptographicException ex) { logger.LogError(ex, "NICE 복호화 실패 tx={Tx}", transactionId); return Result.Failure(NiceErrorMap.Map("3025", "result")); } NiceResultDecryptedDto? dto; try { dto = JsonSerializer.Deserialize(plaintextJson); } catch (JsonException ex) { logger.LogError(ex, "NICE 복호화 JSON 파싱 실패 tx={Tx}", transactionId); return Result.Failure(Error.Problem( "Nice.ParseFailed", "본인인증 응답 처리 중 오류가 발생했습니다.")); } if (dto is null || string.IsNullOrEmpty(dto.Name) || string.IsNullOrEmpty(dto.Ci) || string.IsNullOrEmpty(dto.Di)) { logger.LogError("NICE 복호화 결과 필수 필드 누락 tx={Tx}", transactionId); return Result.Failure(Error.Problem( "Nice.ParseFailed", "본인인증 응답 처리 중 오류가 발생했습니다.")); } var user = new NiceUserInfo( dto.Name!, dto.Birthdate ?? string.Empty, dto.Gender ?? string.Empty, dto.NationalInfo ?? string.Empty, dto.Ci!, dto.Ci2, dto.Di!, dto.MobileCo, dto.MobileNo); return Result.Success(user); } private async Task> SendResultRequestAsync( string requestNo, string transactionId, string webTransactionId, string accessToken, CancellationToken ct) { using var req = new HttpRequestMessage(HttpMethod.Post, ResultPath); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); req.Content = JsonContent.Create(new { web_transaction_id = webTransactionId, transaction_id = transactionId, request_no = requestNo }); NiceResultResponse? body; try { using var res = await http.SendAsync(req, ct); body = await res.Content.ReadFromJsonAsync(ct); } catch (TaskCanceledException ex) when (!ct.IsCancellationRequested) { logger.LogError(ex, "NICE result 요청 타임아웃"); return Result.Failure(NiceErrorMap.Map(null, "result")); } catch (HttpRequestException ex) { logger.LogError(ex, "NICE result 요청 실패"); return Result.Failure(NiceErrorMap.Map(null, "result")); } if (body is null || body.ResultCode != SuccessCode || string.IsNullOrEmpty(body.EncData) || string.IsNullOrEmpty(body.IntegrityValue)) { logger.LogWarning( "NICE result 실패: code={Code} msg={Msg}", body?.ResultCode, body?.ResultMessage); return Result.Failure(NiceErrorMap.Map(body?.ResultCode, "result")); } return Result.Success(body); } // ───────────────────────────────────────────────────────────── // helpers // ───────────────────────────────────────────────────────────── private static bool IsTokenExpiredCode(string code) { return code is "Nice.TokenInvalid"; } private async Task InvalidateTokenCacheAsync(CancellationToken ct) { await cache.RemoveAsync(_opt.TokenCacheKeyPrefix + ":current", ct); } /// /// 가맹점 측 고유번호. NICE 요구 길이: 20~50 byte. /// 형식: "DPOT" + yyyyMMddHHmmssfff (17 char) + 8 hex char = 29 char. /// private static string BuildRequestNo() { var ts = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff"); var rand = Convert.ToHexString(RandomNumberGenerator.GetBytes(4)); return $"DPOT{ts}{rand}"; } }