| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- 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;
- /// <summary>
- /// NICE 통합인증 API HTTP 클라이언트.
- ///
- /// 가이드: .claude/plan/nice-auth-integration.md
- /// </summary>
- internal sealed class NiceAuthClient(
- HttpClient http,
- IOptions<NiceAuthOptions> options,
- ICacheService cache,
- ILogger<NiceAuthClient> 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<Result<NiceTokenInfo>> GetTokenAsync(CancellationToken ct)
- {
- if (!_opt.IsConfigured)
- {
- return Result.Failure<NiceTokenInfo>(Error.Problem(
- "Nice.NotConfigured",
- "본인인증 서비스가 아직 준비되지 않았습니다. 관리자에게 문의해 주세요."));
- }
- var cacheKey = _opt.TokenCacheKeyPrefix + ":current";
- // 캐시 hit 확인
- var cached = await cache.GetAsync<NiceTokenInfo>(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<NiceTokenResponse>(ct);
- }
- catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
- {
- logger.LogError(ex, "NICE token 요청 타임아웃");
- return Result.Failure<NiceTokenInfo>(NiceErrorMap.Map(null, "token"));
- }
- catch (HttpRequestException ex)
- {
- logger.LogError(ex, "NICE token 요청 실패");
- return Result.Failure<NiceTokenInfo>(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<NiceTokenInfo>(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<Result<NiceUrlInfo>> 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<NiceUrlInfo>(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<NiceUrlInfo>(tokenRes.Error);
- }
- token = tokenRes.Value;
- bodyResult = await SendUrlRequestAsync(requestNo, returnUrl, token.AccessToken, ct);
- }
- if (bodyResult.IsFailure)
- {
- return Result.Failure<NiceUrlInfo>(bodyResult.Error);
- }
- var body = bodyResult.Value;
- return Result.Success(new NiceUrlInfo(
- body.AuthUrl!,
- body.TransactionId!,
- requestNo,
- token.Ticket,
- token.Iterators));
- }
- private async Task<Result<NiceUrlResponse>> 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<NiceUrlResponse>(ct);
- }
- catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
- {
- logger.LogError(ex, "NICE url 요청 타임아웃");
- return Result.Failure<NiceUrlResponse>(NiceErrorMap.Map(null, "url"));
- }
- catch (HttpRequestException ex)
- {
- logger.LogError(ex, "NICE url 요청 실패");
- return Result.Failure<NiceUrlResponse>(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<NiceUrlResponse>(NiceErrorMap.Map(body?.ResultCode, "url"));
- }
- return Result.Success(body);
- }
- // ─────────────────────────────────────────────────────────────
- // 3) Result + 복호화
- // ─────────────────────────────────────────────────────────────
- public async Task<Result<NiceUserInfo>> 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<NiceUserInfo>(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<NiceUserInfo>(tokenRes.Error);
- }
- token = tokenRes.Value;
- bodyResult = await SendResultRequestAsync(requestNo, transactionId, webTransactionId, token.AccessToken, ct);
- }
- if (bodyResult.IsFailure)
- {
- return Result.Failure<NiceUserInfo>(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<NiceUserInfo>(Error.Problem(
- "Nice.KdfFailed",
- "본인인증 응답 처리 중 오류가 발생했습니다. 다시 시도해 주세요."));
- }
- if (!NiceCrypto.VerifyIntegrity(hmacKey, body.EncData!, body.IntegrityValue!))
- {
- logger.LogError("NICE integrity 검증 실패 tx={Tx}", transactionId);
- return Result.Failure<NiceUserInfo>(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<NiceUserInfo>(NiceErrorMap.Map("3025", "result"));
- }
- NiceResultDecryptedDto? dto;
- try
- {
- dto = JsonSerializer.Deserialize<NiceResultDecryptedDto>(plaintextJson);
- }
- catch (JsonException ex)
- {
- logger.LogError(ex, "NICE 복호화 JSON 파싱 실패 tx={Tx}", transactionId);
- return Result.Failure<NiceUserInfo>(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<NiceUserInfo>(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<Result<NiceResultResponse>> 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<NiceResultResponse>(ct);
- }
- catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
- {
- logger.LogError(ex, "NICE result 요청 타임아웃");
- return Result.Failure<NiceResultResponse>(NiceErrorMap.Map(null, "result"));
- }
- catch (HttpRequestException ex)
- {
- logger.LogError(ex, "NICE result 요청 실패");
- return Result.Failure<NiceResultResponse>(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<NiceResultResponse>(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);
- }
- /// <summary>
- /// 가맹점 측 고유번호. NICE 요구 길이: 20~50 byte.
- /// 형식: "DPOT" + yyyyMMddHHmmssfff (17 char) + 8 hex char = 29 char.
- /// </summary>
- private static string BuildRequestNo()
- {
- var ts = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff");
- var rand = Convert.ToHexString(RandomNumberGenerator.GetBytes(4));
- return $"DPOT{ts}{rand}";
- }
- }
|