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}";
}
}