7626328c9c
- Fernet encrypt qd_exchange_credentials via SECRET_KEY (cryptography) - Remove global read-only demo middleware; drop is_demo from auth payloads - Egress whitelist: /api/credentials/egress-ip returns ipv4 + ipv6 (ipify) - Exchange factory: demo/testnet URLs and OKX simulated-trading header - Bitget spot connection test; misc route/service fixes Made-with: Cursor
262 lines
8.8 KiB
Python
262 lines
8.8 KiB
Python
"""
|
|
Exchange credentials vault.
|
|
|
|
encrypted_config stores Fernet ciphertext derived from SECRET_KEY (see app.utils.credential_crypto).
|
|
"""
|
|
|
|
import traceback
|
|
import json
|
|
from flask import Blueprint, request, jsonify, g
|
|
|
|
import requests as rq
|
|
|
|
from app.utils.db import get_db_connection
|
|
from app.utils.logger import get_logger
|
|
from app.utils.auth import login_required
|
|
from app.utils.credential_crypto import encrypt_credential_blob, decrypt_credential_blob
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
credentials_bp = Blueprint('credentials', __name__)
|
|
|
|
|
|
def _api_key_hint(api_key: str) -> str:
|
|
if not api_key:
|
|
return ''
|
|
s = str(api_key)
|
|
if len(s) <= 8:
|
|
return s[:2] + '***'
|
|
return f"{s[:4]}...{s[-4:]}"
|
|
|
|
|
|
@credentials_bp.route('/list', methods=['GET'])
|
|
@login_required
|
|
def list_credentials():
|
|
"""List all credentials for the current user."""
|
|
try:
|
|
user_id = g.user_id
|
|
|
|
with get_db_connection() as db:
|
|
cur = db.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT id, user_id, name, exchange_id, api_key_hint, created_at, updated_at
|
|
FROM qd_exchange_credentials
|
|
WHERE user_id = %s
|
|
ORDER BY id DESC
|
|
""",
|
|
(user_id,)
|
|
)
|
|
rows = cur.fetchall() or []
|
|
cur.close()
|
|
|
|
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': rows}})
|
|
except Exception as e:
|
|
logger.error(f"list_credentials failed: {str(e)}")
|
|
logger.error(traceback.format_exc())
|
|
return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500
|
|
|
|
|
|
CRYPTO_EXCHANGES = [
|
|
'binance', 'okx', 'bitget', 'bybit', 'coinbaseexchange',
|
|
'kraken', 'kucoin', 'gate', 'bitfinex', 'deepcoin'
|
|
]
|
|
|
|
|
|
def _egress_ipify(url: str) -> str:
|
|
try:
|
|
r = rq.get(url, timeout=8)
|
|
if r.status_code != 200:
|
|
return ""
|
|
j = r.json()
|
|
if not isinstance(j, dict):
|
|
return ""
|
|
return str(j.get("ip") or "").strip()
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
@credentials_bp.route('/egress-ip', methods=['GET'])
|
|
@login_required
|
|
def get_egress_ip():
|
|
"""
|
|
Public egress IPv4/IPv6 of this API server (for exchange API key IP whitelist).
|
|
Uses ipify's v4-only / v6-only endpoints so each family is detected independently.
|
|
"""
|
|
ipv4 = _egress_ipify("https://api4.ipify.org?format=json")
|
|
ipv6 = _egress_ipify("https://api6.ipify.org?format=json")
|
|
return jsonify(
|
|
{
|
|
"code": 1,
|
|
"msg": "success",
|
|
"data": {
|
|
"ipv4": ipv4 or None,
|
|
"ipv6": ipv6 or None,
|
|
# 兼容旧前端:优先 IPv4,否则 IPv6
|
|
"ip": ipv4 or ipv6 or None,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@credentials_bp.route('/create', methods=['POST'])
|
|
@login_required
|
|
def create_credential():
|
|
"""Create a new credential for the current user.
|
|
|
|
Supports crypto exchanges, IBKR (US stocks) and MT5 (Forex).
|
|
"""
|
|
try:
|
|
user_id = g.user_id
|
|
data = request.get_json() or {}
|
|
name = (data.get('name') or '').strip()
|
|
exchange_id = (data.get('exchange_id') or '').strip().lower()
|
|
|
|
if not exchange_id:
|
|
return jsonify({'code': 0, 'msg': 'Missing exchange_id', 'data': None}), 400
|
|
|
|
config = {'exchange_id': exchange_id}
|
|
hint = ''
|
|
|
|
if exchange_id == 'ibkr':
|
|
# Interactive Brokers (US stocks)
|
|
config.update({
|
|
'ibkr_host': (data.get('ibkr_host') or '127.0.0.1').strip(),
|
|
'ibkr_port': int(data.get('ibkr_port') or 7497),
|
|
'ibkr_client_id': int(data.get('ibkr_client_id') or 1),
|
|
'ibkr_account': (data.get('ibkr_account') or '').strip()
|
|
})
|
|
hint = f"{config['ibkr_host']}:{config['ibkr_port']}"
|
|
elif exchange_id == 'mt5':
|
|
# MetaTrader 5 (Forex)
|
|
mt5_server = (data.get('mt5_server') or '').strip()
|
|
mt5_login = str(data.get('mt5_login') or '').strip()
|
|
mt5_password = (data.get('mt5_password') or '').strip()
|
|
if not mt5_server or not mt5_login or not mt5_password:
|
|
return jsonify({'code': 0, 'msg': 'Missing mt5_server/mt5_login/mt5_password', 'data': None}), 400
|
|
config.update({
|
|
'mt5_server': mt5_server,
|
|
'mt5_login': mt5_login,
|
|
'mt5_password': mt5_password,
|
|
'mt5_terminal_path': (data.get('mt5_terminal_path') or '').strip()
|
|
})
|
|
hint = f"{mt5_server}/{mt5_login}"
|
|
elif exchange_id in CRYPTO_EXCHANGES:
|
|
# Crypto exchanges
|
|
api_key = (data.get('api_key') or '').strip()
|
|
secret_key = (data.get('secret_key') or '').strip()
|
|
if not api_key or not secret_key:
|
|
return jsonify({'code': 0, 'msg': 'Missing api_key/secret_key', 'data': None}), 400
|
|
config.update({
|
|
'api_key': api_key,
|
|
'secret_key': secret_key,
|
|
'passphrase': (data.get('passphrase') or '').strip(),
|
|
'enable_demo_trading': bool(data.get('enable_demo_trading', False))
|
|
})
|
|
hint = _api_key_hint(api_key)
|
|
else:
|
|
return jsonify({'code': 0, 'msg': f'Unsupported exchange: {exchange_id}', 'data': None}), 400
|
|
|
|
plaintext_config = json.dumps(config, ensure_ascii=False)
|
|
stored_blob = encrypt_credential_blob(plaintext_config)
|
|
|
|
with get_db_connection() as db:
|
|
cur = db.cursor()
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO qd_exchange_credentials (user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at)
|
|
VALUES (%s, %s, %s, %s, %s, NOW(), NOW())
|
|
RETURNING id
|
|
""",
|
|
(user_id, name, exchange_id, hint, stored_blob)
|
|
)
|
|
row = cur.fetchone()
|
|
new_id = (row or {}).get('id')
|
|
db.commit()
|
|
cur.close()
|
|
|
|
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}})
|
|
except Exception as e:
|
|
logger.error(f"create_credential failed: {str(e)}")
|
|
logger.error(traceback.format_exc())
|
|
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
|
|
|
|
|
@credentials_bp.route('/delete', methods=['DELETE'])
|
|
@login_required
|
|
def delete_credential():
|
|
"""Delete a credential for the current user."""
|
|
try:
|
|
user_id = g.user_id
|
|
cred_id = request.args.get('id', type=int)
|
|
if not cred_id:
|
|
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
|
|
|
|
with get_db_connection() as db:
|
|
cur = db.cursor()
|
|
cur.execute(
|
|
"DELETE FROM qd_exchange_credentials WHERE id = %s AND user_id = %s",
|
|
(cred_id, user_id)
|
|
)
|
|
db.commit()
|
|
cur.close()
|
|
|
|
return jsonify({'code': 1, 'msg': 'success', 'data': None})
|
|
except Exception as e:
|
|
logger.error(f"delete_credential failed: {str(e)}")
|
|
logger.error(traceback.format_exc())
|
|
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
|
|
|
|
|
@credentials_bp.route('/get', methods=['GET'])
|
|
@login_required
|
|
def get_credential():
|
|
"""
|
|
Return decrypted credential for form auto-fill.
|
|
"""
|
|
try:
|
|
user_id = g.user_id
|
|
cred_id = request.args.get('id', type=int)
|
|
if not cred_id:
|
|
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
|
|
|
|
with get_db_connection() as db:
|
|
cur = db.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT id, user_id, name, exchange_id, encrypted_config, api_key_hint, created_at, updated_at
|
|
FROM qd_exchange_credentials
|
|
WHERE id = %s AND user_id = %s
|
|
""",
|
|
(cred_id, user_id)
|
|
)
|
|
row = cur.fetchone()
|
|
cur.close()
|
|
|
|
if not row:
|
|
return jsonify({'code': 0, 'msg': 'Not found', 'data': None}), 404
|
|
|
|
raw = row.get('encrypted_config')
|
|
plain = decrypt_credential_blob(raw)
|
|
decrypted = json.loads(plain) if plain else {}
|
|
# Ensure exchange_id is present
|
|
decrypted['exchange_id'] = row.get('exchange_id') or decrypted.get('exchange_id')
|
|
|
|
return jsonify({
|
|
'code': 1,
|
|
'msg': 'success',
|
|
'data': {
|
|
'id': row.get('id'),
|
|
'name': row.get('name'),
|
|
'exchange_id': row.get('exchange_id'),
|
|
'api_key_hint': row.get('api_key_hint'),
|
|
'config': decrypted
|
|
}
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"get_credential failed: {str(e)}")
|
|
logger.error(traceback.format_exc())
|
|
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
|
|
|
|