Files
DinQuant/backend_api_python/app/services/user_service.py
T

387 lines
14 KiB
Python

"""
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,
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 authenticate(self, username: str, password: str) -> Optional[Dict[str, Any]]:
"""
Authenticate user with username and password.
Returns user info (without password_hash) if successful, None otherwise.
"""
user = self.get_user_by_username(username)
if not user:
return None
if user.get('status') != 'active':
logger.warning(f"Login attempt for disabled user: {username}")
return None
if not self.verify_password(password, user.get('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()
cur.close()
except Exception as e:
logger.warning(f"Failed to update last_login_at: {e}")
# Remove password_hash from return value
user.pop('password_hash', None)
return user
def create_user(self, data: Dict[str, Any]) -> Optional[int]:
"""
Create a new user.
Args:
data: {
username: str (required),
password: str (required),
email: str (optional),
nickname: str (optional),
role: str (optional, default 'user'),
status: str (optional, default 'active')
}
Returns:
New user ID or None if failed
"""
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not username or not password:
raise ValueError("Username and password are required")
if len(username) < 3 or len(username) > 50:
raise ValueError("Username must be 3-50 characters")
if 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")
password_hash = self.hash_password(password)
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')
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, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
""",
(username, password_hash, email, nickname, role, status)
)
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})")
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)"""
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
if not self.verify_password(old_password, row['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 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) -> Dict[str, Any]:
"""List all users with pagination"""
offset = (page - 1) * page_size
try:
with get_db_connection() as db:
cur = db.cursor()
# Get total count
cur.execute("SELECT COUNT(*) as count FROM qd_users")
total = cur.fetchone()['count']
# Get users
cur.execute(
"""
SELECT id, username, email, nickname, avatar, status, role,
last_login_at, created_at, updated_at
FROM qd_users
ORDER BY id DESC
LIMIT ? OFFSET ?
""",
(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')
self.create_user({
'username': admin_user,
'password': admin_password,
'nickname': 'Administrator',
'role': 'admin',
'status': 'active'
})
logger.info(f"Created admin user: {admin_user}")
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