feat: improve authentication and user management
- Fix token missing issue after email code registration/login - Add last login time update for code-based login - Support email login in addition to username login - Fix password login for code-registered users (allow setting password) - Fix referral code parameter passing from URL hash - Add email column to user management table - Improve profile page layout (align card heights, reorganize layout) - Add i18n support for Register Bonus, Referral Bonus, Code Lock Minutes, Code Max Attempts - Fix billing service add_credits to support reference_id parameter - Update password handling documentation
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
Billing Service - 统一计费服务
|
||||
|
||||
管理用户积分消费、VIP状态检查、计费配置等功能。
|
||||
支持两种计费模式:
|
||||
1. 积分消耗模式:每次使用功能扣除相应积分
|
||||
2. VIP免费模式:VIP用户在有效期内免费使用
|
||||
|
||||
计费配置存储在 .env 文件中,可通过系统设置界面配置。
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# 功能计费配置键名
|
||||
BILLING_CONFIG_PREFIX = 'BILLING_'
|
||||
|
||||
# 默认计费配置
|
||||
DEFAULT_BILLING_CONFIG = {
|
||||
# 全局开关
|
||||
'enabled': False, # 是否启用计费
|
||||
'vip_bypass': True, # VIP用户是否免费
|
||||
|
||||
# 各功能积分消耗(0表示免费)
|
||||
'cost_ai_analysis': 10, # AI分析 每次消耗积分
|
||||
'cost_strategy_run': 5, # 策略运行 每次消耗积分(启动时)
|
||||
'cost_backtest': 3, # 回测 每次消耗积分
|
||||
'cost_portfolio_monitor': 8, # Portfolio AI监控 每次消耗积分
|
||||
'cost_indicator_create': 0, # 创建指标 免费
|
||||
}
|
||||
|
||||
# Feature name mapping (for log recording)
|
||||
FEATURE_NAMES = {
|
||||
'ai_analysis': 'AI Analysis',
|
||||
'strategy_run': 'Strategy Run',
|
||||
'backtest': 'Backtest',
|
||||
'portfolio_monitor': 'Portfolio Monitor',
|
||||
'indicator_create': 'Indicator Create',
|
||||
}
|
||||
|
||||
|
||||
class BillingService:
|
||||
"""计费服务类"""
|
||||
|
||||
def __init__(self):
|
||||
self._config_cache = None
|
||||
self._config_cache_time = 0
|
||||
self._cache_ttl = 60 # 配置缓存60秒
|
||||
|
||||
def get_billing_config(self) -> Dict[str, Any]:
|
||||
"""获取计费配置"""
|
||||
now = time.time()
|
||||
if self._config_cache and (now - self._config_cache_time) < self._cache_ttl:
|
||||
return self._config_cache
|
||||
|
||||
config = {}
|
||||
for key, default_value in DEFAULT_BILLING_CONFIG.items():
|
||||
env_key = f'{BILLING_CONFIG_PREFIX}{key.upper()}'
|
||||
value = os.getenv(env_key)
|
||||
|
||||
if value is None or value == '':
|
||||
config[key] = default_value
|
||||
elif isinstance(default_value, bool):
|
||||
config[key] = str(value).lower() in ('true', '1', 'yes')
|
||||
elif isinstance(default_value, int):
|
||||
try:
|
||||
config[key] = int(value)
|
||||
except (ValueError, TypeError):
|
||||
config[key] = default_value
|
||||
else:
|
||||
config[key] = value
|
||||
|
||||
self._config_cache = config
|
||||
self._config_cache_time = now
|
||||
return config
|
||||
|
||||
def clear_config_cache(self):
|
||||
"""清除配置缓存"""
|
||||
self._config_cache = None
|
||||
self._config_cache_time = 0
|
||||
|
||||
def is_billing_enabled(self) -> bool:
|
||||
"""检查是否启用计费"""
|
||||
config = self.get_billing_config()
|
||||
return config.get('enabled', False)
|
||||
|
||||
def get_feature_cost(self, feature: str) -> int:
|
||||
"""获取功能消耗积分"""
|
||||
config = self.get_billing_config()
|
||||
cost_key = f'cost_{feature}'
|
||||
return config.get(cost_key, 0)
|
||||
|
||||
def get_user_credits(self, user_id: int) -> Decimal:
|
||||
"""获取用户积分余额"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT credits FROM qd_users WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if row:
|
||||
return Decimal(str(row.get('credits', 0) or 0))
|
||||
return Decimal('0')
|
||||
except Exception as e:
|
||||
logger.error(f"get_user_credits failed: {e}")
|
||||
return Decimal('0')
|
||||
|
||||
def get_user_vip_status(self, user_id: int) -> Tuple[bool, Optional[datetime]]:
|
||||
"""
|
||||
获取用户VIP状态
|
||||
|
||||
Returns:
|
||||
(is_vip, expires_at): VIP是否有效, VIP过期时间
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT vip_expires_at FROM qd_users WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if row and row.get('vip_expires_at'):
|
||||
expires_at = row['vip_expires_at']
|
||||
# 确保是 datetime 对象
|
||||
if isinstance(expires_at, str):
|
||||
expires_at = datetime.fromisoformat(expires_at.replace('Z', '+00:00'))
|
||||
|
||||
# 检查是否过期
|
||||
now = datetime.now(timezone.utc)
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
is_vip = expires_at > now
|
||||
return is_vip, expires_at
|
||||
|
||||
return False, None
|
||||
except Exception as e:
|
||||
logger.error(f"get_user_vip_status failed: {e}")
|
||||
return False, None
|
||||
|
||||
def check_and_consume(self, user_id: int, feature: str, reference_id: str = '') -> Tuple[bool, str]:
|
||||
"""
|
||||
检查并消耗积分
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
feature: 功能名称(ai_analysis/strategy_run/backtest/portfolio_monitor等)
|
||||
reference_id: 关联ID(可选)
|
||||
|
||||
Returns:
|
||||
(success, message): 是否成功, 提示消息
|
||||
"""
|
||||
# 检查是否启用计费
|
||||
if not self.is_billing_enabled():
|
||||
return True, 'billing_disabled'
|
||||
|
||||
config = self.get_billing_config()
|
||||
cost = self.get_feature_cost(feature)
|
||||
|
||||
# 免费功能
|
||||
if cost <= 0:
|
||||
return True, 'free_feature'
|
||||
|
||||
# 检查VIP状态
|
||||
if config.get('vip_bypass', True):
|
||||
is_vip, _ = self.get_user_vip_status(user_id)
|
||||
if is_vip:
|
||||
return True, 'vip_free'
|
||||
|
||||
# 检查积分余额
|
||||
credits = self.get_user_credits(user_id)
|
||||
if credits < cost:
|
||||
return False, f'insufficient_credits:{credits}:{cost}'
|
||||
|
||||
# 扣除积分
|
||||
try:
|
||||
new_balance = credits - Decimal(str(cost))
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# 更新用户积分
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
|
||||
(float(new_balance), user_id)
|
||||
)
|
||||
|
||||
# 记录日志
|
||||
feature_name = FEATURE_NAMES.get(feature, feature)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_credits_log
|
||||
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at)
|
||||
VALUES (?, 'consume', ?, ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(user_id, -cost, float(new_balance), feature, reference_id, f'Consume: {feature_name}')
|
||||
)
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
logger.info(f"User {user_id} consumed {cost} credits for {feature}, balance: {new_balance}")
|
||||
return True, 'consumed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"check_and_consume failed: {e}")
|
||||
return False, f'error:{str(e)}'
|
||||
|
||||
def add_credits(self, user_id: int, amount: int, action: str = 'recharge',
|
||||
remark: str = '', operator_id: int = None, reference_id: str = '') -> Tuple[bool, str]:
|
||||
"""
|
||||
增加用户积分
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
amount: 增加金额(正数)
|
||||
action: 操作类型(recharge/admin_adjust/refund/referral_bonus/register_bonus)
|
||||
remark: 备注
|
||||
operator_id: 操作人ID(管理员操作时)
|
||||
reference_id: 关联ID(如被邀请用户ID、订单号等)
|
||||
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
if amount <= 0:
|
||||
return False, 'amount_must_be_positive'
|
||||
|
||||
try:
|
||||
credits = self.get_user_credits(user_id)
|
||||
new_balance = credits + Decimal(str(amount))
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# 更新用户积分
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
|
||||
(float(new_balance), user_id)
|
||||
)
|
||||
|
||||
# 记录日志(包含 reference_id)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_credits_log
|
||||
(user_id, action, amount, balance_after, remark, operator_id, reference_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(user_id, action, amount, float(new_balance), remark, operator_id, reference_id)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
logger.info(f"User {user_id} added {amount} credits ({action}), balance: {new_balance}")
|
||||
return True, str(new_balance)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"add_credits failed: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def set_credits(self, user_id: int, amount: int, remark: str = '',
|
||||
operator_id: int = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
设置用户积分(管理员直接设置)
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
amount: 设置的金额
|
||||
remark: 备注
|
||||
operator_id: 操作人ID
|
||||
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
if amount < 0:
|
||||
return False, 'amount_cannot_be_negative'
|
||||
|
||||
try:
|
||||
old_credits = self.get_user_credits(user_id)
|
||||
diff = Decimal(str(amount)) - old_credits
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# 更新用户积分
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
|
||||
(amount, user_id)
|
||||
)
|
||||
|
||||
# 记录日志
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_credits_log
|
||||
(user_id, action, amount, balance_after, remark, operator_id, created_at)
|
||||
VALUES (?, 'admin_adjust', ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(user_id, float(diff), amount, remark or f'Admin adjust: {old_credits} -> {amount}', operator_id)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
logger.info(f"User {user_id} credits set to {amount} by admin {operator_id}")
|
||||
return True, str(amount)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"set_credits failed: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def set_vip(self, user_id: int, expires_at: Optional[datetime],
|
||||
remark: str = '', operator_id: int = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
设置用户VIP状态
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
expires_at: VIP过期时间(None表示取消VIP)
|
||||
remark: 备注
|
||||
operator_id: 操作人ID
|
||||
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# 更新VIP过期时间
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET vip_expires_at = ?, updated_at = NOW() WHERE id = ?",
|
||||
(expires_at, user_id)
|
||||
)
|
||||
|
||||
# 记录日志
|
||||
action = 'vip_grant' if expires_at else 'vip_revoke'
|
||||
log_remark = remark or (f'VIP granted until {expires_at}' if expires_at else 'VIP revoked')
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_credits_log
|
||||
(user_id, action, amount, balance_after, remark, operator_id, created_at)
|
||||
VALUES (?, ?, 0, (SELECT credits FROM qd_users WHERE id = ?), ?, ?, NOW())
|
||||
""",
|
||||
(user_id, action, user_id, log_remark, operator_id)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
logger.info(f"User {user_id} VIP set to {expires_at} by admin {operator_id}")
|
||||
return True, 'success'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"set_vip failed: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def get_credits_log(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||||
"""获取用户积分变动日志"""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# 获取总数
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as count FROM qd_credits_log WHERE user_id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
total = cur.fetchone()['count']
|
||||
|
||||
# 获取日志
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, action, amount, balance_after, feature, reference_id, remark, created_at
|
||||
FROM qd_credits_log
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(user_id, page_size, offset)
|
||||
)
|
||||
logs = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'items': logs,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total + page_size - 1) // page_size
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"get_credits_log failed: {e}")
|
||||
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
|
||||
|
||||
def get_user_billing_info(self, user_id: int) -> Dict[str, Any]:
|
||||
"""获取用户计费信息(供前端显示)"""
|
||||
credits = self.get_user_credits(user_id)
|
||||
is_vip, vip_expires_at = self.get_user_vip_status(user_id)
|
||||
config = self.get_billing_config()
|
||||
|
||||
return {
|
||||
'credits': float(credits),
|
||||
'is_vip': is_vip,
|
||||
'vip_expires_at': vip_expires_at.isoformat() if vip_expires_at else None,
|
||||
'billing_enabled': config.get('enabled', False),
|
||||
'vip_bypass': config.get('vip_bypass', True),
|
||||
# 功能费用(供前端显示)
|
||||
'feature_costs': {
|
||||
'ai_analysis': config.get('cost_ai_analysis', 0),
|
||||
'strategy_run': config.get('cost_strategy_run', 0),
|
||||
'backtest': config.get('cost_backtest', 0),
|
||||
'portfolio_monitor': config.get('cost_portfolio_monitor', 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# 全局单例
|
||||
_billing_service = None
|
||||
|
||||
|
||||
def get_billing_service() -> BillingService:
|
||||
"""获取计费服务单例"""
|
||||
global _billing_service
|
||||
if _billing_service is None:
|
||||
_billing_service = BillingService()
|
||||
return _billing_service
|
||||
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
Email Service - Handles email verification codes and notifications.
|
||||
"""
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Tuple, Optional
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Singleton instance
|
||||
_email_service = None
|
||||
|
||||
|
||||
def get_email_service():
|
||||
"""Get singleton EmailService instance"""
|
||||
global _email_service
|
||||
if _email_service is None:
|
||||
_email_service = EmailService()
|
||||
return _email_service
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Email service for verification codes and notifications"""
|
||||
|
||||
def __init__(self):
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
"""Load email configuration from environment variables"""
|
||||
self.smtp_host = os.getenv('SMTP_HOST', '')
|
||||
self.smtp_port = int(os.getenv('SMTP_PORT', '587'))
|
||||
self.smtp_user = os.getenv('SMTP_USER', '')
|
||||
self.smtp_password = os.getenv('SMTP_PASSWORD', '')
|
||||
self.smtp_from = os.getenv('SMTP_FROM', '') or self.smtp_user
|
||||
self.smtp_use_tls = os.getenv('SMTP_USE_TLS', 'true').lower() == 'true'
|
||||
self.smtp_use_ssl = os.getenv('SMTP_USE_SSL', 'false').lower() == 'true'
|
||||
|
||||
# Verification code settings
|
||||
self.code_expire_minutes = int(os.getenv('VERIFICATION_CODE_EXPIRE_MINUTES', '10'))
|
||||
self.code_length = 6
|
||||
|
||||
# Verification code attempt limits (anti-brute-force)
|
||||
self.code_max_attempts = int(os.getenv('VERIFICATION_CODE_MAX_ATTEMPTS', '5'))
|
||||
self.code_lock_minutes = int(os.getenv('VERIFICATION_CODE_LOCK_MINUTES', '30'))
|
||||
|
||||
# Check if email is properly configured
|
||||
self.email_enabled = bool(self.smtp_host and self.smtp_user and self.smtp_password)
|
||||
|
||||
if not self.email_enabled:
|
||||
logger.warning("Email service is not configured. SMTP settings are missing.")
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if email service is properly configured"""
|
||||
return self.email_enabled
|
||||
|
||||
# =========================================================================
|
||||
# Verification Code Generation & Storage
|
||||
# =========================================================================
|
||||
|
||||
def generate_code(self) -> str:
|
||||
"""Generate a random numeric verification code"""
|
||||
return ''.join(random.choices(string.digits, k=self.code_length))
|
||||
|
||||
def create_verification_code(self, email: str, code_type: str,
|
||||
ip_address: str = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
Create and store a new verification code.
|
||||
|
||||
Args:
|
||||
email: Email address
|
||||
code_type: Type of verification (register, reset_password, change_password, change_email)
|
||||
ip_address: Requester's IP address
|
||||
|
||||
Returns:
|
||||
(success, code_or_message)
|
||||
"""
|
||||
try:
|
||||
code = self.generate_code()
|
||||
expires_at = datetime.now() + timedelta(minutes=self.code_expire_minutes)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Invalidate any existing unused codes of the same type for this email
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_verification_codes
|
||||
SET used_at = NOW()
|
||||
WHERE email = ? AND type = ? AND used_at IS NULL
|
||||
""",
|
||||
(email, code_type)
|
||||
)
|
||||
|
||||
# Insert new code
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_verification_codes
|
||||
(email, code, type, expires_at, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(email, code, code_type, expires_at, ip_address)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
return True, code
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create verification code: {e}")
|
||||
return False, 'Failed to generate verification code'
|
||||
|
||||
def verify_code(self, email: str, code: str, code_type: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Verify a submitted code with brute-force protection.
|
||||
|
||||
Args:
|
||||
email: Email address
|
||||
code: The code to verify
|
||||
code_type: Type of verification
|
||||
|
||||
Returns:
|
||||
(valid, message)
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Check if locked due to too many failed attempts
|
||||
lock_window = datetime.now() - timedelta(minutes=self.code_lock_minutes)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) as cnt FROM qd_verification_codes
|
||||
WHERE email = ? AND type = ?
|
||||
AND attempts >= ? AND last_attempt_at > ?
|
||||
AND used_at IS NULL
|
||||
""",
|
||||
(email, code_type, self.code_max_attempts, lock_window.isoformat())
|
||||
)
|
||||
lock_row = cur.fetchone()
|
||||
if lock_row and lock_row['cnt'] > 0:
|
||||
cur.close()
|
||||
return False, f'Too many failed attempts. Please try again in {self.code_lock_minutes} minutes'
|
||||
|
||||
# Find latest unused code for this email/type
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, code as stored_code, expires_at, attempts FROM qd_verification_codes
|
||||
WHERE email = ? AND type = ? AND used_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(email, code_type)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
cur.close()
|
||||
return False, 'Invalid verification code'
|
||||
|
||||
code_id = row['id']
|
||||
stored_code = row['stored_code']
|
||||
attempts = row['attempts'] or 0
|
||||
|
||||
# Check if code matches
|
||||
if stored_code != code:
|
||||
# Increment attempt counter
|
||||
new_attempts = attempts + 1
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_verification_codes
|
||||
SET attempts = ?, last_attempt_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(new_attempts, code_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
remaining = self.code_max_attempts - new_attempts
|
||||
if remaining <= 0:
|
||||
return False, f'Too many failed attempts. Please try again in {self.code_lock_minutes} minutes'
|
||||
return False, f'Invalid verification code. {remaining} attempts remaining'
|
||||
|
||||
# Check expiration
|
||||
expires_at = row['expires_at']
|
||||
if isinstance(expires_at, str):
|
||||
expires_at = datetime.fromisoformat(expires_at)
|
||||
|
||||
if datetime.now() > expires_at:
|
||||
cur.close()
|
||||
return False, 'Verification code has expired'
|
||||
|
||||
# Mark as used
|
||||
cur.execute(
|
||||
"UPDATE qd_verification_codes SET used_at = NOW() WHERE id = ?",
|
||||
(code_id,)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
return True, 'verified'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to verify code: {e}")
|
||||
return False, 'Verification failed'
|
||||
|
||||
# =========================================================================
|
||||
# Email Sending
|
||||
# =========================================================================
|
||||
|
||||
def send_email(self, to_email: str, subject: str, html_body: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Send an email.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
subject: Email subject
|
||||
html_body: HTML body content
|
||||
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
if not self.email_enabled:
|
||||
logger.warning(f"Email not sent (service disabled): {subject} to {to_email}")
|
||||
return False, 'Email service is not configured'
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = self.smtp_from
|
||||
msg['To'] = to_email
|
||||
|
||||
# Plain text version (fallback)
|
||||
text_body = html_body.replace('<br>', '\n').replace('<br/>', '\n')
|
||||
# Simple HTML tag removal for plain text
|
||||
import re
|
||||
text_body = re.sub('<[^<]+?>', '', text_body)
|
||||
|
||||
part1 = MIMEText(text_body, 'plain', 'utf-8')
|
||||
part2 = MIMEText(html_body, 'html', 'utf-8')
|
||||
|
||||
msg.attach(part1)
|
||||
msg.attach(part2)
|
||||
|
||||
# Connect and send
|
||||
if self.smtp_use_ssl:
|
||||
server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port)
|
||||
else:
|
||||
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
|
||||
if self.smtp_use_tls:
|
||||
server.starttls()
|
||||
|
||||
server.login(self.smtp_user, self.smtp_password)
|
||||
server.sendmail(self.smtp_from, to_email, msg.as_string())
|
||||
server.quit()
|
||||
|
||||
logger.info(f"Email sent successfully: {subject} to {to_email}")
|
||||
return True, 'sent'
|
||||
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
logger.error(f"SMTP authentication failed: {e}")
|
||||
return False, 'Email authentication failed'
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error(f"SMTP error: {e}")
|
||||
return False, 'Failed to send email'
|
||||
except Exception as e:
|
||||
logger.error(f"Email error: {e}")
|
||||
return False, 'Failed to send email'
|
||||
|
||||
def send_verification_code(self, email: str, code_type: str,
|
||||
ip_address: str = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
Generate and send a verification code email.
|
||||
|
||||
Args:
|
||||
email: Recipient email address
|
||||
code_type: Type of verification (register, reset_password, change_password)
|
||||
ip_address: Requester's IP address
|
||||
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
# Generate code
|
||||
success, code_or_msg = self.create_verification_code(email, code_type, ip_address)
|
||||
if not success:
|
||||
return False, code_or_msg
|
||||
|
||||
code = code_or_msg
|
||||
|
||||
# Prepare email content based on type
|
||||
if code_type == 'register':
|
||||
subject = 'QuantDinger - Verification Code for Registration'
|
||||
action_text = 'complete your registration'
|
||||
elif code_type == 'login':
|
||||
subject = 'QuantDinger - Quick Login Verification Code'
|
||||
action_text = 'log in to your account'
|
||||
elif code_type == 'reset_password':
|
||||
subject = 'QuantDinger - Password Reset Verification Code'
|
||||
action_text = 'reset your password'
|
||||
elif code_type == 'change_password':
|
||||
subject = 'QuantDinger - Verification Code for Password Change'
|
||||
action_text = 'change your password'
|
||||
elif code_type == 'change_email':
|
||||
subject = 'QuantDinger - Verification Code for Email Change'
|
||||
action_text = 'change your email address'
|
||||
else:
|
||||
subject = 'QuantDinger - Verification Code'
|
||||
action_text = 'complete the verification'
|
||||
|
||||
html_body = f"""
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="color: #1890ff; margin: 0;">QuantDinger</h1>
|
||||
<p style="color: #666; margin-top: 5px;">AI-Driven Quantitative Insights</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f5f5f5; border-radius: 8px; padding: 30px; text-align: center;">
|
||||
<p style="color: #333; font-size: 16px; margin: 0 0 20px 0;">
|
||||
Your verification code to {action_text} is:
|
||||
</p>
|
||||
<div style="background: #fff; border: 2px solid #1890ff; border-radius: 8px; padding: 20px; display: inline-block;">
|
||||
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #1890ff;">{code}</span>
|
||||
</div>
|
||||
<p style="color: #999; font-size: 14px; margin-top: 20px;">
|
||||
This code will expire in {self.code_expire_minutes} minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; padding: 20px; background: #fff8e6; border-radius: 8px;">
|
||||
<p style="color: #d48806; font-size: 14px; margin: 0;">
|
||||
<strong>Security Notice:</strong> If you did not request this code,
|
||||
please ignore this email. Do not share this code with anyone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 30px; color: #999; font-size: 12px;">
|
||||
<p>© QuantDinger. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Send email
|
||||
return self.send_email(email, subject, html_body)
|
||||
|
||||
# =========================================================================
|
||||
# Email Validation
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def is_valid_email(email: str) -> bool:
|
||||
"""Basic email format validation"""
|
||||
import re
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return bool(re.match(pattern, email))
|
||||
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
OAuth Service - Handles Google and GitHub OAuth authentication.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
from datetime import datetime
|
||||
from typing import Tuple, Optional, Dict, Any
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Singleton instance
|
||||
_oauth_service = None
|
||||
|
||||
|
||||
def get_oauth_service():
|
||||
"""Get singleton OAuthService instance"""
|
||||
global _oauth_service
|
||||
if _oauth_service is None:
|
||||
_oauth_service = OAuthService()
|
||||
return _oauth_service
|
||||
|
||||
|
||||
class OAuthService:
|
||||
"""OAuth service for Google and GitHub authentication"""
|
||||
|
||||
def __init__(self):
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
"""Load OAuth configuration from environment variables"""
|
||||
# Google OAuth
|
||||
self.google_client_id = os.getenv('GOOGLE_CLIENT_ID', '')
|
||||
self.google_client_secret = os.getenv('GOOGLE_CLIENT_SECRET', '')
|
||||
self.google_redirect_uri = os.getenv('GOOGLE_REDIRECT_URI', '')
|
||||
self.google_enabled = bool(self.google_client_id and self.google_client_secret)
|
||||
|
||||
# GitHub OAuth
|
||||
self.github_client_id = os.getenv('GITHUB_CLIENT_ID', '')
|
||||
self.github_client_secret = os.getenv('GITHUB_CLIENT_SECRET', '')
|
||||
self.github_redirect_uri = os.getenv('GITHUB_REDIRECT_URI', '')
|
||||
self.github_enabled = bool(self.github_client_id and self.github_client_secret)
|
||||
|
||||
# Frontend URL for redirect after OAuth
|
||||
self.frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:8080')
|
||||
|
||||
# State storage (in-memory for simplicity, could use Redis in production)
|
||||
self._states = {}
|
||||
|
||||
# =========================================================================
|
||||
# Google OAuth
|
||||
# =========================================================================
|
||||
|
||||
def get_google_auth_url(self, state: str = None) -> Tuple[str, str]:
|
||||
"""
|
||||
Generate Google OAuth authorization URL.
|
||||
|
||||
Returns:
|
||||
(auth_url, state)
|
||||
"""
|
||||
if not self.google_enabled:
|
||||
return '', ''
|
||||
|
||||
state = state or secrets.token_urlsafe(32)
|
||||
self._states[state] = {'provider': 'google', 'created_at': datetime.now()}
|
||||
|
||||
params = {
|
||||
'client_id': self.google_client_id,
|
||||
'redirect_uri': self.google_redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'openid email profile',
|
||||
'state': state,
|
||||
'access_type': 'offline',
|
||||
'prompt': 'select_account'
|
||||
}
|
||||
|
||||
auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
|
||||
return auth_url, state
|
||||
|
||||
def handle_google_callback(self, code: str, state: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
Handle Google OAuth callback.
|
||||
|
||||
Args:
|
||||
code: Authorization code from Google
|
||||
state: State parameter for CSRF protection
|
||||
|
||||
Returns:
|
||||
(success, user_info_or_error)
|
||||
"""
|
||||
# Validate state
|
||||
if state not in self._states or self._states[state].get('provider') != 'google':
|
||||
return False, {'error': 'Invalid state parameter'}
|
||||
|
||||
del self._states[state]
|
||||
|
||||
try:
|
||||
# Exchange code for tokens
|
||||
token_response = requests.post(
|
||||
'https://oauth2.googleapis.com/token',
|
||||
data={
|
||||
'code': code,
|
||||
'client_id': self.google_client_id,
|
||||
'client_secret': self.google_client_secret,
|
||||
'redirect_uri': self.google_redirect_uri,
|
||||
'grant_type': 'authorization_code'
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
logger.error(f"Google token exchange failed: {token_response.text}")
|
||||
return False, {'error': 'Failed to exchange authorization code'}
|
||||
|
||||
tokens = token_response.json()
|
||||
access_token = tokens.get('access_token')
|
||||
|
||||
# Get user info
|
||||
user_response = requests.get(
|
||||
'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if user_response.status_code != 200:
|
||||
logger.error(f"Google user info failed: {user_response.text}")
|
||||
return False, {'error': 'Failed to get user information'}
|
||||
|
||||
user_info = user_response.json()
|
||||
|
||||
return True, {
|
||||
'provider': 'google',
|
||||
'provider_user_id': user_info.get('id'),
|
||||
'email': user_info.get('email'),
|
||||
'name': user_info.get('name'),
|
||||
'avatar': user_info.get('picture'),
|
||||
'access_token': access_token,
|
||||
'refresh_token': tokens.get('refresh_token')
|
||||
}
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Google OAuth error: {e}")
|
||||
return False, {'error': 'OAuth service unavailable'}
|
||||
|
||||
# =========================================================================
|
||||
# GitHub OAuth
|
||||
# =========================================================================
|
||||
|
||||
def get_github_auth_url(self, state: str = None) -> Tuple[str, str]:
|
||||
"""
|
||||
Generate GitHub OAuth authorization URL.
|
||||
|
||||
Returns:
|
||||
(auth_url, state)
|
||||
"""
|
||||
if not self.github_enabled:
|
||||
return '', ''
|
||||
|
||||
state = state or secrets.token_urlsafe(32)
|
||||
self._states[state] = {'provider': 'github', 'created_at': datetime.now()}
|
||||
|
||||
params = {
|
||||
'client_id': self.github_client_id,
|
||||
'redirect_uri': self.github_redirect_uri,
|
||||
'scope': 'user:email read:user',
|
||||
'state': state
|
||||
}
|
||||
|
||||
auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
|
||||
return auth_url, state
|
||||
|
||||
def handle_github_callback(self, code: str, state: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
Handle GitHub OAuth callback.
|
||||
|
||||
Args:
|
||||
code: Authorization code from GitHub
|
||||
state: State parameter for CSRF protection
|
||||
|
||||
Returns:
|
||||
(success, user_info_or_error)
|
||||
"""
|
||||
# Validate state
|
||||
if state not in self._states or self._states[state].get('provider') != 'github':
|
||||
return False, {'error': 'Invalid state parameter'}
|
||||
|
||||
del self._states[state]
|
||||
|
||||
try:
|
||||
# Exchange code for token
|
||||
token_response = requests.post(
|
||||
'https://github.com/login/oauth/access_token',
|
||||
data={
|
||||
'client_id': self.github_client_id,
|
||||
'client_secret': self.github_client_secret,
|
||||
'code': code,
|
||||
'redirect_uri': self.github_redirect_uri
|
||||
},
|
||||
headers={'Accept': 'application/json'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
logger.error(f"GitHub token exchange failed: {token_response.text}")
|
||||
return False, {'error': 'Failed to exchange authorization code'}
|
||||
|
||||
tokens = token_response.json()
|
||||
access_token = tokens.get('access_token')
|
||||
|
||||
if not access_token:
|
||||
error = tokens.get('error_description', 'Unknown error')
|
||||
logger.error(f"GitHub token error: {error}")
|
||||
return False, {'error': error}
|
||||
|
||||
# Get user info
|
||||
user_response = requests.get(
|
||||
'https://api.github.com/user',
|
||||
headers={
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if user_response.status_code != 200:
|
||||
logger.error(f"GitHub user info failed: {user_response.text}")
|
||||
return False, {'error': 'Failed to get user information'}
|
||||
|
||||
user_info = user_response.json()
|
||||
|
||||
# Get user email (might be private)
|
||||
email = user_info.get('email')
|
||||
if not email:
|
||||
email_response = requests.get(
|
||||
'https://api.github.com/user/emails',
|
||||
headers={
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
if email_response.status_code == 200:
|
||||
emails = email_response.json()
|
||||
# Find primary email
|
||||
for e in emails:
|
||||
if e.get('primary') and e.get('verified'):
|
||||
email = e.get('email')
|
||||
break
|
||||
# Fallback to any verified email
|
||||
if not email:
|
||||
for e in emails:
|
||||
if e.get('verified'):
|
||||
email = e.get('email')
|
||||
break
|
||||
|
||||
return True, {
|
||||
'provider': 'github',
|
||||
'provider_user_id': str(user_info.get('id')),
|
||||
'email': email,
|
||||
'name': user_info.get('name') or user_info.get('login'),
|
||||
'avatar': user_info.get('avatar_url'),
|
||||
'access_token': access_token,
|
||||
'refresh_token': None # GitHub doesn't use refresh tokens
|
||||
}
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"GitHub OAuth error: {e}")
|
||||
return False, {'error': 'OAuth service unavailable'}
|
||||
|
||||
# =========================================================================
|
||||
# OAuth Link Management
|
||||
# =========================================================================
|
||||
|
||||
def get_or_create_user_from_oauth(self, oauth_info: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
Get existing user or create new user from OAuth info.
|
||||
|
||||
Args:
|
||||
oauth_info: Dict with provider, provider_user_id, email, name, avatar, tokens
|
||||
|
||||
Returns:
|
||||
(success, user_or_error)
|
||||
"""
|
||||
provider = oauth_info['provider']
|
||||
provider_user_id = oauth_info['provider_user_id']
|
||||
email = oauth_info.get('email')
|
||||
name = oauth_info.get('name', '')
|
||||
avatar = oauth_info.get('avatar', '/avatar2.jpg')
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Check if OAuth link exists
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT user_id FROM qd_oauth_links
|
||||
WHERE provider = ? AND provider_user_id = ?
|
||||
""",
|
||||
(provider, provider_user_id)
|
||||
)
|
||||
link = cur.fetchone()
|
||||
|
||||
if link:
|
||||
# Existing OAuth link - get user
|
||||
user_id = link['user_id']
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, email, nickname, avatar, status, role
|
||||
FROM qd_users WHERE id = ?
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
|
||||
if user:
|
||||
# Update OAuth tokens
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_oauth_links
|
||||
SET access_token = ?, refresh_token = ?, updated_at = NOW()
|
||||
WHERE provider = ? AND provider_user_id = ?
|
||||
""",
|
||||
(oauth_info.get('access_token'), oauth_info.get('refresh_token'),
|
||||
provider, provider_user_id)
|
||||
)
|
||||
|
||||
# Update last login
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET last_login_at = NOW() WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True, dict(user)
|
||||
else:
|
||||
# Orphaned OAuth link - remove it
|
||||
cur.execute(
|
||||
"DELETE FROM qd_oauth_links WHERE provider = ? AND provider_user_id = ?",
|
||||
(provider, provider_user_id)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Check if user exists with same email
|
||||
if email:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, email, nickname, avatar, status, role
|
||||
FROM qd_users WHERE email = ?
|
||||
""",
|
||||
(email,)
|
||||
)
|
||||
existing_user = cur.fetchone()
|
||||
|
||||
if existing_user:
|
||||
# Link OAuth to existing user
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_oauth_links
|
||||
(user_id, provider, provider_user_id, provider_email,
|
||||
provider_name, provider_avatar, access_token, refresh_token)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(existing_user['id'], provider, provider_user_id, email,
|
||||
name, avatar, oauth_info.get('access_token'),
|
||||
oauth_info.get('refresh_token'))
|
||||
)
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET last_login_at = NOW() WHERE id = ?",
|
||||
(existing_user['id'],)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True, dict(existing_user)
|
||||
|
||||
# Create new user
|
||||
# Generate unique username from OAuth name or email
|
||||
base_username = (name or email.split('@')[0] if email else provider_user_id)
|
||||
base_username = ''.join(c for c in base_username if c.isalnum() or c in '_-')[:30]
|
||||
username = base_username
|
||||
|
||||
# Ensure username is unique
|
||||
counter = 1
|
||||
while True:
|
||||
cur.execute("SELECT id FROM qd_users WHERE username = ?", (username,))
|
||||
if not cur.fetchone():
|
||||
break
|
||||
username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# Generate a random password (user won't need it for OAuth login)
|
||||
import secrets
|
||||
random_password = secrets.token_urlsafe(32)
|
||||
from app.services.user_service import get_user_service
|
||||
password_hash = get_user_service().hash_password(random_password)
|
||||
|
||||
# Ensure email is unique or generate placeholder
|
||||
if email:
|
||||
cur.execute("SELECT id FROM qd_users WHERE email = ?", (email,))
|
||||
if cur.fetchone():
|
||||
email = f"{provider}_{provider_user_id}@oauth.local"
|
||||
else:
|
||||
email = f"{provider}_{provider_user_id}@oauth.local"
|
||||
|
||||
# Insert new user
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_users
|
||||
(username, password_hash, email, nickname, avatar, status, role, email_verified)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', 'user', TRUE)
|
||||
""",
|
||||
(username, password_hash, email, name or username, avatar or '/avatar2.jpg')
|
||||
)
|
||||
user_id = cur.lastrowid
|
||||
|
||||
# Create OAuth link
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_oauth_links
|
||||
(user_id, provider, provider_user_id, provider_email,
|
||||
provider_name, provider_avatar, access_token, refresh_token)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, provider, provider_user_id, oauth_info.get('email'),
|
||||
name, avatar, oauth_info.get('access_token'),
|
||||
oauth_info.get('refresh_token'))
|
||||
)
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
return True, {
|
||||
'id': user_id,
|
||||
'username': username,
|
||||
'email': email,
|
||||
'nickname': name or username,
|
||||
'avatar': avatar or '/avatar2.jpg',
|
||||
'status': 'active',
|
||||
'role': 'user'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth user creation failed: {e}")
|
||||
return False, {'error': 'Failed to create user account'}
|
||||
|
||||
def get_user_oauth_links(self, user_id: int) -> list:
|
||||
"""Get all OAuth links for a user"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT provider, provider_email, provider_name, created_at
|
||||
FROM qd_oauth_links WHERE user_id = ?
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
links = cur.fetchall()
|
||||
cur.close()
|
||||
return [dict(link) for link in links] if links else []
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get OAuth links: {e}")
|
||||
return []
|
||||
|
||||
def unlink_oauth(self, user_id: int, provider: str) -> Tuple[bool, str]:
|
||||
"""Unlink an OAuth provider from user account"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Check if user has password (can't unlink last auth method)
|
||||
cur.execute(
|
||||
"SELECT password_hash FROM qd_users WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
|
||||
if not user or not user['password_hash']:
|
||||
# Check if this is the only OAuth link
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as count FROM qd_oauth_links WHERE user_id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
count = cur.fetchone()['count']
|
||||
if count <= 1:
|
||||
cur.close()
|
||||
return False, 'Cannot unlink the only authentication method'
|
||||
|
||||
cur.execute(
|
||||
"DELETE FROM qd_oauth_links WHERE user_id = ? AND provider = ?",
|
||||
(user_id, provider)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True, 'unlinked'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to unlink OAuth: {e}")
|
||||
return False, 'Failed to unlink account'
|
||||
|
||||
# =========================================================================
|
||||
# Cleanup
|
||||
# =========================================================================
|
||||
|
||||
def cleanup_expired_states(self, max_age_minutes: int = 10):
|
||||
"""Clean up expired OAuth states"""
|
||||
cutoff = datetime.now()
|
||||
from datetime import timedelta
|
||||
cutoff = cutoff - timedelta(minutes=max_age_minutes)
|
||||
|
||||
expired = [k for k, v in self._states.items()
|
||||
if v.get('created_at', datetime.now()) < cutoff]
|
||||
for k in expired:
|
||||
del self._states[k]
|
||||
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Security Service - Handles Turnstile verification, rate limiting, and brute-force protection.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Tuple, Optional, Dict, Any
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Singleton instance
|
||||
_security_service = None
|
||||
|
||||
|
||||
def get_security_service():
|
||||
"""Get singleton SecurityService instance"""
|
||||
global _security_service
|
||||
if _security_service is None:
|
||||
_security_service = SecurityService()
|
||||
return _security_service
|
||||
|
||||
|
||||
class SecurityService:
|
||||
"""Security service for authentication protection"""
|
||||
|
||||
def __init__(self):
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
"""Load security configuration from environment variables"""
|
||||
# Turnstile config
|
||||
self.turnstile_site_key = os.getenv('TURNSTILE_SITE_KEY', '')
|
||||
self.turnstile_secret_key = os.getenv('TURNSTILE_SECRET_KEY', '')
|
||||
self.turnstile_enabled = bool(self.turnstile_site_key and self.turnstile_secret_key)
|
||||
|
||||
# IP rate limit config
|
||||
self.ip_max_attempts = int(os.getenv('SECURITY_IP_MAX_ATTEMPTS', '10'))
|
||||
self.ip_window_minutes = int(os.getenv('SECURITY_IP_WINDOW_MINUTES', '5'))
|
||||
self.ip_block_minutes = int(os.getenv('SECURITY_IP_BLOCK_MINUTES', '15'))
|
||||
|
||||
# Account rate limit config
|
||||
self.account_max_attempts = int(os.getenv('SECURITY_ACCOUNT_MAX_ATTEMPTS', '5'))
|
||||
self.account_window_minutes = int(os.getenv('SECURITY_ACCOUNT_WINDOW_MINUTES', '60'))
|
||||
self.account_block_minutes = int(os.getenv('SECURITY_ACCOUNT_BLOCK_MINUTES', '30'))
|
||||
|
||||
# Verification code rate limit
|
||||
self.code_rate_limit_seconds = int(os.getenv('VERIFICATION_CODE_RATE_LIMIT', '60'))
|
||||
self.code_ip_hourly_limit = int(os.getenv('VERIFICATION_CODE_IP_HOURLY_LIMIT', '10'))
|
||||
|
||||
def get_security_config(self) -> Dict[str, Any]:
|
||||
"""Get public security config for frontend"""
|
||||
return {
|
||||
'turnstile_enabled': self.turnstile_enabled,
|
||||
'turnstile_site_key': self.turnstile_site_key,
|
||||
'registration_enabled': os.getenv('ENABLE_REGISTRATION', 'true').lower() == 'true',
|
||||
'oauth_google_enabled': bool(os.getenv('GOOGLE_CLIENT_ID', '')),
|
||||
'oauth_github_enabled': bool(os.getenv('GITHUB_CLIENT_ID', '')),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Turnstile Verification
|
||||
# =========================================================================
|
||||
|
||||
def verify_turnstile(self, token: str, ip_address: str = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
Verify Cloudflare Turnstile token.
|
||||
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
if not self.turnstile_enabled:
|
||||
# If Turnstile is not configured, skip verification
|
||||
return True, 'turnstile_disabled'
|
||||
|
||||
if not token:
|
||||
return False, 'Missing Turnstile token'
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
data={
|
||||
'secret': self.turnstile_secret_key,
|
||||
'response': token,
|
||||
'remoteip': ip_address
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
if result.get('success'):
|
||||
return True, 'verified'
|
||||
else:
|
||||
error_codes = result.get('error-codes', [])
|
||||
logger.warning(f"Turnstile verification failed: {error_codes}")
|
||||
return False, 'Turnstile verification failed'
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Turnstile API error: {e}")
|
||||
# On API error, we might want to allow (fail-open) or deny (fail-closed)
|
||||
# For security, we'll deny
|
||||
return False, 'Turnstile service unavailable'
|
||||
|
||||
# =========================================================================
|
||||
# Rate Limiting & Brute-Force Protection
|
||||
# =========================================================================
|
||||
|
||||
def record_login_attempt(self, identifier: str, identifier_type: str,
|
||||
success: bool, ip_address: str = None,
|
||||
user_agent: str = None) -> bool:
|
||||
"""
|
||||
Record a login attempt for rate limiting.
|
||||
|
||||
Args:
|
||||
identifier: IP address or username
|
||||
identifier_type: 'ip' or 'account'
|
||||
success: Whether the attempt was successful
|
||||
ip_address: Client IP address
|
||||
user_agent: Client user agent string
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_login_attempts
|
||||
(identifier, identifier_type, success, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(identifier, identifier_type, success, ip_address, user_agent)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record login attempt: {e}")
|
||||
return False
|
||||
|
||||
def is_blocked(self, identifier: str, identifier_type: str) -> Tuple[bool, int]:
|
||||
"""
|
||||
Check if an identifier (IP or account) is blocked due to too many failed attempts.
|
||||
|
||||
Returns:
|
||||
(is_blocked, remaining_seconds)
|
||||
"""
|
||||
try:
|
||||
if identifier_type == 'ip':
|
||||
max_attempts = self.ip_max_attempts
|
||||
window_minutes = self.ip_window_minutes
|
||||
block_minutes = self.ip_block_minutes
|
||||
else: # account
|
||||
max_attempts = self.account_max_attempts
|
||||
window_minutes = self.account_window_minutes
|
||||
block_minutes = self.account_block_minutes
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Count failed attempts in the time window
|
||||
window_start = datetime.now() - timedelta(minutes=window_minutes)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) as count, MAX(attempt_time) as last_attempt
|
||||
FROM qd_login_attempts
|
||||
WHERE identifier = ? AND identifier_type = ?
|
||||
AND success = FALSE AND attempt_time > ?
|
||||
""",
|
||||
(identifier, identifier_type, window_start)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if not row:
|
||||
return False, 0
|
||||
|
||||
failed_count = row['count'] or 0
|
||||
last_attempt = row['last_attempt']
|
||||
|
||||
if failed_count >= max_attempts:
|
||||
# Check if still in block period
|
||||
if last_attempt:
|
||||
block_until = last_attempt + timedelta(minutes=block_minutes)
|
||||
if datetime.now() < block_until:
|
||||
remaining = int((block_until - datetime.now()).total_seconds())
|
||||
return True, remaining
|
||||
|
||||
return False, 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check block status: {e}")
|
||||
return False, 0
|
||||
|
||||
def check_login_allowed(self, username: str, ip_address: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if login is allowed for the given username and IP.
|
||||
|
||||
Returns:
|
||||
(allowed, message)
|
||||
"""
|
||||
# Check IP block
|
||||
ip_blocked, ip_remaining = self.is_blocked(ip_address, 'ip')
|
||||
if ip_blocked:
|
||||
minutes = ip_remaining // 60
|
||||
return False, f'Too many failed attempts from this IP. Try again in {minutes + 1} minutes.'
|
||||
|
||||
# Check account block
|
||||
account_blocked, account_remaining = self.is_blocked(username, 'account')
|
||||
if account_blocked:
|
||||
minutes = account_remaining // 60
|
||||
return False, f'Account temporarily locked due to too many failed attempts. Try again in {minutes + 1} minutes.'
|
||||
|
||||
return True, 'allowed'
|
||||
|
||||
def clear_login_attempts(self, identifier: str, identifier_type: str) -> bool:
|
||||
"""
|
||||
Clear login attempts for an identifier (called after successful login).
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM qd_login_attempts
|
||||
WHERE identifier = ? AND identifier_type = ?
|
||||
""",
|
||||
(identifier, identifier_type)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear login attempts: {e}")
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Security Audit Logging
|
||||
# =========================================================================
|
||||
|
||||
def log_security_event(self, action: str, user_id: int = None,
|
||||
ip_address: str = None, user_agent: str = None,
|
||||
details: dict = None) -> bool:
|
||||
"""
|
||||
Log a security-related event.
|
||||
|
||||
Args:
|
||||
action: Event type (login, logout, register, reset_password, etc.)
|
||||
user_id: User ID if applicable
|
||||
ip_address: Client IP
|
||||
user_agent: Client user agent
|
||||
details: Additional details as dict
|
||||
"""
|
||||
try:
|
||||
details_json = json.dumps(details) if details else None
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_security_logs
|
||||
(user_id, action, ip_address, user_agent, details)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, action, ip_address, user_agent, details_json)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log security event: {e}")
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Verification Code Rate Limiting
|
||||
# =========================================================================
|
||||
|
||||
def can_send_verification_code(self, email: str, ip_address: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if we can send a verification code to this email from this IP.
|
||||
|
||||
Returns:
|
||||
(allowed, message)
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Check email rate limit (one code per minute per email)
|
||||
rate_limit_time = datetime.now() - timedelta(seconds=self.code_rate_limit_seconds)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) as count FROM qd_verification_codes
|
||||
WHERE email = ? AND created_at > ?
|
||||
""",
|
||||
(email, rate_limit_time)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row['count'] > 0:
|
||||
return False, f'Please wait {self.code_rate_limit_seconds} seconds before requesting another code'
|
||||
|
||||
# Check IP hourly limit
|
||||
hour_ago = datetime.now() - timedelta(hours=1)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) as count FROM qd_verification_codes
|
||||
WHERE ip_address = ? AND created_at > ?
|
||||
""",
|
||||
(ip_address, hour_ago)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row['count'] >= self.code_ip_hourly_limit:
|
||||
return False, 'Too many verification code requests from this IP. Try again later.'
|
||||
|
||||
cur.close()
|
||||
return True, 'allowed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check verification code rate limit: {e}")
|
||||
return True, 'allowed' # Fail open on DB errors
|
||||
|
||||
# =========================================================================
|
||||
# Password Strength Validation
|
||||
# =========================================================================
|
||||
|
||||
def validate_password_strength(self, password: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate password meets minimum security requirements.
|
||||
|
||||
Requirements:
|
||||
- At least 8 characters
|
||||
- Contains at least one uppercase letter
|
||||
- Contains at least one lowercase letter
|
||||
- Contains at least one digit
|
||||
|
||||
Returns:
|
||||
(valid, message)
|
||||
"""
|
||||
if len(password) < 8:
|
||||
return False, 'Password must be at least 8 characters long'
|
||||
|
||||
if not any(c.isupper() for c in password):
|
||||
return False, 'Password must contain at least one uppercase letter'
|
||||
|
||||
if not any(c.islower() for c in password):
|
||||
return False, 'Password must contain at least one lowercase letter'
|
||||
|
||||
if not any(c.isdigit() for c in password):
|
||||
return False, 'Password must contain at least one digit'
|
||||
|
||||
return True, 'valid'
|
||||
|
||||
# =========================================================================
|
||||
# Cleanup
|
||||
# =========================================================================
|
||||
|
||||
def cleanup_old_records(self, days: int = 7) -> int:
|
||||
"""
|
||||
Clean up old login attempts and expired verification codes.
|
||||
|
||||
Returns:
|
||||
Number of records deleted
|
||||
"""
|
||||
deleted = 0
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Clean old login attempts
|
||||
cur.execute(
|
||||
"DELETE FROM qd_login_attempts WHERE attempt_time < ?",
|
||||
(cutoff,)
|
||||
)
|
||||
deleted += cur.rowcount or 0
|
||||
|
||||
# Clean expired verification codes
|
||||
cur.execute(
|
||||
"DELETE FROM qd_verification_codes WHERE expires_at < ?",
|
||||
(cutoff,)
|
||||
)
|
||||
deleted += cur.rowcount or 0
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
logger.info(f"Security cleanup: deleted {deleted} old records")
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Security cleanup failed: {e}")
|
||||
return 0
|
||||
@@ -75,7 +75,7 @@ class UserService:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, email, nickname, avatar, status, role,
|
||||
last_login_at, created_at, updated_at
|
||||
credits, vip_expires_at, last_login_at, created_at, updated_at
|
||||
FROM qd_users WHERE id = ?
|
||||
""",
|
||||
(user_id,)
|
||||
@@ -107,12 +107,41 @@ class UserService:
|
||||
logger.error(f"get_user_by_username failed: {e}")
|
||||
return None
|
||||
|
||||
def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get user by email (includes password_hash for auth)"""
|
||||
if not email:
|
||||
return None
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, password_hash, email, nickname, avatar,
|
||||
status, role, last_login_at, created_at, updated_at
|
||||
FROM qd_users WHERE LOWER(email) = LOWER(?)
|
||||
""",
|
||||
(email,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row
|
||||
except Exception as e:
|
||||
logger.error(f"get_user_by_email failed: {e}")
|
||||
return None
|
||||
|
||||
def authenticate(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Authenticate user with username and password.
|
||||
Authenticate user with username/email and password.
|
||||
Supports both username and email login.
|
||||
Returns user info (without password_hash) if successful, None otherwise.
|
||||
"""
|
||||
# Try username first
|
||||
user = self.get_user_by_username(username)
|
||||
|
||||
# If not found, try email (supports both username and email login)
|
||||
if not user:
|
||||
user = self.get_user_by_email(username)
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
@@ -120,7 +149,16 @@ class UserService:
|
||||
logger.warning(f"Login attempt for disabled user: {username}")
|
||||
return None
|
||||
|
||||
if not self.verify_password(password, user.get('password_hash', '')):
|
||||
password_hash = user.get('password_hash', '')
|
||||
|
||||
# Check if user has no password (code-login user)
|
||||
if not password_hash or password_hash.strip() == '':
|
||||
logger.info(f"Password login attempted for code-login user: {username}")
|
||||
# Return a special marker to indicate no password set
|
||||
# This allows the caller to provide a more specific error message
|
||||
return {'_no_password': True, **user}
|
||||
|
||||
if not self.verify_password(password, password_hash):
|
||||
return None
|
||||
|
||||
# Update last login time
|
||||
@@ -140,33 +178,41 @@ class UserService:
|
||||
user.pop('password_hash', None)
|
||||
return user
|
||||
|
||||
def create_user(self, data: Dict[str, Any]) -> Optional[int]:
|
||||
def create_user(self, data: Dict[str, Any] = None, **kwargs) -> Optional[int]:
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
Args:
|
||||
data: {
|
||||
data: dict with user fields, OR use keyword arguments:
|
||||
username: str (required),
|
||||
password: str (required),
|
||||
password: str (optional, can be None for code-login users),
|
||||
email: str (optional),
|
||||
nickname: str (optional),
|
||||
role: str (optional, default 'user'),
|
||||
status: str (optional, default 'active')
|
||||
}
|
||||
status: str (optional, default 'active'),
|
||||
email_verified: bool (optional, default False),
|
||||
referred_by: int (optional, referrer user ID)
|
||||
|
||||
Returns:
|
||||
New user ID or None if failed
|
||||
"""
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
# Support both dict and kwargs style
|
||||
if data is None:
|
||||
data = kwargs
|
||||
else:
|
||||
data = {**data, **kwargs}
|
||||
|
||||
if not username or not password:
|
||||
raise ValueError("Username and password are required")
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') # Can be None for code-login users
|
||||
|
||||
if not username:
|
||||
raise ValueError("Username is required")
|
||||
|
||||
if len(username) < 3 or len(username) > 50:
|
||||
raise ValueError("Username must be 3-50 characters")
|
||||
|
||||
if len(password) < 6:
|
||||
# Password validation only if provided
|
||||
if password and len(password) < 6:
|
||||
raise ValueError("Password must be at least 6 characters")
|
||||
|
||||
# Check if username already exists
|
||||
@@ -174,11 +220,14 @@ class UserService:
|
||||
if existing:
|
||||
raise ValueError("Username already exists")
|
||||
|
||||
password_hash = self.hash_password(password)
|
||||
# Hash password or use empty string for code-login users
|
||||
password_hash = self.hash_password(password) if password else ''
|
||||
email = (data.get('email') or '').strip() or None
|
||||
nickname = (data.get('nickname') or '').strip() or username
|
||||
role = data.get('role', 'user')
|
||||
status = data.get('status', 'active')
|
||||
email_verified = data.get('email_verified', False)
|
||||
referred_by = data.get('referred_by') # Referrer user ID
|
||||
|
||||
if role not in self.ROLES:
|
||||
role = 'user'
|
||||
@@ -189,10 +238,10 @@ class UserService:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_users
|
||||
(username, password_hash, email, nickname, role, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
(username, password_hash, email, nickname, role, status, email_verified, referred_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(username, password_hash, email, nickname, role, status)
|
||||
(username, password_hash, email, nickname, role, status, email_verified, referred_by)
|
||||
)
|
||||
db.commit()
|
||||
user_id = cur.lastrowid
|
||||
@@ -206,7 +255,7 @@ class UserService:
|
||||
user_id = row['id'] if row else None
|
||||
cur.close()
|
||||
|
||||
logger.info(f"Created user: {username} (id={user_id})")
|
||||
logger.info(f"Created user: {username} (id={user_id}, referred_by={referred_by})")
|
||||
return user_id
|
||||
except Exception as e:
|
||||
logger.error(f"create_user failed: {e}")
|
||||
@@ -251,7 +300,7 @@ class UserService:
|
||||
return False
|
||||
|
||||
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
"""Change user password (requires old password verification)"""
|
||||
"""Change user password (requires old password verification, except for users with no password)"""
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
@@ -266,7 +315,15 @@ class UserService:
|
||||
if not row:
|
||||
return False
|
||||
|
||||
if not self.verify_password(old_password, row['password_hash']):
|
||||
password_hash = row.get('password_hash', '')
|
||||
|
||||
# If user has no password (code-login user), allow setting password without old password
|
||||
if not password_hash or password_hash.strip() == '':
|
||||
logger.info(f"Setting initial password for code-login user: {user_id}")
|
||||
return self.reset_password(user_id, new_password)
|
||||
|
||||
# For users with existing password, verify old password
|
||||
if not self.verify_password(old_password, password_hash):
|
||||
return False
|
||||
|
||||
return self.reset_password(user_id, new_password)
|
||||
@@ -292,6 +349,10 @@ class UserService:
|
||||
logger.error(f"reset_password failed: {e}")
|
||||
return False
|
||||
|
||||
def update_password(self, user_id: int, new_password: str) -> bool:
|
||||
"""Alias for reset_password - update user password without old password verification"""
|
||||
return self.reset_password(user_id, new_password)
|
||||
|
||||
def delete_user(self, user_id: int) -> bool:
|
||||
"""Delete a user"""
|
||||
try:
|
||||
@@ -305,29 +366,37 @@ class UserService:
|
||||
logger.error(f"delete_user failed: {e}")
|
||||
return False
|
||||
|
||||
def list_users(self, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||||
"""List all users with pagination"""
|
||||
def list_users(self, page: int = 1, page_size: int = 20, search: str = None) -> Dict[str, Any]:
|
||||
"""List all users with pagination and optional search"""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Build WHERE clause for search
|
||||
where_clause = ""
|
||||
params = []
|
||||
if search and search.strip():
|
||||
search_term = f"%{search.strip()}%"
|
||||
where_clause = "WHERE username LIKE ? OR email LIKE ? OR nickname LIKE ?"
|
||||
params = [search_term, search_term, search_term]
|
||||
|
||||
# Get total count
|
||||
cur.execute("SELECT COUNT(*) as count FROM qd_users")
|
||||
count_sql = f"SELECT COUNT(*) as count FROM qd_users {where_clause}"
|
||||
cur.execute(count_sql, tuple(params))
|
||||
total = cur.fetchone()['count']
|
||||
|
||||
# Get users
|
||||
cur.execute(
|
||||
"""
|
||||
query_sql = f"""
|
||||
SELECT id, username, email, nickname, avatar, status, role,
|
||||
last_login_at, created_at, updated_at
|
||||
credits, vip_expires_at, last_login_at, created_at, updated_at
|
||||
FROM qd_users
|
||||
{where_clause}
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(page_size, offset)
|
||||
)
|
||||
"""
|
||||
cur.execute(query_sql, tuple(params + [page_size, offset]))
|
||||
users = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
@@ -362,15 +431,18 @@ class UserService:
|
||||
# Create admin using env credentials
|
||||
admin_user = os.getenv('ADMIN_USER', 'admin')
|
||||
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
||||
|
||||
admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com')
|
||||
|
||||
self.create_user({
|
||||
'username': admin_user,
|
||||
'password': admin_password,
|
||||
'email': admin_email,
|
||||
'nickname': 'Administrator',
|
||||
'role': 'admin',
|
||||
'status': 'active'
|
||||
'status': 'active',
|
||||
'email_verified': True # Admin email is pre-verified
|
||||
})
|
||||
logger.info(f"Created admin user: {admin_user}")
|
||||
logger.info(f"Created admin user: {admin_user} ({admin_email})")
|
||||
except Exception as e:
|
||||
logger.error(f"ensure_admin_exists failed: {e}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user