보안 점검 및 대시보드 문구 수정
This commit is contained in:
@@ -33,14 +33,6 @@ export function normalizeTradingEnv(value?: string): KisTradingEnv {
|
||||
return (value ?? "mock").toLowerCase() === "real" ? "real" : "mock";
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 거래 환경을 반환합니다.
|
||||
* @returns real | mock
|
||||
*/
|
||||
export function getKisTradingEnv() {
|
||||
return normalizeTradingEnv(process.env.KIS_TRADING_ENV);
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 웹소켓 URL을 반환합니다.
|
||||
* @param tradingEnvInput 거래 모드(real/mock)
|
||||
@@ -50,10 +42,10 @@ export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) {
|
||||
const tradingEnv = normalizeTradingEnv(tradingEnvInput);
|
||||
|
||||
if (tradingEnv === "real") {
|
||||
return process.env.KIS_WS_URL_REAL ?? DEFAULT_KIS_REAL_WS_URL;
|
||||
return DEFAULT_KIS_REAL_WS_URL;
|
||||
}
|
||||
|
||||
return process.env.KIS_WS_URL_MOCK ?? DEFAULT_KIS_MOCK_WS_URL;
|
||||
return DEFAULT_KIS_MOCK_WS_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,14 +54,7 @@ export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) {
|
||||
* @returns 사용 가능 여부
|
||||
*/
|
||||
export function hasKisConfig(input?: KisCredentialInput) {
|
||||
if (input?.appKey && input?.appSecret) return true;
|
||||
|
||||
const env = getKisTradingEnv();
|
||||
if (env === "real") {
|
||||
return Boolean(process.env.KIS_APP_KEY_REAL && process.env.KIS_APP_SECRET_REAL);
|
||||
}
|
||||
|
||||
return Boolean(process.env.KIS_APP_KEY_MOCK && process.env.KIS_APP_SECRET_MOCK);
|
||||
return Boolean(input?.appKey?.trim() && input?.appSecret?.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +63,7 @@ export function hasKisConfig(input?: KisCredentialInput) {
|
||||
* @returns tradingEnv/appKey/appSecret/baseUrl
|
||||
*/
|
||||
export function getKisConfig(input?: KisCredentialInput): KisConfig {
|
||||
if (input?.appKey && input?.appSecret) {
|
||||
if (input?.appKey?.trim() && input?.appSecret?.trim()) {
|
||||
const tradingEnv = normalizeTradingEnv(input.tradingEnv);
|
||||
const baseUrl =
|
||||
input.baseUrl ??
|
||||
@@ -86,37 +71,13 @@ export function getKisConfig(input?: KisCredentialInput): KisConfig {
|
||||
|
||||
return {
|
||||
tradingEnv,
|
||||
appKey: input.appKey,
|
||||
appSecret: input.appSecret,
|
||||
appKey: input.appKey.trim(),
|
||||
appSecret: input.appSecret.trim(),
|
||||
baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const tradingEnv = getKisTradingEnv();
|
||||
|
||||
if (tradingEnv === "real") {
|
||||
const appKey = process.env.KIS_APP_KEY_REAL;
|
||||
const appSecret = process.env.KIS_APP_SECRET_REAL;
|
||||
const baseUrl = process.env.KIS_BASE_URL_REAL ?? DEFAULT_KIS_REAL_BASE_URL;
|
||||
|
||||
if (!appKey || !appSecret) {
|
||||
throw new Error(
|
||||
"KIS 실전투자 키가 없습니다. KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL 환경변수를 설정하세요.",
|
||||
);
|
||||
}
|
||||
|
||||
return { tradingEnv, appKey, appSecret, baseUrl };
|
||||
}
|
||||
|
||||
const appKey = process.env.KIS_APP_KEY_MOCK;
|
||||
const appSecret = process.env.KIS_APP_SECRET_MOCK;
|
||||
const baseUrl = process.env.KIS_BASE_URL_MOCK ?? DEFAULT_KIS_MOCK_BASE_URL;
|
||||
|
||||
if (!appKey || !appSecret) {
|
||||
throw new Error(
|
||||
"KIS 모의투자 키가 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 환경변수를 설정하세요.",
|
||||
);
|
||||
}
|
||||
|
||||
return { tradingEnv, appKey, appSecret, baseUrl };
|
||||
throw new Error(
|
||||
"KIS API 키가 없습니다. 설정 화면에서 앱 키와 앱 시크릿을 먼저 입력해 주세요.",
|
||||
);
|
||||
}
|
||||
|
||||
158
lib/kis/token.ts
158
lib/kis/token.ts
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import type { KisConfig, KisCredentialInput } from "@/lib/kis/config";
|
||||
import { getKisConfig } from "@/lib/kis/config";
|
||||
|
||||
/**
|
||||
@@ -11,8 +11,9 @@ import { getKisConfig } from "@/lib/kis/config";
|
||||
interface KisTokenResponse {
|
||||
access_token?: string;
|
||||
access_token_token_expired?: string;
|
||||
token_type?: string;
|
||||
access_token_expired?: string;
|
||||
expires_in?: number;
|
||||
expires_in?: number | string;
|
||||
msg1?: string;
|
||||
msg_cd?: string;
|
||||
error?: string;
|
||||
@@ -33,16 +34,39 @@ interface KisRevokeResponse {
|
||||
const tokenCacheMap = new Map<string, KisTokenCache>();
|
||||
const tokenIssueInFlightMap = new Map<string, Promise<KisTokenCache>>();
|
||||
const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
|
||||
const TOKEN_DEFAULT_TTL_MS = 23 * 60 * 60 * 1000;
|
||||
const KIS_TOKEN_ISSUE_PATH = "/oauth2/tokenP";
|
||||
const KIS_TOKEN_REVOKE_PATH = "/oauth2/revokeP";
|
||||
const KIS_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";
|
||||
const KIS_TOKEN_TYPE_BEARER = "bearer";
|
||||
|
||||
/**
|
||||
* @description 토큰 캐시 식별용 해시를 생성합니다.
|
||||
* @param value 해시 입력값
|
||||
* @returns sha256 hex 문자열
|
||||
* @see getTokenCacheKey 앱키 원문을 직접 저장하지 않기 위한 보조 함수
|
||||
*/
|
||||
function hashKey(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 거래환경+앱키 기준 토큰 캐시 키를 생성합니다.
|
||||
* @param credentials 사용자 입력 KIS 인증정보
|
||||
* @returns 캐시 키
|
||||
* @see getKisAccessToken 토큰 캐시 조회/갱신 키로 사용
|
||||
*/
|
||||
function getTokenCacheKey(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 토큰 응답 문자열을 안전하게 JSON 파싱합니다.
|
||||
* @param rawText 응답 원문
|
||||
* @returns 파싱된 토큰 응답 객체
|
||||
* @see issueKisToken 토큰 발급 응답 파싱 단계
|
||||
*/
|
||||
function tryParseTokenResponse(rawText: string): KisTokenResponse {
|
||||
try {
|
||||
return JSON.parse(rawText) as KisTokenResponse;
|
||||
@@ -53,6 +77,12 @@ function tryParseTokenResponse(rawText: string): KisTokenResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 토큰 폐기 응답 문자열을 안전하게 JSON 파싱합니다.
|
||||
* @param rawText 응답 원문
|
||||
* @returns 파싱된 토큰 폐기 응답 객체
|
||||
* @see revokeKisAccessToken 토큰 폐기 응답 파싱 단계
|
||||
*/
|
||||
function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
|
||||
try {
|
||||
return JSON.parse(rawText) as KisRevokeResponse;
|
||||
@@ -63,19 +93,62 @@ function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
|
||||
}
|
||||
}
|
||||
|
||||
function parseTokenExpiryText(value?: string) {
|
||||
if (!value) return null;
|
||||
/**
|
||||
* @description number 또는 숫자 문자열을 안전하게 숫자로 변환합니다.
|
||||
* @param value 원본 값
|
||||
* @returns 숫자값 또는 null
|
||||
* @see resolveTokenExpiry expires_in 파싱 단계
|
||||
*/
|
||||
function parseNumericSeconds(value?: number | string) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized = value.includes("T") ? value : value.replace(" ", "T");
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS 만료일시 문자열을 epoch(ms)로 변환합니다.
|
||||
* @param value 만료일시 문자열 (예: 2023-12-22 08:16:59)
|
||||
* @returns epoch(ms) 또는 null
|
||||
* @see resolveTokenExpiry access_token_token_expired 파싱 단계
|
||||
*/
|
||||
function parseTokenExpiryText(value?: string) {
|
||||
if (!value?.trim()) return null;
|
||||
const trimmed = value.trim();
|
||||
|
||||
// 명세 샘플("YYYY-MM-DD HH:mm:ss")은 한국시간(KST)으로 해석해 UTC 서버에서도 오차를 줄입니다.
|
||||
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}$/.test(trimmed)) {
|
||||
const parsedKst = Date.parse(`${trimmed.replace(" ", "T")}+09:00`);
|
||||
if (!Number.isNaN(parsedKst)) {
|
||||
return parsedKst;
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
|
||||
const parsed = Date.parse(normalized);
|
||||
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 토큰 만료시각을 계산합니다.
|
||||
* @param payload KIS 토큰 발급 응답
|
||||
* @returns 만료시각 epoch(ms)
|
||||
* @see issueKisToken 토큰 캐시 저장 만료시간 계산
|
||||
*/
|
||||
function resolveTokenExpiry(payload: KisTokenResponse) {
|
||||
if (typeof payload.expires_in === "number" && payload.expires_in > 0) {
|
||||
return Date.now() + payload.expires_in * 1000;
|
||||
const expiresInSeconds = parseNumericSeconds(payload.expires_in);
|
||||
if (expiresInSeconds && expiresInSeconds > 0) {
|
||||
return Date.now() + expiresInSeconds * 1000;
|
||||
}
|
||||
|
||||
const absoluteExpiry =
|
||||
@@ -87,7 +160,7 @@ function resolveTokenExpiry(payload: KisTokenResponse) {
|
||||
}
|
||||
|
||||
// 예외 상황 기본값: 23시간
|
||||
return Date.now() + 23 * 60 * 60 * 1000;
|
||||
return Date.now() + TOKEN_DEFAULT_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,24 +184,58 @@ function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") {
|
||||
return " | 점검: API 서비스 상태와 거래 환경(real/mock)을 확인해 주세요.";
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 토큰 발급/폐기 공통 헤더를 생성합니다.
|
||||
* @returns OAuth 요청 헤더
|
||||
* @see issueKisToken 접근토큰발급(P) 호출
|
||||
* @see revokeKisAccessToken 접근토큰폐기(P) 호출
|
||||
*/
|
||||
function buildOauthHeaders(): HeadersInit {
|
||||
return {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
accept: "application/json, text/plain, */*",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 접근토큰발급(P) 요청 바디를 생성합니다.
|
||||
* @param config KIS 설정
|
||||
* @returns tokenP 요청 바디
|
||||
* @see 접근토큰발급(P)[인증-001].xlsx grant_type/appkey/appsecret 명세
|
||||
*/
|
||||
function buildTokenIssueBody(config: KisConfig) {
|
||||
return {
|
||||
grant_type: KIS_GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
appkey: config.appKey,
|
||||
appsecret: config.appSecret,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 토큰 응답 실패 상세 메시지를 조합합니다.
|
||||
* @param payload KIS 토큰 응답
|
||||
* @returns 상세 메시지
|
||||
* @see issueKisToken 토큰 발급 실패 에러 메시지 구성
|
||||
*/
|
||||
function buildTokenIssueDetail(payload: KisTokenResponse) {
|
||||
return [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS 액세스 토큰을 발급합니다.
|
||||
* @see app/api/kis/validate/route.ts
|
||||
* @see C:/Users/이지훈/Downloads/접근토큰발급(P)[인증-001].xlsx 접근토큰발급(P) 명세
|
||||
*/
|
||||
async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> {
|
||||
const config = getKisConfig(credentials);
|
||||
const tokenUrl = `${config.baseUrl}/oauth2/tokenP`;
|
||||
const tokenUrl = `${config.baseUrl}${KIS_TOKEN_ISSUE_PATH}`;
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "client_credentials",
|
||||
appkey: config.appKey,
|
||||
appsecret: config.appSecret,
|
||||
}),
|
||||
headers: buildOauthHeaders(),
|
||||
body: JSON.stringify(buildTokenIssueBody(config)),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -136,9 +243,7 @@ async function issueKisToken(credentials?: KisCredentialInput): Promise<KisToken
|
||||
const payload = tryParseTokenResponse(rawText);
|
||||
|
||||
if (!response.ok || !payload.access_token) {
|
||||
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
const detail = buildTokenIssueDetail(payload);
|
||||
const hint = buildTokenIssueHint(detail, config.tradingEnv);
|
||||
|
||||
throw new Error(
|
||||
@@ -148,6 +253,13 @@ async function issueKisToken(credentials?: KisCredentialInput): Promise<KisToken
|
||||
);
|
||||
}
|
||||
|
||||
const tokenType = (payload.token_type ?? "").toLowerCase();
|
||||
if (tokenType && tokenType !== KIS_TOKEN_TYPE_BEARER) {
|
||||
throw new Error(
|
||||
`KIS 토큰 발급 응답 검증 실패 (${config.tradingEnv}): token_type=${payload.token_type}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
token: payload.access_token,
|
||||
expiresAt: resolveTokenExpiry(payload),
|
||||
@@ -192,11 +304,9 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
|
||||
const cacheKey = getTokenCacheKey(credentials);
|
||||
const token = await getKisAccessToken(credentials);
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/oauth2/revokeP`, {
|
||||
const response = await fetch(`${config.baseUrl}${KIS_TOKEN_REVOKE_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
headers: buildOauthHeaders(),
|
||||
body: JSON.stringify({
|
||||
appkey: config.appKey,
|
||||
appsecret: config.appSecret,
|
||||
|
||||
Reference in New Issue
Block a user