""" User Service - Multi-user management Handles user CRUD operations, password hashing, and role management. """ import hashlib import time import os from typing import Optional, Dict, Any, List from app.utils.db import get_db_connection from app.utils.logger import get_logger logger = get_logger(__name__) # Try to import bcrypt for secure password hashing try: import bcrypt HAS_BCRYPT = True except ImportError: HAS_BCRYPT = False logger.warning("bcrypt not installed. Using SHA256 for password hashing (less secure).") class UserService: """User management service""" # Available roles (ordered by privilege level) ROLES = ['viewer', 'user', 'manager', 'admin'] # Role permissions mapping ROLE_PERMISSIONS = { 'viewer': ['dashboard', 'view'], 'user': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio'], 'manager': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings'], 'admin': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings', 'user_manage', 'credentials'], } def hash_password(self, password: str) -> str: """Hash password using bcrypt (preferred) or SHA256 (fallback)""" if HAS_BCRYPT: salt = bcrypt.gensalt(rounds=12) return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') else: # Fallback to SHA256 with salt salt = os.urandom(16).hex() hashed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest() return f"sha256${salt}${hashed}" def verify_password(self, password: str, password_hash: str) -> bool: """Verify password against hash""" if password_hash.startswith('$2b$') or password_hash.startswith('$2a$'): # bcrypt hash if HAS_BCRYPT: try: return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8')) except Exception: return False return False elif password_hash.startswith('sha256$'): # SHA256 fallback hash parts = password_hash.split('$') if len(parts) != 3: return False salt = parts[1] stored_hash = parts[2] computed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest() return computed == stored_hash return False def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]: """Get user by ID""" try: with get_db_connection() as db: cur = db.cursor() cur.execute( """ SELECT id, username, email, nickname, avatar, status, role, credits, vip_expires_at, last_login_at, created_at, updated_at FROM qd_users WHERE id = ? """, (user_id,) ) row = cur.fetchone() cur.close() return row except Exception as e: logger.error(f"get_user_by_id failed: {e}") return None def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]: """Get user by username (includes password_hash for auth)""" 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 username = ? """, (username,) ) row = cur.fetchone() cur.close() return row except Exception as e: 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/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 if user.get('status') != 'active': logger.warning(f"Login attempt for disabled user: {username}") return None 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 try: with get_db_connection() as db: cur = db.cursor() cur.execute( "UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", (user['id'],) ) db.commit() affected = cur.rowcount cur.close() if affected == 0: logger.error(f"Failed to update last_login_at: no rows affected for user_id={user['id']}") else: logger.info(f"Updated last_login_at for user_id={user['id']}") except Exception as e: logger.error(f"Failed to update last_login_at for user_id={user.get('id')}: {e}") # Remove password_hash from return value user.pop('password_hash', None) return user def get_token_version(self, user_id: int) -> int: """ 获取用户当前的 token 版本号。 Args: user_id: 用户ID Returns: 当前 token 版本号,默认为 1 """ try: with get_db_connection() as db: cur = db.cursor() cur.execute( "SELECT token_version FROM qd_users WHERE id = ?", (user_id,) ) row = cur.fetchone() cur.close() if row: return int(row.get('token_version') or 1) return 1 except Exception as e: logger.error(f"get_token_version failed: {e}") return 1 def increment_token_version(self, user_id: int) -> int: """ 递增用户的 token 版本号,使旧的 token 失效。 用于实现单一客户端登录(踢出其他设备)。 Args: user_id: 用户ID Returns: 新的 token 版本号 """ try: with get_db_connection() as db: cur = db.cursor() # 递增 token_version cur.execute( """ UPDATE qd_users SET token_version = COALESCE(token_version, 0) + 1, updated_at = NOW() WHERE id = ? """, (user_id,) ) db.commit() # 获取新的 token_version cur.execute( "SELECT token_version FROM qd_users WHERE id = ?", (user_id,) ) row = cur.fetchone() cur.close() new_version = int(row.get('token_version') or 1) if row else 1 logger.info(f"Incremented token_version for user_id={user_id} to {new_version}") return new_version except Exception as e: logger.error(f"increment_token_version failed: {e}") return 1 def create_user(self, data: Dict[str, Any] = None, **kwargs) -> Optional[int]: """ Create a new user. Args: data: dict with user fields, OR use keyword arguments: username: 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'), email_verified: bool (optional, default False), referred_by: int (optional, referrer user ID) Returns: New user ID or None if failed """ # Support both dict and kwargs style if data is None: data = kwargs else: data = {**data, **kwargs} 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") # 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 existing = self.get_user_by_username(username) if existing: raise ValueError("Username already exists") # 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' try: with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_users (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, email_verified, referred_by) ) db.commit() user_id = cur.lastrowid cur.close() # For PostgreSQL, get the ID differently if user_id is None: cur = db.cursor() cur.execute("SELECT id FROM qd_users WHERE username = ?", (username,)) row = cur.fetchone() user_id = row['id'] if row else None cur.close() 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}") raise def update_user(self, user_id: int, data: Dict[str, Any]) -> bool: """ Update user information. Args: user_id: User ID data: Fields to update (email, nickname, avatar, role, status) """ allowed_fields = ['email', 'nickname', 'avatar', 'role', 'status'] updates = [] values = [] for field in allowed_fields: if field in data: value = data[field] if field == 'role' and value not in self.ROLES: continue updates.append(f"{field} = ?") values.append(value) if not updates: return False updates.append("updated_at = NOW()") values.append(user_id) try: with get_db_connection() as db: cur = db.cursor() sql = f"UPDATE qd_users SET {', '.join(updates)} WHERE id = ?" cur.execute(sql, tuple(values)) db.commit() cur.close() return True except Exception as e: logger.error(f"update_user failed: {e}") return False def change_password(self, user_id: int, old_password: str, new_password: str) -> bool: """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 # Get full user with password_hash with get_db_connection() as db: cur = db.cursor() cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() if not row: return False 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) def reset_password(self, user_id: int, new_password: str) -> bool: """Reset user password (admin operation, no old password required)""" if len(new_password) < 6: raise ValueError("Password must be at least 6 characters") password_hash = self.hash_password(new_password) try: with get_db_connection() as db: cur = db.cursor() cur.execute( "UPDATE qd_users SET password_hash = ?, updated_at = NOW() WHERE id = ?", (password_hash, user_id) ) db.commit() cur.close() return True except Exception as e: 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: with get_db_connection() as db: cur = db.cursor() cur.execute("DELETE FROM qd_users WHERE id = ?", (user_id,)) db.commit() cur.close() return True except Exception as e: logger.error(f"delete_user failed: {e}") return False 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 count_sql = f"SELECT COUNT(*) as count FROM qd_users {where_clause}" cur.execute(count_sql, tuple(params)) total = cur.fetchone()['count'] # Get users query_sql = f""" SELECT id, username, email, nickname, avatar, status, role, credits, vip_expires_at, last_login_at, created_at, updated_at FROM qd_users {where_clause} ORDER BY id DESC LIMIT ? OFFSET ? """ cur.execute(query_sql, tuple(params + [page_size, offset])) users = cur.fetchall() cur.close() return { 'items': users, 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size } except Exception as e: logger.error(f"list_users failed: {e}") return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0} def get_user_permissions(self, role: str) -> List[str]: """Get permissions for a role""" return self.ROLE_PERMISSIONS.get(role, self.ROLE_PERMISSIONS['viewer']) def ensure_admin_exists(self): """ Ensure at least one admin user exists. Creates admin using ADMIN_USER/ADMIN_PASSWORD from env if no users exist. """ try: with get_db_connection() as db: cur = db.cursor() cur.execute("SELECT COUNT(*) as count FROM qd_users") count = cur.fetchone()['count'] cur.close() if count == 0: # 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', 'email_verified': True # Admin email is pre-verified }) logger.info(f"Created admin user: {admin_user} ({admin_email})") except Exception as e: logger.error(f"ensure_admin_exists failed: {e}") # Global singleton _user_service = None def get_user_service() -> UserService: """Get UserService singleton""" global _user_service if _user_service is None: _user_service = UserService() return _user_service