juribot-backend / services /user_service.py
entidi2608's picture
update: access in mobile
530a737
from db.mongoDB import mongo_db
from fastapi import HTTPException, Request, Response
import math
from datetime import datetime, timedelta, timezone
from fastapi import status
import logging
import secrets
from bson import ObjectId
from passlib.context import CryptContext
from services.send_email import send_reset_password_email,send_verification_code_email,send_suspicious_activity_email
from schemas.user import LoginRequest
from utils.utils import create_access_token,create_refresh_token
import os
logger = logging.getLogger(__name__)
# Initialize password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
async def get_users(skip: int = 0, limit: int = 100, query: dict = None):
"""
Lấy danh sách người dùng với phân trang và tìm kiếm nâng cao
Args:
skip (int): Số lượng bản ghi bỏ qua (để phân trang)
limit (int): Số lượng bản ghi tối đa trả về
query (dict, optional): Điều kiện tìm kiếm người dùng
Returns:
list: Danh sách người dùng đã được lọc và phân trang
Raises:
HTTPException: Nếu có lỗi xảy ra khi truy vấn cơ sở dữ liệu
"""
try:
# Khởi tạo query nếu chưa có
search_query = query or {}
# Thực hiện truy vấn với phân trang
users_cursor = mongo_db.users.find(
search_query,
{"_id": 0, "password": 0} # Loại bỏ các trường nhạy cảm
).skip(skip).limit(limit)
# Chuyển cursor thành danh sách
user_list = list(users_cursor)
# Thêm log để debug nếu cần
logger.debug(f"Đã lấy {len(user_list)} người dùng với skip={skip}, limit={limit}")
return user_list
except Exception as e:
logger.error(f"Lỗi khi lấy danh sách người dùng: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Lỗi khi lấy danh sách người dùng: {str(e)}"
)
async def count_users(query: dict = None):
"""
Đếm tổng số người dùng thỏa mãn điều kiện tìm kiếm
Args:
query (dict, optional): Điều kiện tìm kiếm người dùng
Returns:
int: Tổng số người dùng
"""
try:
search_query = query or {}
return await mongo_db.users.count_documents(search_query)
except Exception as e:
logger.error(f"Lỗi khi đếm người dùng: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Lỗi khi đếm người dùng: {str(e)}"
)
async def get_paginated_users(
skip: int = 0,
limit: int = 10,
search: str = None,
sort_by: str = "created_at",
sort_order: int = -1
):
"""
Lấy danh sách người dùng với đầy đủ thông tin phân trang và tìm kiếm
Args:
skip (int): Số lượng bản ghi bỏ qua
limit (int): Số lượng bản ghi tối đa trả về
search (str, optional): Từ khóa tìm kiếm (tên, email, v.v.)
sort_by (str): Trường để sắp xếp
sort_order (int): Thứ tự sắp xếp (1: tăng dần, -1: giảm dần)
Returns:
dict: Kết quả phân trang với items và metadata
"""
try:
# Xây dựng query tìm kiếm nếu có từ khóa
query = {}
if search:
# Tìm kiếm theo tên hoặc email
query = {
"$or": [
{"email": {"$regex": search, "$options": "i"}},
{"username": {"$regex": search, "$options": "i"}}
]
}
# Sắp xếp
sort_criteria = [(sort_by, sort_order)]
# Thực hiện truy vấn
users_cursor = mongo_db.users.find(
query,
{"_id": 0, "password": 0} # Loại bỏ các trường nhạy cảm
).sort(sort_criteria).skip(skip).limit(limit)
# Chuyển cursor thành danh sách
user_list = await users_cursor.to_list(length=limit)
# Đếm tổng số bản ghi
total =await mongo_db.users.count_documents(query)
# Tính toán thông tin phân trang
total_pages = math.ceil(total / limit) if limit > 0 else 0
current_page = math.floor(skip / limit) + 1 if limit > 0 else 1
# Trả về kết quả với metadata phân trang
return {
"items": user_list,
"metadata": {
"total": total,
"page": current_page,
"page_size": limit,
"pages": total_pages,
"has_more": current_page < total_pages
}
}
except Exception as e:
logger.error(f"Lỗi khi lấy danh sách người dùng phân trang: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Lỗi khi lấy danh sách người dùng: {str(e)}"
)
async def get_current_user_profile(email: str):
"""
Lấy thông tin profile của người dùng hiện tại
Args:
email (str): Email của người dùng
Returns:
dict: Thông tin profile của người dùng
"""
try:
user =await mongo_db.users.find_one({"email": email}, {"_id": 0, "password": 0})
logger.info(f"check user user_service: {user}")
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Người dùng không tồn tại")
return user
except HTTPException as he:
raise he
except Exception as e:
logger.error(f"Lỗi khi lấy thông tin profile người dùng: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Lỗi khi lấy thông tin profile người dùng: {str(e)}"
)
async def delete_user(user_id: str):
"""
Xóa người dùng theo ID
Args:
user_id (str): ID của người dùng cần xóa
Returns:
bool: True nếu xóa thành công, False nếu không tìm thấy người dùng
"""
try:
result =await mongo_db.users.delete_one({"_id": user_id})
return result.deleted_count > 0
except Exception as e:
logger.error(f"Lỗi khi xóa người dùng: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Lỗi khi xóa người dùng: {str(e)}"
)
async def change_password(email: str, current_password: str, new_password: str):
"""
Đổi mật khẩu của người dùng
Args:
user_id (str): ID của người dùng
current_password (str): Mật khẩu hiện tại
new_password (str): Mật khẩu mới
Returns:
bool: True nếu đổi mật khẩu thành công, False nếu không thành công
"""
try:
user =await mongo_db.users.find_one({"email": email})
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Người dùng không tồn tại")
if not pwd_context.verify(current_password, user["password"]):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Mật khẩu hiện tại không chính xác")
# Hash the new password
hashed_password = pwd_context.hash(new_password)
# Update the password in the database
result =await mongo_db.users.update_one({"email": email}, {"$set": {"password": hashed_password}})
return result.modified_count > 0
except HTTPException as he:
raise he
except Exception as e:
logger.error(f"Lỗi khi đổi mật khẩu: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Lỗi khi đổi mật khẩu"
)
async def reset_password_request(email: str) -> bool:
"""
Yêu cầu đặt lại mật khẩu cho người dùng
Args:
email (str): Địa chỉ email của người dùng
Returns:
bool: True nếu yêu cầu thành công, False nếu không tìm thấy người dùng
Raises:
HTTPException: Nếu có lỗi trong quá trình xử lý
"""
try:
# Check if user exists
user = await mongo_db.users.find_one({"email": email.lower()})
if not user:
# Return True even if user not found to prevent email enumeration
return True
# Check for rate limiting (e.g., max 3 requests per hour)
reset_requests =await mongo_db.users.count_documents({
"email": email.lower(),
"reset_password_timestamp": {
"$gte": datetime.now(tz=timezone.utc) - timedelta(hours=1)
}
})
if reset_requests >= 3:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Quá nhiều yêu cầu đặt lại mật khẩu. Vui lòng thử lại sau."
)
# Generate secure reset token
code = str(secrets.randbelow(1000000)).zfill(6) # e.g., '123456'
expiry = datetime.now() + timedelta(minutes=10)
# Store reset token and timestamp in database
await mongo_db.users.update_one(
{"_id": ObjectId(user["_id"])},
{
"$set": {
"reset_password_code": code,
"reset_password_timestamp": datetime.now(tz=timezone.utc),
"reset_password_expiry": expiry,
}
}
)
# Send reset password email (uncomment and implement actual email sending)
await send_reset_password_email(
email=email,
code=code,
expiry=15
)
return True
except HTTPException as he:
raise he
except Exception as e:
logger.error(f"Lỗi khi yêu cầu đặt lại mật khẩu: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Lỗi hệ thống. Vui lòng thử lại sau."
)
async def reset_password(code: str, newPassword: str) -> bool:
"""
Hoàn tất đặt lại mật khẩu với token và mật khẩu mới.
Args:
code (str): code đặt lại mật khẩu.
newPassword (str): Mật khẩu mới của người dùng.
Returns:
bool: True nếu đặt lại mật khẩu thành công, False nếu token không hợp lệ hoặc đã hết hạn.
Raises:
HTTPException: Nếu có lỗi hệ thống trong quá trình xử lý.
"""
try:
# Find user by reset token
user =await mongo_db.users.find_one({"reset_password_code": code})
if not user:
logger.warning(f"Code không hợp lệ: {code}")
return False
# Check if token is expired
expiry = user.get("reset_password_expiry")
if not expiry or expiry < datetime.now():
logger.warning(f"Code đã hết hạn: {code}")
return False
# Validate new password (basic example, can add more rules)
if len(newPassword) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mật khẩu mới phải có ít nhất 8 ký tự"
)
# Hash the new password
hashed_password = pwd_context.hash(newPassword)
# Update user's password and clear reset token
result =await mongo_db.users.update_one(
{"_id": ObjectId(user["_id"])},
{
"$set": {"password": hashed_password},
"$unset": {
"reset_password_code": "",
"reset_password_expiry": "",
"reset_password_timestamp": ""
}
}
)
if result.modified_count != 1:
logger.error(f"Không thể cập nhật mật khẩu cho user: {user['_id']}")
return False
logger.info(f"Đặt lại mật khẩu thành công cho email: {user['email']}")
return True
except Exception as e:
logger.error(f"Lỗi khi đặt lại mật khẩu: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Lỗi hệ thống khi đặt lại mật khẩu. Vui lòng thử lại sau."
)
async def generate_and_store_verification_code(email: str) -> bool:
"""
Tạo và lưu mã xác minh đăng nhập vào cơ sở dữ liệu.
Args:
email (str): Địa chỉ email của người dùng.
Returns:
bool: True nếu lưu mã thành công.
Raises:
HTTPException: Nếu có lỗi hệ thống.
"""
try:
# Generate 6-digit code
code = str(secrets.randbelow(1000000)).zfill(6) # e.g., '123456'
expiry = datetime.now() + timedelta(minutes=10)
# Store code and expiry in database
result =await mongo_db.users.update_one(
{"email": email.lower()},
{
"$set": {
"login_verification_code": code,
"login_verification_expiry": expiry,
"login_verification_timestamp": datetime.now()
}
}
)
if result.modified_count != 1:
logger.error(f"Không thể lưu mã xác minh cho email: {email}")
return False
# Send verification email
await send_verification_code_email(email, code)
return True
except Exception as e:
logger.error(f"Lỗi khi tạo mã xác minh: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Lỗi hệ thống khi tạo mã xác minh. Vui lòng thử lại sau."
)
async def verify_login_code(email: str, code: str, res: Response): # Bỏ kiểu trả về dict, để FastAPI tự suy luận hoặc dùng Pydantic model
"""
Xác minh mã đăng nhập, set access và refresh tokens vào HttpOnly cookies,
và trả về thông tin người dùng.
Args:
email (str): Địa chỉ email của người dùng.
code (str): Mã xác minh do người dùng cung cấp.
res (Response): Đối tượng Response của FastAPI để set cookies.
Returns:
dict: Thông tin người dùng và thông báo thành công.
Raises:
HTTPException: Nếu mã không hợp lệ, đã hết hạn hoặc có lỗi hệ thống.
"""
try:
user = await mongo_db.users.find_one({ # Sử dụng await nếu mongo_db.users là async (ví dụ Motor)
"email": email.lower(),
"login_verification_code": code
})
# Nếu mongo_db.users là đồng bộ (ví dụ PyMongo)
# user = mongo_db.users.find_one({ ... })
if not user:
logger.warning(f"Mã xác minh không hợp lệ: {code} cho email: {email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mã xác minh không hợp lệ hoặc đã được sử dụng." # Thêm "đã được sử dụng"
)
expiry = user.get("login_verification_expiry")
if not expiry or expiry < datetime.now(expiry.tzinfo if expiry.tzinfo else None): # So sánh aware với aware, naive với naive
logger.warning(f"Mã xác minh đã hết hạn cho email: {email}")
# Xóa mã đã hết hạn để tránh sử dụng lại
await mongo_db.users.update_one(
{"_id": ObjectId(user["_id"])},
{
"$unset": {
"login_verification_code": "",
"login_verification_expiry": "",
"login_verification_timestamp": ""
}
}
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mã xác minh đã hết hạn."
)
try:
token_expire_minutes_str = os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60") # Default là chuỗi "60"
ACCESS_TOKEN_EXPIRE_MINUTES_INT = int(token_expire_minutes_str)
if ACCESS_TOKEN_EXPIRE_MINUTES_INT <= 0:
logger.warning(f"Giá trị ACCESS_TOKEN_EXPIRE_MINUTES ('{token_expire_minutes_str}') không hợp lệ, sử dụng mặc định 60 phút.")
ACCESS_TOKEN_EXPIRE_MINUTES_INT = 60
except ValueError:
logger.error(f"Không thể chuyển đổi ACCESS_TOKEN_EXPIRE_MINUTES ('{os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES')}') sang số nguyên. Sử dụng mặc định 60 phút.")
ACCESS_TOKEN_EXPIRE_MINUTES_INT = 60
logger.info(f"Sử dụng thời gian hết hạn cho access token: {ACCESS_TOKEN_EXPIRE_MINUTES_INT} phút.")
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES_INT) # Sử dụng biến đã chuyển đổi
refresh_token_expires = timedelta(days=7) # Sử dụng biến đã chuyển đổi
access_token = create_access_token(
data={"sub": email.lower()}, expires_delta=access_token_expires
) # Giả sử create_access_token chấp nhận expires_delta
IS_PRODUCTION = os.getenv("APP_ENVIRONMENT", "development").lower() == "production"
# Generate refresh token (logic của bạn đã có)
refresh_token_value = await create_refresh_token(email.lower()) # Đảm bảo email là lowercase
# --- SET COOKIES ---
# Môi trường (ví dụ: "development", "production")
# Bạn nên có một biến môi trường để xác định điều này
# IS_PRODUCTION = os.getenv("ENVIRONMENT") == "production"
# Tạm đặt là True, bạn nên lấy từ biến môi trường
max_age_seconds = int(access_token_expires.total_seconds())
logger.info(f"Setting access_token_cookie with Max-Age: {max_age_seconds} seconds")
# Access Token Cookie
res.set_cookie(
key="access_token_cookie", # Tên cookie cho access token
value=access_token,
max_age=int(access_token_expires.total_seconds()), # Thời gian sống bằng access token
httponly=True,
secure=IS_PRODUCTION, # True trong production (HTTPS), False khi dev với HTTP
samesite="none", # Hoặc "strict"
path="/",
)
# Refresh Token Cookie (logic của bạn đã có, điều chỉnh secure)
res.set_cookie(
key="refresh_token",
value=refresh_token_value,
max_age=int(refresh_token_expires.total_seconds()),
httponly=True,
secure=IS_PRODUCTION, # True trong production, False khi dev với HTTP
samesite="none",
path="/api/user/refresh-token",
)
# --------------------
# Clear verification code và cập nhật last_login
await mongo_db.users.update_one( # Sử dụng await nếu là async
{"_id": ObjectId(user["_id"])},
{
"$unset": {
"login_verification_code": "",
"login_verification_expiry": "",
"login_verification_timestamp": ""
},
"$set": {
"last_login": datetime.now(timezone.utc if expiry.tzinfo else None), # Giữ timezone nhất quán
"login_type": "email", # Giả sử đây là đăng nhập bằng email
}
}
)
# if update_result.modified_count == 0:
# logger.warning(f"Không thể cập nhật user sau khi xác minh: {email}")
# Cân nhắc có nên raise lỗi ở đây không, hoặc chỉ log
logger.info(f"Đăng nhập thành công cho email: {email}. Tokens đã được set vào cookies.")
# Response trả về không còn chứa accessToken
return {
# "accessToken": access_token, # KHÔNG TRẢ VỀ ACCESS TOKEN TRONG BODY NỮA
"user": {
"email": email.lower(),
"username": user.get("username", email.lower().split('@')[0]), # Cung cấp username mặc định nếu không có
"role": user.get("role", "user"),
"avatar_url": user.get("avatar_url", None), # Hoặc một avatar mặc định,
"login_type": user.get("login_type", "email") # Giả sử đây là đăng nhập bằng email
},
"message": "Đăng nhập thành công. Tokens đã được lưu trữ an toàn."
}
except HTTPException as he:
# Ghi log chi tiết hơn cho HTTPException nếu cần
# logger.error(f"HTTPException trong verify_login_code cho {email}: {he.detail}", exc_info=not (he.status_code < 500))
raise he
except Exception as e:
logger.error(f"Lỗi hệ thống khi xác minh mã đăng nhập cho {email}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Lỗi hệ thống, vui lòng thử lại sau."
)
async def authenticate_user(request: LoginRequest) -> dict:
"""
Xác thực người dùng dựa trên email và mật khẩu.
Args:
request (LoginRequest): Yêu cầu đăng nhập chứa email và mật khẩu.
Returns:
dict: Thông tin người dùng nếu xác thực thành công.
Raises:
HTTPException: Nếu thông tin đăng nhập không hợp lệ.
"""
user = await mongo_db.users.find_one({"email": request.email.lower()})
if not user or not pwd_context.verify(request.password, user["password"]):
logger.warning(f"Xác thực thất bại cho email: {request.email}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email hoặc mật khẩu không đúng",
headers={"WWW-Authenticate": "Bearer"}
)
return user
async def refresh_access_token(req: Request , res: Response ) -> dict:
"""
Làm mới access token dựa trên refresh token từ cookie.
Args:
req (Request): FastAPI request object chứa cookie.
Returns:
JSONResponse: Phản hồi chứa access_token, refresh_token, username, email, role và set cookie mới.
Raises:
HTTPException: Nếu refresh token không hợp lệ, đã hết hạn hoặc có lỗi hệ thống.
"""
try:
# Retrieve refresh token from cookie
refresh_token = req.cookies.get("refresh_token")
if not refresh_token:
logger.warning("Không tìm thấy refresh token trong cookie")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Không tìm thấy refresh token",
headers={"WWW-Authenticate": "Bearer"}
)
# Find user by refresh token in database
user =await mongo_db.users.find_one({"refresh_token": refresh_token})
if not user:
logger.warning(f"Refresh token không hợp lệ: {refresh_token}")
await send_suspicious_activity_email(user.get("email", "unknown") if user else "unknown")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token không hợp lệ",
headers={"WWW-Authenticate": "Bearer"}
)
# Check if refresh token is expired
expiry = user.get("refresh_token_expiry")
if not expiry or expiry < datetime.now():
logger.warning(f"Refresh token đã hết hạn cho email: {user['email']}")
await send_suspicious_activity_email(user["email"])
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token đã hết hạn",
headers={"WWW-Authenticate": "Bearer"}
)
email = user.get('email')
# Generate new access token
token_expire_minutes_str = os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60") # Default là chuỗi "60"
ACCESS_TOKEN_EXPIRE_MINUTES_INT = int(token_expire_minutes_str)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES_INT) # Sử dụng biến đã chuyển đổi
access_token = create_access_token(
data={"sub": email.lower()}, expires_delta=access_token_expires
) # Giả sử create_access_token chấp nhận expires_delta
IS_PRODUCTION = os.getenv("APP_ENVIRONMENT", "development").lower() == "production"
# Generate new refresh token (rotation)
new_refresh_token = await create_refresh_token(email)
# Update database with new refresh token
await mongo_db.users.update_one(
{"_id": user["_id"]},
{
"$set": {
"refresh_token": new_refresh_token,
"refresh_token_expiry": datetime.now() + timedelta(days=7),
"last_login": datetime.now(timezone.utc)
}
}
)
# Set access token mới vào cookie
res.set_cookie(
key="access_token_cookie", # Đảm bảo tên này khớp với client mong đợi
value=access_token,
max_age= access_token_expires, # tính bằng giây
httponly=True,
secure=IS_PRODUCTION,
samesite='none', # type: ignore
path='/',
)
# Set new refresh token in cookie
res.set_cookie(
key="refresh_token",
value=new_refresh_token,
max_age=7 * 24 * 60 * 60, # 7 days
httponly=True,
secure=IS_PRODUCTION, # Set to False for local development
samesite="none", # CSRF protection
path="/api/user/refresh-token",
)
logger.info(f"Access token refreshed for email: {user['email']}")
return {
"success": True,
"message": "Token đã được làm mới thành công.",
"user": {
"username": user['username'], # Lấy username, fallback về email
"email": user['email'],
"role": user['role'],
"avatar_url": user['avatar_url']
}
# Không cần trả về token trong body nếu chúng đã được set trong HttpOnly cookies
}
except HTTPException as he:
raise he
except Exception as e:
logger.error(f"Lỗi khi làm mới access token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Lỗi hệ thống khi làm mới access token"
)
async def verify_forgot_password_code(email: str, code: str) -> dict:
try:
# Find user by email and code
user =await mongo_db.users.find_one({
"email": email.lower(),
"reset_password_code": code
})
if not user:
logger.warning(f"Mã xác minh không hợp lệ: {code}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mã xác minh không hợp lệ"
)
# Check if code is expired
expiry = user.get("reset_password_expiry")
if not expiry or expiry < datetime.now():
logger.warning(f"Mã xác minh đã hết hạn cho email: {email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mã xác minh đã hết hạn"
)
return True
except HTTPException as he:
raise he
except Exception as e:
logger.error(f"Lỗi khi xác minh mã quen mat khau: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Lỗi hệ thống khi xác minh mã quen mat khau."
)