feat(backend): encrypt exchange credentials, remove IS_DEMO_MODE
- 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
This commit is contained in:
@@ -129,7 +129,6 @@ def login():
|
||||
if not allowed:
|
||||
return jsonify({'code': 0, 'msg': block_msg, 'data': {'blocked': True}}), 429
|
||||
|
||||
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
|
||||
user = None
|
||||
|
||||
# Step 3: Authenticate
|
||||
@@ -206,9 +205,8 @@ def login():
|
||||
userinfo = {
|
||||
'id': user.get('id') or user.get('user_id', 1),
|
||||
'username': user.get('username', username),
|
||||
'nickname': user.get('nickname', 'User') + (' (Demo)' if is_demo else ''),
|
||||
'nickname': user.get('nickname', 'User'),
|
||||
'avatar': user.get('avatar', '/avatar2.jpg'),
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': user.get('role', 'admin'),
|
||||
'permissions': _get_permissions(user.get('role', 'admin'))
|
||||
@@ -409,8 +407,6 @@ def login_with_code():
|
||||
# Log login
|
||||
security.log_security_event('login_via_code', user['id'], ip_address, user_agent)
|
||||
|
||||
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Login successful' + (' (new account created)' if is_new_user else ''),
|
||||
@@ -420,10 +416,9 @@ def login_with_code():
|
||||
'userinfo': {
|
||||
'id': user['id'],
|
||||
'username': user['username'],
|
||||
'nickname': user.get('nickname', user['username']) + (' (Demo)' if is_demo else ''),
|
||||
'nickname': user.get('nickname', user['username']),
|
||||
'email': user.get('email'),
|
||||
'avatar': user.get('avatar', '/avatar2.jpg'),
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': user.get('role', 'user'),
|
||||
'permissions': _get_permissions(user.get('role', 'user'))
|
||||
@@ -679,8 +674,6 @@ def register():
|
||||
token_version=new_token_version
|
||||
)
|
||||
|
||||
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'Registration successful',
|
||||
@@ -692,7 +685,6 @@ def register():
|
||||
'nickname': username,
|
||||
'email': email,
|
||||
'avatar': '/avatar2.jpg',
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': 'user',
|
||||
'permissions': _get_permissions('user')
|
||||
@@ -1034,8 +1026,6 @@ def logout():
|
||||
def get_user_info():
|
||||
"""Get current user info."""
|
||||
try:
|
||||
is_demo = os.getenv('IS_DEMO_MODE', 'false').lower() == 'true'
|
||||
|
||||
user_id = getattr(g, 'user_id', 1)
|
||||
username = getattr(g, 'user', Config.ADMIN_USER)
|
||||
role = getattr(g, 'user_role', 'admin')
|
||||
@@ -1056,10 +1046,9 @@ def get_user_info():
|
||||
'data': {
|
||||
'id': user_data.get('id'),
|
||||
'username': user_data.get('username'),
|
||||
'nickname': user_data.get('nickname', 'User') + (' (Demo)' if is_demo else ''),
|
||||
'nickname': user_data.get('nickname', 'User'),
|
||||
'email': user_data.get('email'),
|
||||
'avatar': user_data.get('avatar', '/avatar2.jpg'),
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': user_data.get('role', 'user'),
|
||||
'permissions': _get_permissions(user_data.get('role', 'user'))
|
||||
@@ -1074,9 +1063,8 @@ def get_user_info():
|
||||
'data': {
|
||||
'id': user_id,
|
||||
'username': username,
|
||||
'nickname': 'Admin' + (' (Demo)' if is_demo else ''),
|
||||
'nickname': 'Admin',
|
||||
'avatar': '/avatar2.jpg',
|
||||
'is_demo': is_demo,
|
||||
'role': {
|
||||
'id': role,
|
||||
'permissions': _get_permissions(role)
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"""
|
||||
Exchange credentials vault (local-only).
|
||||
Exchange credentials vault.
|
||||
|
||||
Local deployment notes:
|
||||
- No encryption/decryption is used.
|
||||
- Credentials are stored as plaintext JSON in DB (encrypted_config column kept for compatibility).
|
||||
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__)
|
||||
|
||||
@@ -62,6 +63,42 @@ CRYPTO_EXCHANGES = [
|
||||
]
|
||||
|
||||
|
||||
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():
|
||||
@@ -121,6 +158,7 @@ def create_credential():
|
||||
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()
|
||||
@@ -130,7 +168,7 @@ def create_credential():
|
||||
VALUES (%s, %s, %s, %s, %s, NOW(), NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(user_id, name, exchange_id, hint, plaintext_config)
|
||||
(user_id, name, exchange_id, hint, stored_blob)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
new_id = (row or {}).get('id')
|
||||
@@ -198,13 +236,9 @@ def get_credential():
|
||||
if not row:
|
||||
return jsonify({'code': 0, 'msg': 'Not found', 'data': None}), 404
|
||||
|
||||
decrypted = {}
|
||||
raw = row.get('encrypted_config') or ''
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
decrypted = json.loads(raw)
|
||||
except Exception:
|
||||
decrypted = {}
|
||||
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')
|
||||
|
||||
|
||||
@@ -651,7 +651,7 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
|
||||
|
||||
# Get provider and model from env config (no frontend override)
|
||||
current_provider = llm.provider
|
||||
current_model = llm.get_default_model()
|
||||
current_model = llm.get_code_generation_model()
|
||||
current_api_key = llm.get_api_key()
|
||||
base_url = llm.get_base_url()
|
||||
|
||||
@@ -682,6 +682,7 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
model=current_model,
|
||||
temperature=temperature,
|
||||
use_json_mode=False # Code generation doesn't need JSON mode
|
||||
)
|
||||
@@ -697,13 +698,15 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
|
||||
|
||||
return content.strip() or _template_code()
|
||||
|
||||
# Capture user_id before generator runs (generator executes outside request context)
|
||||
user_id = g.user_id
|
||||
def stream():
|
||||
from app.services.billing_service import get_billing_service
|
||||
billing = get_billing_service()
|
||||
ok, msg = billing.check_and_consume(
|
||||
user_id=g.user_id,
|
||||
user_id=user_id,
|
||||
feature='ai_code_gen',
|
||||
reference_id=f"ai_code_gen_{g.user_id}_{int(time.time())}"
|
||||
reference_id=f"ai_code_gen_{user_id}_{int(time.time())}"
|
||||
)
|
||||
if not ok:
|
||||
yield "data: " + json.dumps({"error": f"积分不足: {msg}"}, ensure_ascii=False) + "\n\n"
|
||||
|
||||
@@ -576,6 +576,25 @@ def add_monitor():
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
# 创建后立即在后台跑一轮:立刻发通知,并以完成时刻为基准写入 next_run_at(间隔后再次执行)
|
||||
if is_active and monitor_id:
|
||||
try:
|
||||
from app.services.portfolio_monitor import run_single_monitor as _run_single_monitor
|
||||
|
||||
def _initial_run():
|
||||
try:
|
||||
_run_single_monitor(int(monitor_id), user_id=int(user_id))
|
||||
except Exception as ex:
|
||||
logger.error(f"Initial portfolio monitor run failed #{monitor_id}: {ex}")
|
||||
|
||||
threading.Thread(
|
||||
target=_initial_run,
|
||||
daemon=True,
|
||||
name=f"monitor-init-{monitor_id}",
|
||||
).start()
|
||||
except Exception as ex:
|
||||
logger.error(f"Failed to schedule initial monitor run #{monitor_id}: {ex}")
|
||||
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': monitor_id}})
|
||||
except Exception as e:
|
||||
logger.error(f"add_monitor failed: {str(e)}")
|
||||
|
||||
@@ -25,6 +25,7 @@ from flask import Blueprint, g, jsonify, request
|
||||
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 decrypt_credential_blob
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -149,7 +150,12 @@ def _load_credential(credential_id: int, user_id: int) -> Dict[str, Any]:
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
cur.close()
|
||||
return _safe_json(row.get("encrypted_config"), {})
|
||||
try:
|
||||
plain = decrypt_credential_blob(row.get("encrypted_config"))
|
||||
except ValueError as e:
|
||||
logger.warning(f"decrypt credential_id={credential_id}: {e}")
|
||||
return {}
|
||||
return _safe_json(plain, {})
|
||||
|
||||
|
||||
def _build_exchange_config(credential_id: int, user_id: int, overrides: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
|
||||
@@ -134,6 +134,14 @@ CONFIG_SCHEMA = {
|
||||
],
|
||||
'description': 'Select your preferred LLM provider'
|
||||
},
|
||||
{
|
||||
'key': 'AI_CODE_GEN_MODEL',
|
||||
'label': 'Code Generation Model',
|
||||
'type': 'text',
|
||||
'default': '',
|
||||
'required': False,
|
||||
'description': 'Optional model override for AI code generation. If empty, uses provider default model'
|
||||
},
|
||||
# OpenRouter
|
||||
{
|
||||
'key': 'OPENROUTER_API_KEY',
|
||||
|
||||
@@ -650,6 +650,7 @@ def update_notification_settings():
|
||||
'email': str(data.get('email') or '').strip(),
|
||||
'discord_webhook': str(data.get('discord_webhook') or '').strip(),
|
||||
'webhook_url': str(data.get('webhook_url') or '').strip(),
|
||||
'webhook_token': str(data.get('webhook_token') or '').strip(),
|
||||
'phone': str(data.get('phone') or '').strip(),
|
||||
}
|
||||
|
||||
@@ -677,6 +678,85 @@ def update_notification_settings():
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/notification-settings/test', methods=['POST'])
|
||||
@login_required
|
||||
def test_notification_settings():
|
||||
"""
|
||||
Send a test notification using the current user's saved notification_settings
|
||||
(save settings first via PUT /notification-settings).
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
from app.services.signal_notifier import SignalNotifier
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT notification_settings, email FROM qd_users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
|
||||
|
||||
settings_str = row.get('notification_settings') or ''
|
||||
account_email = (row.get('email') or '').strip()
|
||||
settings = {}
|
||||
if settings_str:
|
||||
try:
|
||||
settings = json.loads(settings_str)
|
||||
except Exception:
|
||||
settings = {}
|
||||
|
||||
channels = settings.get('default_channels') or ['browser']
|
||||
if not isinstance(channels, list) or not channels:
|
||||
channels = ['browser']
|
||||
|
||||
notify_email = (settings.get('email') or '').strip() or account_email
|
||||
targets = {
|
||||
'telegram': (settings.get('telegram_chat_id') or '').strip(),
|
||||
'telegram_bot_token': (settings.get('telegram_bot_token') or '').strip(),
|
||||
'email': notify_email,
|
||||
'phone': (settings.get('phone') or '').strip(),
|
||||
'discord': (settings.get('discord_webhook') or '').strip(),
|
||||
'webhook': (settings.get('webhook_url') or '').strip(),
|
||||
'webhook_token': (settings.get('webhook_token') or '').strip(),
|
||||
}
|
||||
|
||||
accept = (request.headers.get('Accept-Language') or '') + ' ' + (request.headers.get('X-Locale') or '')
|
||||
language = 'zh-CN' if 'zh' in accept.lower() else 'en-US'
|
||||
|
||||
notifier = SignalNotifier()
|
||||
results = notifier.send_profile_test_notifications(
|
||||
user_id=int(user_id),
|
||||
channels=channels,
|
||||
targets=targets,
|
||||
language=language,
|
||||
)
|
||||
|
||||
any_ok = any((v or {}).get('ok') for v in results.values())
|
||||
failed = [k for k, v in results.items() if not (v or {}).get('ok')]
|
||||
if failed:
|
||||
err_detail = {k: (results.get(k) or {}).get('error', '') for k in failed}
|
||||
logger.warning("notification_settings test: user_id=%s failed_channels=%s errors=%s", user_id, failed, err_detail)
|
||||
|
||||
if not any_ok:
|
||||
detail = '; '.join(f"{k}: {(results[k] or {}).get('error', '')}" for k in failed) or 'all channels failed'
|
||||
return jsonify({'code': 0, 'msg': detail, 'data': {'results': results}})
|
||||
|
||||
msg = 'Test notification sent'
|
||||
if failed:
|
||||
msg = f"Sent OK; failed: {', '.join(failed)}"
|
||||
return jsonify({'code': 1, 'msg': msg, 'data': {'results': results}})
|
||||
except Exception as e:
|
||||
logger.error(f"test_notification_settings failed: {e}")
|
||||
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
|
||||
|
||||
|
||||
@user_bp.route('/change-password', methods=['POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
|
||||
Reference in New Issue
Block a user