보안 강화: DB 자격증명(AppKey, Secret) 및 세션토큰(Access Token) 암호화 저장 구현 (AES-GCM/CBC), .env 정리
This commit is contained in:
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:
|
||||
from_attributes = True
|
||||
|
||||
from app.core.crypto import encrypt_str, decrypt_str
|
||||
|
||||
@router.get("/", response_model=SettingsSchema)
|
||||
async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||
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()
|
||||
if not settings:
|
||||
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)
|
||||
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)
|
||||
db.add(settings)
|
||||
|
||||
# Update fields if provided
|
||||
if payload.appKey is not None: settings.appKey = payload.appKey
|
||||
if payload.appSecret is not None: settings.appSecret = payload.appSecret
|
||||
if payload.accountNumber is not None: settings.accountNumber = payload.accountNumber
|
||||
# Update fields if provided - ENCRYPT SENSITIVE DATA
|
||||
if payload.appKey is not None:
|
||||
settings.appKey = encrypt_str(payload.appKey)
|
||||
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
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
# Trigger Token Refresh if Creds changed (Async Background task ideally)
|
||||
# await kis_auth.get_access_token(db)
|
||||
# Return masked object check
|
||||
# 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 settings
|
||||
return resp
|
||||
|
||||
|
||||
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: str = "Asia/Seoul"
|
||||
|
||||
# Encryption
|
||||
SECRET_KEY: str = "dlrpwjdakfehlsmswl_skf!wkf!ahfmrpTDJ!@#unsafe_default_key_change_in_production_min_32_bytes"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import unpad
|
||||
from base64 import b64decode
|
||||
from Crypto.Util.Padding import unpad, pad
|
||||
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:
|
||||
"""
|
||||
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)
|
||||
|
||||
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 app.db.database import SessionLocal
|
||||
from app.db.models import ApiSettings
|
||||
from app.db.models import ApiSettings
|
||||
from app.core.crypto import decrypt_str, encrypt_str
|
||||
|
||||
class KisAuth:
|
||||
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:
|
||||
raise ValueError("KIS API Credentials not configured.")
|
||||
|
||||
# 2. Check Expiry (Buffer 10 mins)
|
||||
# 2. Check Expiry (Buffer 10 mins)
|
||||
if settings_obj.accessToken and settings_obj.tokenExpiry:
|
||||
if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10):
|
||||
return settings_obj.accessToken
|
||||
token_dec = decrypt_str(settings_obj.accessToken)
|
||||
if token_dec and token_dec != "[Decryption Failed]":
|
||||
if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10):
|
||||
return token_dec
|
||||
|
||||
# 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
|
||||
settings_obj.accessToken = token_data['access_token']
|
||||
# 4. Save to DB (Encrypt Token)
|
||||
settings_obj.accessToken = encrypt_str(token_data['access_token'])
|
||||
# expires_in is seconds (usually 86400)
|
||||
settings_obj.tokenExpiry = datetime.now() + timedelta(seconds=int(token_data['expires_in']))
|
||||
|
||||
await db_session.commit()
|
||||
return settings_obj.accessToken
|
||||
return token_data['access_token']
|
||||
|
||||
except Exception as e:
|
||||
await db_session.rollback()
|
||||
@@ -83,12 +90,16 @@ class KisAuth:
|
||||
raise ValueError("KIS API Credentials not configured.")
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
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.models import ApiSettings
|
||||
from sqlalchemy import select
|
||||
from app.core.crypto import decrypt_str
|
||||
|
||||
class KisClient:
|
||||
"""
|
||||
@@ -51,8 +52,8 @@ class KisClient:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"authorization": f"Bearer {token}",
|
||||
"appkey": settings.appKey,
|
||||
"appsecret": settings.appSecret,
|
||||
"appkey": decrypt_str(settings.appKey),
|
||||
"appsecret": decrypt_str(settings.appSecret),
|
||||
"tr_id": tr_id,
|
||||
"tr_cont": "",
|
||||
"custtype": "P"
|
||||
@@ -106,7 +107,7 @@ class KisClient:
|
||||
# -----------------------------
|
||||
async def get_balance(self, market: str) -> Dict:
|
||||
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
|
||||
if '-' in acc_no:
|
||||
cano, prdt = acc_no.split('-')
|
||||
@@ -156,11 +157,13 @@ class KisClient:
|
||||
price: 0 for Market? KIS logic varies.
|
||||
"""
|
||||
settings = await self._get_settings()
|
||||
if '-' in settings.accountNumber:
|
||||
cano, prdt = settings.accountNumber.split('-')
|
||||
acc_no_str = decrypt_str(settings.accountNumber)
|
||||
|
||||
if '-' in acc_no_str:
|
||||
cano, prdt = acc_no_str.split('-')
|
||||
else:
|
||||
cano = settings.accountNumber[:8]
|
||||
prdt = settings.accountNumber[8:]
|
||||
cano = acc_no_str[:8]
|
||||
prdt = acc_no_str[8:]
|
||||
|
||||
if market == "Domestic":
|
||||
# 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.
Reference in New Issue
Block a user