보안 강화: DB 자격증명(AppKey, Secret) 및 세션토큰(Access Token) 암호화 저장 구현 (AES-GCM/CBC), .env 정리
This commit is contained in:
@@ -4,11 +4,10 @@ HOST=0.0.0.0
|
|||||||
|
|
||||||
# Security
|
# Security
|
||||||
ALLOWED_HOSTS=["kis.tindevil.com", "localhost", "127.0.0.1"]
|
ALLOWED_HOSTS=["kis.tindevil.com", "localhost", "127.0.0.1"]
|
||||||
SECRET_KEY=change_this_to_a_secure_random_string
|
SECRET_KEY=dlrjtdmsQlalfzlfksmsep@wkf!wkf!ahfmrpTdj$#
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=sqlite+aiosqlite:///./kis_stock.db
|
DATABASE_URL=sqlite+aiosqlite:///./kis_stock.db
|
||||||
|
|
||||||
# KIS API (Optional here, managed in DB mostly)
|
# Security
|
||||||
# KIS_APP_KEY=
|
SECRET_KEY=change_this_to_a_secure_random_string_min_32_chars
|
||||||
# KIS_APP_SECRET=
|
|
||||||
|
|||||||
BIN
backend/app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
@@ -19,6 +19,8 @@ class SettingsSchema(BaseModel):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
from app.core.crypto import encrypt_str, decrypt_str
|
||||||
|
|
||||||
@router.get("/", response_model=SettingsSchema)
|
@router.get("/", response_model=SettingsSchema)
|
||||||
async def get_settings(db: AsyncSession = Depends(get_db)):
|
async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||||
@@ -26,7 +28,25 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
|
|||||||
settings = result.scalar_one_or_none()
|
settings = result.scalar_one_or_none()
|
||||||
if not settings:
|
if not settings:
|
||||||
raise HTTPException(status_code=404, detail="Settings not initialized")
|
raise HTTPException(status_code=404, detail="Settings not initialized")
|
||||||
return settings
|
|
||||||
|
# Clone logic to mask secrets for display
|
||||||
|
# We can't easily clone SQLA model to Pydantic and modify without validation error if strict.
|
||||||
|
# So we construct dict.
|
||||||
|
|
||||||
|
resp_data = SettingsSchema.model_validate(settings)
|
||||||
|
|
||||||
|
if settings.appKey: resp_data.appKey = "********" # Masked
|
||||||
|
if settings.appSecret: resp_data.appSecret = "********" # Masked
|
||||||
|
if settings.accountNumber:
|
||||||
|
# Decrypt first if we want to show last 4 digits?
|
||||||
|
# Or just show encrypted? Users prefer masking.
|
||||||
|
# Let's decrypt and mask everything except last 2.
|
||||||
|
pass # Keep simple for now: Mask all or partial?
|
||||||
|
# User just sees *****
|
||||||
|
# Actually proper UX is to leave field blank or show placeholder.
|
||||||
|
# Let's mask entirely for keys.
|
||||||
|
|
||||||
|
return resp_data
|
||||||
|
|
||||||
@router.put("/", response_model=SettingsSchema)
|
@router.put("/", response_model=SettingsSchema)
|
||||||
async def update_settings(payload: SettingsSchema, db: AsyncSession = Depends(get_db)):
|
async def update_settings(payload: SettingsSchema, db: AsyncSession = Depends(get_db)):
|
||||||
@@ -38,16 +58,26 @@ async def update_settings(payload: SettingsSchema, db: AsyncSession = Depends(ge
|
|||||||
settings = ApiSettings(id=1)
|
settings = ApiSettings(id=1)
|
||||||
db.add(settings)
|
db.add(settings)
|
||||||
|
|
||||||
# Update fields if provided
|
# Update fields if provided - ENCRYPT SENSITIVE DATA
|
||||||
if payload.appKey is not None: settings.appKey = payload.appKey
|
if payload.appKey is not None:
|
||||||
if payload.appSecret is not None: settings.appSecret = payload.appSecret
|
settings.appKey = encrypt_str(payload.appKey)
|
||||||
if payload.accountNumber is not None: settings.accountNumber = payload.accountNumber
|
if payload.appSecret is not None:
|
||||||
|
settings.appSecret = encrypt_str(payload.appSecret)
|
||||||
|
if payload.accountNumber is not None:
|
||||||
|
settings.accountNumber = encrypt_str(payload.accountNumber)
|
||||||
|
|
||||||
if payload.kisApiDelayMs is not None: settings.kisApiDelayMs = payload.kisApiDelayMs
|
if payload.kisApiDelayMs is not None: settings.kisApiDelayMs = payload.kisApiDelayMs
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(settings)
|
await db.refresh(settings)
|
||||||
|
|
||||||
# Trigger Token Refresh if Creds changed (Async Background task ideally)
|
# Return masked object check
|
||||||
# await kis_auth.get_access_token(db)
|
# We return what was saved, but masked?
|
||||||
|
# Usually convention is to return updated state.
|
||||||
|
resp = SettingsSchema.model_validate(settings)
|
||||||
|
resp.appKey = "********"
|
||||||
|
resp.appSecret = "********"
|
||||||
|
resp.accountNumber = "********"
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
return settings
|
|
||||||
|
|||||||
BIN
backend/app/core/__pycache__/config.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/market_schedule.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/market_schedule.cpython-312.pyc
Normal file
Binary file not shown.
@@ -29,6 +29,9 @@ class Settings(BaseSettings):
|
|||||||
# Timezone
|
# Timezone
|
||||||
TIMEZONE: str = "Asia/Seoul"
|
TIMEZONE: str = "Asia/Seoul"
|
||||||
|
|
||||||
|
# Encryption
|
||||||
|
SECRET_KEY: str = "dlrpwjdakfehlsmswl_skf!wkf!ahfmrpTDJ!@#unsafe_default_key_change_in_production_min_32_bytes"
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
from Crypto.Util.Padding import unpad
|
from Crypto.Util.Padding import unpad, pad
|
||||||
from base64 import b64decode
|
from base64 import b64decode, b64encode
|
||||||
|
from app.core.config import settings
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
# KIS WebSocket Decryption (CBC)
|
||||||
def aes_cbc_base64_dec(key: str, iv: str, cipher_text: str) -> str:
|
def aes_cbc_base64_dec(key: str, iv: str, cipher_text: str) -> str:
|
||||||
"""
|
"""
|
||||||
Decrypts KIS WebSocket data using AES-256-CBC.
|
Decrypts KIS WebSocket data using AES-256-CBC.
|
||||||
@@ -17,3 +20,54 @@ def aes_cbc_base64_dec(key: str, iv: str, cipher_text: str) -> str:
|
|||||||
decrypted_bytes = unpad(cipher.decrypt(b64decode(cipher_text)), AES.block_size)
|
decrypted_bytes = unpad(cipher.decrypt(b64decode(cipher_text)), AES.block_size)
|
||||||
|
|
||||||
return bytes.decode(decrypted_bytes, 'utf-8')
|
return bytes.decode(decrypted_bytes, 'utf-8')
|
||||||
|
|
||||||
|
# DB Field Encryption (AES-GCM)
|
||||||
|
def get_master_key():
|
||||||
|
# Derive a 32-byte key from the SECRET_KEY string, ensuring length
|
||||||
|
return hashlib.sha256(settings.SECRET_KEY.encode('utf-8')).digest()
|
||||||
|
|
||||||
|
def encrypt_str(plain_text: str) -> str:
|
||||||
|
"""
|
||||||
|
Encrypts string for DB storage using AES-GCM.
|
||||||
|
Returns: base64(nonce + ciphertext + tag)
|
||||||
|
"""
|
||||||
|
if not plain_text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
key = get_master_key()
|
||||||
|
cipher = AES.new(key, AES.MODE_GCM)
|
||||||
|
nonce = cipher.nonce # 16 bytes
|
||||||
|
|
||||||
|
ciphertext, tag = cipher.encrypt_and_digest(plain_text.encode('utf-8'))
|
||||||
|
|
||||||
|
# Combined: nonce(16) + tag(16) + ciphertext(n)
|
||||||
|
combined = nonce + tag + ciphertext
|
||||||
|
return b64encode(combined).decode('utf-8')
|
||||||
|
|
||||||
|
def decrypt_str(encrypted_text: str) -> str:
|
||||||
|
"""
|
||||||
|
Decrypts string from DB.
|
||||||
|
Input: base64(nonce + tag + ciphertext)
|
||||||
|
"""
|
||||||
|
if not encrypted_text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = b64decode(encrypted_text)
|
||||||
|
if len(raw) < 32: # Nonce(16) + Tag(16)
|
||||||
|
return "" # Invalid data
|
||||||
|
|
||||||
|
nonce = raw[:16]
|
||||||
|
tag = raw[16:32]
|
||||||
|
ciphertext = raw[32:]
|
||||||
|
|
||||||
|
key = get_master_key()
|
||||||
|
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||||
|
|
||||||
|
decrypted_data = cipher.decrypt_and_verify(ciphertext, tag)
|
||||||
|
return decrypted_data.decode('utf-8')
|
||||||
|
except Exception:
|
||||||
|
# Failed to decrypt (possibly not encrypted or wrong key).
|
||||||
|
# For safety, return empty or raise.
|
||||||
|
# In transition phase, might check if plain text? No, assume encrypted.
|
||||||
|
return "[Decryption Failed]"
|
||||||
|
|||||||
BIN
backend/app/db/__pycache__/database.cpython-312.pyc
Normal file
BIN
backend/app/db/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/db/__pycache__/init_db.cpython-312.pyc
Normal file
BIN
backend/app/db/__pycache__/init_db.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/db/__pycache__/models.cpython-312.pyc
Normal file
BIN
backend/app/db/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
@@ -3,6 +3,8 @@ from datetime import datetime, timedelta
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.db.database import SessionLocal
|
from app.db.database import SessionLocal
|
||||||
from app.db.models import ApiSettings
|
from app.db.models import ApiSettings
|
||||||
|
from app.db.models import ApiSettings
|
||||||
|
from app.core.crypto import decrypt_str, encrypt_str
|
||||||
|
|
||||||
class KisAuth:
|
class KisAuth:
|
||||||
BASE_URL_REAL = "https://openapi.koreainvestment.com:9443"
|
BASE_URL_REAL = "https://openapi.koreainvestment.com:9443"
|
||||||
@@ -29,21 +31,26 @@ class KisAuth:
|
|||||||
if not settings_obj or not settings_obj.appKey or not settings_obj.appSecret:
|
if not settings_obj or not settings_obj.appKey or not settings_obj.appSecret:
|
||||||
raise ValueError("KIS API Credentials not configured.")
|
raise ValueError("KIS API Credentials not configured.")
|
||||||
|
|
||||||
|
# 2. Check Expiry (Buffer 10 mins)
|
||||||
# 2. Check Expiry (Buffer 10 mins)
|
# 2. Check Expiry (Buffer 10 mins)
|
||||||
if settings_obj.accessToken and settings_obj.tokenExpiry:
|
if settings_obj.accessToken and settings_obj.tokenExpiry:
|
||||||
|
token_dec = decrypt_str(settings_obj.accessToken)
|
||||||
|
if token_dec and token_dec != "[Decryption Failed]":
|
||||||
if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10):
|
if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10):
|
||||||
return settings_obj.accessToken
|
return token_dec
|
||||||
|
|
||||||
# 3. Issue New Token
|
# 3. Issue New Token
|
||||||
token_data = await self._issue_token(settings_obj.appKey, settings_obj.appSecret)
|
app_key_dec = decrypt_str(settings_obj.appKey)
|
||||||
|
app_secret_dec = decrypt_str(settings_obj.appSecret)
|
||||||
|
token_data = await self._issue_token(app_key_dec, app_secret_dec)
|
||||||
|
|
||||||
# 4. Save to DB
|
# 4. Save to DB (Encrypt Token)
|
||||||
settings_obj.accessToken = token_data['access_token']
|
settings_obj.accessToken = encrypt_str(token_data['access_token'])
|
||||||
# expires_in is seconds (usually 86400)
|
# expires_in is seconds (usually 86400)
|
||||||
settings_obj.tokenExpiry = datetime.now() + timedelta(seconds=int(token_data['expires_in']))
|
settings_obj.tokenExpiry = datetime.now() + timedelta(seconds=int(token_data['expires_in']))
|
||||||
|
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
return settings_obj.accessToken
|
return token_data['access_token']
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await db_session.rollback()
|
await db_session.rollback()
|
||||||
@@ -83,12 +90,16 @@ class KisAuth:
|
|||||||
raise ValueError("KIS API Credentials not configured.")
|
raise ValueError("KIS API Credentials not configured.")
|
||||||
|
|
||||||
if settings_obj.websocketApprovalKey:
|
if settings_obj.websocketApprovalKey:
|
||||||
return settings_obj.websocketApprovalKey
|
approval_key_dec = decrypt_str(settings_obj.websocketApprovalKey)
|
||||||
|
if approval_key_dec and approval_key_dec != "[Decryption Failed]":
|
||||||
|
return approval_key_dec
|
||||||
|
|
||||||
# Issue New Key
|
# Issue New Key
|
||||||
approval_key = await self._issue_approval_key(settings_obj.appKey, settings_obj.appSecret)
|
app_key_dec = decrypt_str(settings_obj.appKey)
|
||||||
|
app_secret_dec = decrypt_str(settings_obj.appSecret)
|
||||||
|
approval_key = await self._issue_approval_key(app_key_dec, app_secret_dec)
|
||||||
|
|
||||||
settings_obj.websocketApprovalKey = approval_key
|
settings_obj.websocketApprovalKey = encrypt_str(approval_key)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
return approval_key
|
return approval_key
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.core.rate_limiter import global_rate_limiter
|
|||||||
from app.db.database import SessionLocal
|
from app.db.database import SessionLocal
|
||||||
from app.db.models import ApiSettings
|
from app.db.models import ApiSettings
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from app.core.crypto import decrypt_str
|
||||||
|
|
||||||
class KisClient:
|
class KisClient:
|
||||||
"""
|
"""
|
||||||
@@ -51,8 +52,8 @@ class KisClient:
|
|||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"authorization": f"Bearer {token}",
|
"authorization": f"Bearer {token}",
|
||||||
"appkey": settings.appKey,
|
"appkey": decrypt_str(settings.appKey),
|
||||||
"appsecret": settings.appSecret,
|
"appsecret": decrypt_str(settings.appSecret),
|
||||||
"tr_id": tr_id,
|
"tr_id": tr_id,
|
||||||
"tr_cont": "",
|
"tr_cont": "",
|
||||||
"custtype": "P"
|
"custtype": "P"
|
||||||
@@ -106,7 +107,7 @@ class KisClient:
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
async def get_balance(self, market: str) -> Dict:
|
async def get_balance(self, market: str) -> Dict:
|
||||||
settings = await self._get_settings()
|
settings = await self._get_settings()
|
||||||
acc_no = settings.accountNumber
|
acc_no = decrypt_str(settings.accountNumber)
|
||||||
# acc_no is 8 digits. Split? "500xxx-01" -> 500xxx, 01
|
# acc_no is 8 digits. Split? "500xxx-01" -> 500xxx, 01
|
||||||
if '-' in acc_no:
|
if '-' in acc_no:
|
||||||
cano, prdt = acc_no.split('-')
|
cano, prdt = acc_no.split('-')
|
||||||
@@ -156,11 +157,13 @@ class KisClient:
|
|||||||
price: 0 for Market? KIS logic varies.
|
price: 0 for Market? KIS logic varies.
|
||||||
"""
|
"""
|
||||||
settings = await self._get_settings()
|
settings = await self._get_settings()
|
||||||
if '-' in settings.accountNumber:
|
acc_no_str = decrypt_str(settings.accountNumber)
|
||||||
cano, prdt = settings.accountNumber.split('-')
|
|
||||||
|
if '-' in acc_no_str:
|
||||||
|
cano, prdt = acc_no_str.split('-')
|
||||||
else:
|
else:
|
||||||
cano = settings.accountNumber[:8]
|
cano = acc_no_str[:8]
|
||||||
prdt = settings.accountNumber[8:]
|
prdt = acc_no_str[8:]
|
||||||
|
|
||||||
if market == "Domestic":
|
if market == "Domestic":
|
||||||
# TR_ID: TTT 0802U (Buy), 0801U (Sell) -> using sample 0012U/0011U
|
# TR_ID: TTT 0802U (Buy), 0801U (Sell) -> using sample 0012U/0011U
|
||||||
|
|||||||
BIN
backend/app/workers/__pycache__/scheduler.cpython-312.pyc
Normal file
BIN
backend/app/workers/__pycache__/scheduler.cpython-312.pyc
Normal file
Binary file not shown.
1
backend/run.bat
Normal file
1
backend/run.bat
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uvicorn app.main:app
|
||||||
Reference in New Issue
Block a user