374 lines
12 KiB
Python
374 lines
12 KiB
Python
|
|
"""
|
|||
|
|
NAS Sharing Auth Module - Login flow với OTP modal support
|
|||
|
|
|
|||
|
|
EXTRACTED từ download_link.py, adapted cho modal OTP pattern
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import time
|
|||
|
|
from typing import Optional, Tuple, TYPE_CHECKING
|
|||
|
|
from selenium import webdriver
|
|||
|
|
from selenium.webdriver.common.by import By
|
|||
|
|
|
|||
|
|
if TYPE_CHECKING:
|
|||
|
|
from selenium.webdriver.remote.webelement import WebElement
|
|||
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|||
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_dsm_credentials() -> Tuple[str, str]:
|
|||
|
|
"""
|
|||
|
|
Get DSM credentials from environment
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(username, password) tuple
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
ValueError: If credentials not set in .env.local
|
|||
|
|
"""
|
|||
|
|
username = os.getenv("NAS_USERNAME")
|
|||
|
|
password = os.getenv("NAS_PASSWORD")
|
|||
|
|
|
|||
|
|
if not username or not password:
|
|||
|
|
raise ValueError("NAS_USERNAME and NAS_PASSWORD must be set in .env.local")
|
|||
|
|
|
|||
|
|
return username, password
|
|||
|
|
|
|||
|
|
|
|||
|
|
def perform_login(
|
|||
|
|
driver: webdriver.Chrome,
|
|||
|
|
username: Optional[str] = None,
|
|||
|
|
password: Optional[str] = None,
|
|||
|
|
otp_callback=None
|
|||
|
|
) -> bool:
|
|||
|
|
"""
|
|||
|
|
Perform DSM login with OTP modal support
|
|||
|
|
|
|||
|
|
EXTRACTED từ download_link.py DSMSeleniumLogin.login()
|
|||
|
|
MODIFIED: Dùng callback cho OTP thay vì manual input
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
driver: Selenium WebDriver instance
|
|||
|
|
username: DSM username (default: from env)
|
|||
|
|
password: DSM password (default: from env)
|
|||
|
|
otp_callback: Function() -> Optional[str] to get OTP code from modal
|
|||
|
|
Should return None if timeout/cancelled
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
True if login successful
|
|||
|
|
"""
|
|||
|
|
if username is None or password is None:
|
|||
|
|
username, password = get_dsm_credentials()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Get DSM URL from env
|
|||
|
|
dsm_url = os.getenv("NAS_DSM_URL")
|
|||
|
|
if not dsm_url:
|
|||
|
|
raise ValueError("NAS_DSM_URL must be set in .env.local")
|
|||
|
|
|
|||
|
|
print(f"\n🌐 Đang truy cập: {dsm_url}")
|
|||
|
|
print(" (Trang có thể mất 30-60s để load...)")
|
|||
|
|
|
|||
|
|
# Navigate to DSM
|
|||
|
|
try:
|
|||
|
|
driver.get(dsm_url)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"⚠️ Timeout khi load trang, nhưng tiếp tục thử...")
|
|||
|
|
|
|||
|
|
time.sleep(5)
|
|||
|
|
|
|||
|
|
wait = WebDriverWait(driver, 30)
|
|||
|
|
|
|||
|
|
# === STEP 1: Enter USERNAME ===
|
|||
|
|
print("🔍 BƯỚC 1: Đang tìm form username...")
|
|||
|
|
|
|||
|
|
# NO FALLBACK - Throw error nếu selector không match
|
|||
|
|
username_input = wait.until(
|
|||
|
|
EC.visibility_of_element_located((By.CSS_SELECTOR,
|
|||
|
|
"input#login_username, input[type='text'][name='username'], input.syno-ux-textfield[type='text']"))
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
print(f"📝 Nhập username: {username}")
|
|||
|
|
username_input.click()
|
|||
|
|
time.sleep(0.5)
|
|||
|
|
username_input.clear()
|
|||
|
|
time.sleep(0.5)
|
|||
|
|
username_input.send_keys(username)
|
|||
|
|
time.sleep(1)
|
|||
|
|
|
|||
|
|
# Click Next button
|
|||
|
|
print("🖱️ Tìm nút Next...")
|
|||
|
|
next_button = _find_button(driver, [
|
|||
|
|
"div[syno-id='account-panel-next-btn']", # DSM 7.x
|
|||
|
|
"div.login-btn[role='button']",
|
|||
|
|
"button#login-btn",
|
|||
|
|
"button[type='submit']",
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
if not next_button:
|
|||
|
|
raise RuntimeError("Không tìm thấy nút Next")
|
|||
|
|
|
|||
|
|
print("🖱️ Click nút Next...")
|
|||
|
|
next_button.click()
|
|||
|
|
time.sleep(3)
|
|||
|
|
|
|||
|
|
# === STEP 2: Enter PASSWORD ===
|
|||
|
|
print("\n🔍 BƯỚC 2: Đang tìm form password...")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
password_input = wait.until(
|
|||
|
|
EC.visibility_of_element_located((By.CSS_SELECTOR, "input[syno-id='password']"))
|
|||
|
|
)
|
|||
|
|
except:
|
|||
|
|
try:
|
|||
|
|
password_input = wait.until(
|
|||
|
|
EC.visibility_of_element_located((By.CSS_SELECTOR, "input[type='password'][name='current-password']"))
|
|||
|
|
)
|
|||
|
|
except:
|
|||
|
|
password_input = wait.until(
|
|||
|
|
EC.visibility_of_element_located((By.CSS_SELECTOR, "input[type='password']"))
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
print("🔑 Nhập password...")
|
|||
|
|
password_input.click()
|
|||
|
|
time.sleep(0.5)
|
|||
|
|
password_input.clear()
|
|||
|
|
time.sleep(0.5)
|
|||
|
|
password_input.send_keys(password)
|
|||
|
|
time.sleep(1)
|
|||
|
|
|
|||
|
|
# Tick "Stay signed in" checkbox
|
|||
|
|
print("☑️ Tick checkbox 'Stay signed in'...")
|
|||
|
|
try:
|
|||
|
|
stay_signed_checkbox = driver.find_element(By.CSS_SELECTOR,
|
|||
|
|
"div.login-checkbox input[type='checkbox']")
|
|||
|
|
|
|||
|
|
if not stay_signed_checkbox.is_selected():
|
|||
|
|
checkbox_label = driver.find_element(By.CSS_SELECTOR,
|
|||
|
|
"div.login-checkbox label.box")
|
|||
|
|
checkbox_label.click()
|
|||
|
|
print(" ✅ Đã tick 'Stay signed in'")
|
|||
|
|
time.sleep(0.5)
|
|||
|
|
else:
|
|||
|
|
print(" ℹ️ Checkbox đã được tick sẵn")
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f" ⚠️ Không tìm thấy checkbox (không sao): {e}")
|
|||
|
|
|
|||
|
|
time.sleep(0.5)
|
|||
|
|
|
|||
|
|
# Click Sign In button
|
|||
|
|
print("🖱️ Tìm nút Sign In...")
|
|||
|
|
signin_button = _find_button(driver, [
|
|||
|
|
"div[syno-id='password-panel-next-btn']", # DSM 7.x password panel
|
|||
|
|
"div[syno-id='account-panel-next-btn']",
|
|||
|
|
"div.login-btn[role='button']",
|
|||
|
|
"button#login-btn",
|
|||
|
|
"button[type='submit']",
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
if not signin_button:
|
|||
|
|
raise RuntimeError("Không tìm thấy nút Sign In")
|
|||
|
|
|
|||
|
|
print("🖱️ Click nút Sign In...")
|
|||
|
|
signin_button.click()
|
|||
|
|
time.sleep(3)
|
|||
|
|
|
|||
|
|
# === STEP 3: Handle OTP if needed ===
|
|||
|
|
otp_required = detect_otp_modal(driver)
|
|||
|
|
|
|||
|
|
if otp_required:
|
|||
|
|
print("\n" + "=" * 70)
|
|||
|
|
print("🔐 PHÁT HIỆN YÊU CẦU OTP (2-FACTOR AUTHENTICATION)")
|
|||
|
|
print("=" * 70)
|
|||
|
|
|
|||
|
|
if otp_callback:
|
|||
|
|
print("⏳ Đang đợi OTP từ frontend modal...")
|
|||
|
|
otp_code = otp_callback()
|
|||
|
|
|
|||
|
|
if not otp_code:
|
|||
|
|
print("❌ Không nhận được OTP (timeout hoặc cancelled)")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Submit OTP
|
|||
|
|
print(f"✅ Nhận OTP: {otp_code[:2]}***")
|
|||
|
|
if not submit_otp_code(driver, otp_code):
|
|||
|
|
print("❌ Lỗi submit OTP")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
time.sleep(3)
|
|||
|
|
else:
|
|||
|
|
print("⚠️ Không có OTP callback, bỏ qua...")
|
|||
|
|
return False
|
|||
|
|
else:
|
|||
|
|
print("ℹ️ Không phát hiện yêu cầu OTP")
|
|||
|
|
|
|||
|
|
# === STEP 4: Wait for login success ===
|
|||
|
|
print("⏳ Đang chờ đăng nhập hoàn tất...")
|
|||
|
|
return wait_for_login_success(driver, timeout=15)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"❌ Lỗi trong quá trình đăng nhập: {e}")
|
|||
|
|
import traceback
|
|||
|
|
traceback.print_exc()
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def detect_otp_modal(driver: webdriver.Chrome) -> bool:
|
|||
|
|
"""
|
|||
|
|
Detect if OTP modal is shown
|
|||
|
|
|
|||
|
|
EXTRACTED từ download_link.py logic
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
True if OTP input is visible
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
# Method 1: Find title "Enter verification code"
|
|||
|
|
otp_title = driver.find_element(By.XPATH,
|
|||
|
|
"//*[contains(text(), 'Enter verification code') or contains(text(), 'verification code')]")
|
|||
|
|
if otp_title.is_displayed():
|
|||
|
|
return True
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Method 2: Find input with name='one-time-code'
|
|||
|
|
otp_input = driver.find_element(By.CSS_SELECTOR, "input[name='one-time-code']")
|
|||
|
|
if otp_input.is_displayed():
|
|||
|
|
return True
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Method 3: Find OTP button with syno-id
|
|||
|
|
otp_button = driver.find_element(By.CSS_SELECTOR, "div[syno-id='otp-panel-next-btn']")
|
|||
|
|
if otp_button.is_displayed():
|
|||
|
|
return True
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def submit_otp_code(driver: webdriver.Chrome, otp_code: str) -> bool:
|
|||
|
|
"""
|
|||
|
|
Submit OTP code to form
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
driver: Selenium WebDriver
|
|||
|
|
otp_code: 6-digit OTP code
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
True if submitted successfully
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
# Find OTP input
|
|||
|
|
otp_input = driver.find_element(By.CSS_SELECTOR, "input[name='one-time-code']")
|
|||
|
|
|
|||
|
|
if not otp_input.is_displayed():
|
|||
|
|
print("❌ OTP input không visible")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Clear and enter OTP
|
|||
|
|
otp_input.clear()
|
|||
|
|
otp_input.send_keys(otp_code)
|
|||
|
|
time.sleep(0.5)
|
|||
|
|
|
|||
|
|
# Click submit or press Enter
|
|||
|
|
try:
|
|||
|
|
otp_button = driver.find_element(By.CSS_SELECTOR, "div[syno-id='otp-panel-next-btn']")
|
|||
|
|
otp_button.click()
|
|||
|
|
except:
|
|||
|
|
# Fallback: press Enter
|
|||
|
|
otp_input.send_keys("\n")
|
|||
|
|
|
|||
|
|
print("✅ OTP đã submit")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"❌ Lỗi submit OTP: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def wait_for_login_success(driver: webdriver.Chrome, timeout: int = 15) -> bool:
|
|||
|
|
"""
|
|||
|
|
Wait for login to complete
|
|||
|
|
|
|||
|
|
EXTRACTED từ download_link.py is_logged_in() check
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
driver: Selenium WebDriver
|
|||
|
|
timeout: Max seconds to wait
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
True if "Synology Drive" div detected (indicates successful login)
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
RuntimeError: If login not detected after timeout
|
|||
|
|
"""
|
|||
|
|
for i in range(timeout):
|
|||
|
|
try:
|
|||
|
|
# Check for "Synology Drive" text - only appears after login
|
|||
|
|
elem = driver.find_element(By.XPATH, "//div[contains(text(), 'Synology Drive')]")
|
|||
|
|
if elem is not None:
|
|||
|
|
print("✅ Đăng nhập thành công!")
|
|||
|
|
return True
|
|||
|
|
except Exception as e:
|
|||
|
|
# Log specific error for debugging
|
|||
|
|
if i == 0: # Only log on first attempt
|
|||
|
|
print(f"🔍 Chưa thấy 'Synology Drive', đang đợi... (Error: {type(e).__name__})")
|
|||
|
|
|
|||
|
|
time.sleep(1)
|
|||
|
|
|
|||
|
|
# Timeout - throw error thay vì return False
|
|||
|
|
print("❌ KHÔNG phát hiện đăng nhập thành công sau timeout")
|
|||
|
|
raise RuntimeError(
|
|||
|
|
f"Login verification failed: 'Synology Drive' element not found after {timeout}s. "
|
|||
|
|
f"Login may have failed or page structure changed."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_logged_in(driver: webdriver.Chrome) -> bool:
|
|||
|
|
"""
|
|||
|
|
Quick check if already logged in
|
|||
|
|
|
|||
|
|
EXACT COPY từ download_link.py DSMSeleniumLogin.is_logged_in()
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
True if "Synology Drive" div found
|
|||
|
|
"""
|
|||
|
|
if not driver:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
elem = driver.find_element(By.XPATH, "//div[contains(text(), 'Synology Drive')]")
|
|||
|
|
return elem is not None
|
|||
|
|
except:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _find_button(driver: webdriver.Chrome, selectors: list) -> Optional['WebElement']:
|
|||
|
|
"""
|
|||
|
|
Helper: Find first visible button from selector list
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
driver: Selenium WebDriver
|
|||
|
|
selectors: List of CSS selectors to try
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
WebElement if found, None otherwise
|
|||
|
|
"""
|
|||
|
|
for selector in selectors:
|
|||
|
|
try:
|
|||
|
|
if selector.startswith("//"):
|
|||
|
|
button = driver.find_element(By.XPATH, selector)
|
|||
|
|
else:
|
|||
|
|
button = driver.find_element(By.CSS_SELECTOR, selector)
|
|||
|
|
|
|||
|
|
if button and button.is_displayed():
|
|||
|
|
return button
|
|||
|
|
except:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
return None
|