auth_service.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
  1. """
  2. 认证服务
  3. """
  4. import secrets
  5. import hashlib
  6. import json
  7. import bcrypt
  8. from datetime import datetime, timedelta
  9. from typing import Optional, Tuple
  10. from sqlalchemy.orm import Session
  11. from app.models.user import User
  12. from app.models.system_config import SystemConfig
  13. from app.utils.timezone import beijing_now_naive
  14. from app.models.session import Session as SessionModel
  15. from app.models.card import Card
  16. from app.models.software import Software
  17. from app.models.user_software_subscription import UserSoftwareSubscription
  18. from app.models.user_steam_account_limit import UserSteamAccountLimit
  19. from app.models.login_attempt import LoginAttempt
  20. from app.models.login_attempt_his import LoginAttemptHis
  21. from app.models.password_reset_request import PasswordResetRequest
  22. from app.utils.machine_code import raw_machine_info_to_machine_code
  23. from app.core.logger import logger
  24. # bcrypt 最大支持 72 字节,超过会报错,统一在哈希前截断
  25. BCRYPT_MAX_PASSWORD_BYTES = 72
  26. # 默认软件代码
  27. DEFAULT_SOFTWARE_CODE = "buff-xp-steam-sh"
  28. # 默认Session有效期(秒)
  29. DEFAULT_SESSION_EXPIRY = 86400 # 24小时
  30. # 登录失败配置
  31. MAX_LOGIN_ATTEMPTS = 5 # 最大失败次数
  32. LOGIN_LOCKOUT_MINUTES = 60 # 锁定时间(分钟)
  33. def hash_password(password: str) -> str:
  34. """密码加密(bcrypt,传入前截断至 72 字节避免库报错)"""
  35. raw = password.encode("utf-8")
  36. if len(raw) > BCRYPT_MAX_PASSWORD_BYTES:
  37. raw = raw[:BCRYPT_MAX_PASSWORD_BYTES]
  38. return bcrypt.hashpw(raw, bcrypt.gensalt()).decode("utf-8")
  39. def verify_password(plain_password: str, hashed_password: str) -> bool:
  40. """密码验证"""
  41. raw = plain_password.encode("utf-8")
  42. if len(raw) > BCRYPT_MAX_PASSWORD_BYTES:
  43. raw = raw[:BCRYPT_MAX_PASSWORD_BYTES]
  44. try:
  45. return bcrypt.checkpw(raw, hashed_password.encode("utf-8"))
  46. except Exception:
  47. return False
  48. def generate_machine_code(disk_serial: str, mac_address: str) -> str:
  49. """生成机器码 (MD5)"""
  50. # 组合机器特征信息
  51. combined = f"{disk_serial}:{mac_address}"
  52. # MD5哈希
  53. return hashlib.md5(combined.encode('utf-8')).hexdigest()
  54. def generate_session_id() -> str:
  55. """生成会话ID"""
  56. return secrets.token_hex(32)
  57. class AuthService:
  58. """认证服务"""
  59. def __init__(self, db: Session):
  60. self.db = db
  61. def _get_int_config(self, config_key: str, default: int) -> int:
  62. """
  63. 从 system_config 表获取整型配置,app_name 包含 backend-client,读取失败时返回默认值。
  64. """
  65. try:
  66. cfg = (
  67. self.db.query(SystemConfig)
  68. .filter(
  69. SystemConfig.config_key == config_key,
  70. SystemConfig.app_name.contains("backend-client"),
  71. SystemConfig.is_deleted == 0,
  72. )
  73. .first()
  74. )
  75. if not cfg or cfg.config_value is None:
  76. return default
  77. return int(str(cfg.config_value).strip())
  78. except Exception:
  79. return default
  80. def _get_software_id(self, software_code: str):
  81. """根据 software_code 解析 software_id,无效或未传则用默认软件"""
  82. code = (software_code or "").strip() or DEFAULT_SOFTWARE_CODE
  83. sw = self.db.query(Software).filter(
  84. Software.code == code,
  85. Software.is_deleted == 0
  86. ).first()
  87. return sw.id if sw else None
  88. def _get_subscription_expire(self, user_id: int, software_code: str) -> Tuple[Optional[datetime], Optional[int]]:
  89. """
  90. 获取用户在某软件下的到期时间。优先读 user_software_subscription,兼容旧数据读 user.expire_time。
  91. 返回 (expire_time, software_id)。
  92. """
  93. software_id = self._get_software_id(software_code)
  94. if software_id is None:
  95. return None, None
  96. sub = self.db.query(UserSoftwareSubscription).filter(
  97. UserSoftwareSubscription.user_id == user_id,
  98. UserSoftwareSubscription.software_id == software_id,
  99. UserSoftwareSubscription.is_deleted == 0
  100. ).first()
  101. if sub and sub.expire_time is not None:
  102. return sub.expire_time, software_id
  103. user = self.db.query(User).filter(User.id == user_id, User.is_deleted == 0).first()
  104. if user and user.expire_time is not None:
  105. return user.expire_time, software_id
  106. return None, software_id
  107. def _check_account_locked(self, username: str) -> Tuple[bool, str]:
  108. """
  109. 检查账号是否被锁定
  110. Returns:
  111. (is_locked, message)
  112. """
  113. # 获取该用户名最近的登录失败记录
  114. lockout_time = beijing_now_naive() - timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
  115. recent_failures = self.db.query(LoginAttempt).filter(
  116. LoginAttempt.username == username,
  117. LoginAttempt.attempted_at > lockout_time,
  118. LoginAttempt.success == 0
  119. ).count()
  120. if recent_failures >= MAX_LOGIN_ATTEMPTS:
  121. # 获取最早一次失败的时间
  122. earliest_failure = self.db.query(LoginAttempt).filter(
  123. LoginAttempt.username == username,
  124. LoginAttempt.attempted_at > lockout_time,
  125. LoginAttempt.success == 0
  126. ).order_by(LoginAttempt.attempted_at.asc()).first()
  127. if earliest_failure:
  128. # 计算解锁时间
  129. unlock_time = earliest_failure.attempted_at + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
  130. remaining_seconds = int((unlock_time - beijing_now_naive()).total_seconds())
  131. if remaining_seconds > 0:
  132. minutes_left = remaining_seconds // 60
  133. seconds_left = remaining_seconds % 60
  134. return True, f"账号已锁定,请{minutes_left}分钟{seconds_left}秒后重试"
  135. return True, "账号已锁定,请稍后重试"
  136. return False, ""
  137. def _record_login_attempt(self, username: str, success: bool, machine_code: str = None,
  138. ip_address: str = None, user_agent: str = None,
  139. failure_reason: str = None):
  140. """记录登录尝试。主表 login_attempts 必写;历史表 login_attempts_his 可选(缺失时仅打日志)。"""
  141. try:
  142. attempt = LoginAttempt(
  143. username=username,
  144. success=success,
  145. machine_code=machine_code,
  146. ip_address=ip_address,
  147. user_agent=user_agent,
  148. failure_reason=failure_reason
  149. )
  150. self.db.add(attempt)
  151. self.db.commit()
  152. except Exception as e:
  153. self.db.rollback()
  154. logger.exception(f"记录登录尝试(主表)失败: {e}")
  155. return
  156. try:
  157. attempt_data = {
  158. "username": username,
  159. "success": success,
  160. "machine_code": machine_code,
  161. "ip_address": ip_address,
  162. "user_agent": user_agent,
  163. "failure_reason": failure_reason
  164. }
  165. attempt_his = LoginAttemptHis(
  166. username=username,
  167. success=success,
  168. machine_code=machine_code,
  169. ip_address=ip_address,
  170. user_agent=user_agent,
  171. failure_reason=failure_reason,
  172. action_type="INSERT",
  173. changed_fields=json.dumps(attempt_data, ensure_ascii=False),
  174. operator="system"
  175. )
  176. self.db.add(attempt_his)
  177. self.db.commit()
  178. except Exception as e:
  179. self.db.rollback()
  180. logger.warning(f"记录登录尝试(历史表)失败,已忽略: {e}")
  181. try:
  182. # 先写历史表,再改/删:将即将删除的登录尝试写入 login_attempts_his
  183. old_attempts_list = self.db.query(LoginAttempt).filter(
  184. LoginAttempt.attempted_at < beijing_now_naive() - timedelta(days=30)
  185. ).all()
  186. for a in old_attempts_list:
  187. self.db.add(LoginAttemptHis(
  188. username=a.username,
  189. success=a.success,
  190. machine_code=a.machine_code,
  191. ip_address=a.ip_address,
  192. user_agent=a.user_agent,
  193. failure_reason=a.failure_reason,
  194. attempted_at=a.attempted_at,
  195. action_type="DELETE",
  196. operator="system",
  197. ))
  198. if old_attempts_list:
  199. self.db.query(LoginAttempt).filter(
  200. LoginAttempt.attempted_at < beijing_now_naive() - timedelta(days=30)
  201. ).delete()
  202. self.db.commit()
  203. except Exception as e:
  204. self.db.rollback()
  205. logger.warning(f"清理过期登录记录失败: {e}")
  206. try:
  207. old_history = self.db.query(LoginAttemptHis).filter(
  208. LoginAttemptHis.created_at < beijing_now_naive() - timedelta(days=90)
  209. ).delete()
  210. if old_history > 0:
  211. self.db.commit()
  212. except Exception as e:
  213. self.db.rollback()
  214. logger.warning(f"清理登录历史记录失败: {e}")
  215. def _clear_login_attempts(self, username: str):
  216. """清除登录失败记录 (登录成功时调用)"""
  217. try:
  218. # 先写历史表,再改/删:将即将删除的失败尝试写入 login_attempts_his
  219. attempts_to_clear = self.db.query(LoginAttempt).filter(
  220. LoginAttempt.username == username,
  221. LoginAttempt.success == 0
  222. ).all()
  223. for a in attempts_to_clear:
  224. self.db.add(LoginAttemptHis(
  225. username=a.username,
  226. success=a.success,
  227. machine_code=a.machine_code,
  228. ip_address=a.ip_address,
  229. user_agent=a.user_agent,
  230. failure_reason=a.failure_reason,
  231. attempted_at=a.attempted_at,
  232. action_type="DELETE",
  233. operator="system",
  234. ))
  235. self.db.query(LoginAttempt).filter(
  236. LoginAttempt.username == username,
  237. LoginAttempt.success == 0
  238. ).delete()
  239. self.db.commit()
  240. except Exception as e:
  241. self.db.rollback()
  242. logger.exception(f"清除登录记录失败: {e}")
  243. def register(self, username: str, password: str, email: str = None,
  244. card_code: str = None, machine_code: str = None,
  245. software_code: str = None) -> Tuple[bool, str, dict]:
  246. """
  247. 用户注册。支持多软件:按 software_code 绑定到对应软件,卡密/到期写入 user_software_subscription。
  248. Args:
  249. username: 用户名
  250. password: 密码
  251. email: 邮箱(可选)
  252. card_code: 卡密代码(可选,注册时激活)
  253. machine_code: 机器码(可选)
  254. software_code: 软件特征码(可选,默认 buff-xp-steam-sh)
  255. Returns:
  256. (success, message, user_info)
  257. """
  258. try:
  259. # 检查用户名是否已存在
  260. existing_user = self.db.query(User).filter(
  261. User.username == username,
  262. User.is_deleted == 0
  263. ).first()
  264. if existing_user:
  265. return False, "用户名已存在", {}
  266. # 检查邮箱是否已存在(如果提供了邮箱)
  267. if email:
  268. existing_email = self.db.query(User).filter(
  269. User.email == email,
  270. User.is_deleted == 0
  271. ).first()
  272. if existing_email:
  273. return False, "邮箱已被注册", {}
  274. sw_code = (software_code or "").strip() or DEFAULT_SOFTWARE_CODE
  275. software = self.db.query(Software).filter(
  276. Software.code == sw_code,
  277. Software.is_active == 1,
  278. Software.is_deleted == 0
  279. ).first()
  280. if not software:
  281. return False, "软件不存在或已禁用", {}
  282. expire_time = None
  283. card = None
  284. # 如果提供了卡密,检查并激活(卡密必须属于当前软件)
  285. if card_code:
  286. card = self.db.query(Card).filter(
  287. Card.card_code == card_code,
  288. Card.is_deleted == 0
  289. ).first()
  290. if not card:
  291. return False, "卡密不存在", {}
  292. if card.status == 1:
  293. return False, "卡密已被使用", {}
  294. if card.status == 2:
  295. return False, "卡密已过期", {}
  296. if card.software_code != sw_code:
  297. return False, "卡密类型不匹配", {}
  298. card.status = 1
  299. card.user_id = None
  300. card.activated_at = beijing_now_naive()
  301. card.expire_time = beijing_now_naive() + timedelta(days=card.days)
  302. expire_time = card.expire_time
  303. # 创建用户(用户表不再存 card_id/expire_time,由订阅表维护)
  304. hashed_password = hash_password(password)
  305. user = User(
  306. username=username,
  307. password=hashed_password,
  308. email=email,
  309. machine_code=machine_code,
  310. card_id=card.id if card else None,
  311. expire_time=expire_time, # 兼容旧逻辑,同时写订阅表
  312. status=1
  313. )
  314. self.db.add(user)
  315. self.db.flush()
  316. if card:
  317. card.user_id = user.id
  318. # 用户-软件订阅:多软件共用用户表,按软件存卡密/到期
  319. sub = UserSoftwareSubscription(
  320. user_id=user.id,
  321. software_id=software.id,
  322. card_id=card.id if card else None,
  323. expire_time=expire_time,
  324. is_deleted=0
  325. )
  326. self.db.add(sub)
  327. # 新用户默认 Steam 账号绑定数量上限(默认 3,管理员可后续修改)
  328. limit_row = UserSteamAccountLimit(user_id=user.id, max_steam_accounts=3)
  329. self.db.add(limit_row)
  330. self.db.commit()
  331. logger.info(f"用户注册成功: {username}, software={sw_code}")
  332. return True, "注册成功", {
  333. "user_id": user.id,
  334. "username": user.username,
  335. "expire_time": user.expire_time.isoformat() if user.expire_time else None,
  336. "days_remaining": (user.expire_time - beijing_now_naive()).days if user.expire_time else 0
  337. }
  338. except Exception as e:
  339. self.db.rollback()
  340. logger.exception(f"用户注册失败: {e}")
  341. return False, f"注册失败: {str(e)}", {}
  342. def forgot_password(self, username: str, email: str) -> Tuple[bool, str]:
  343. """
  344. 找回密码:校验用户名与邮箱匹配后写入工单表,由外部脚本扫描并发邮件。
  345. 无论是否匹配均返回统一成功提示,避免泄露用户是否存在。
  346. """
  347. try:
  348. username = (username or "").strip()
  349. email = (email or "").strip()
  350. if not username or not email:
  351. return False, "请输入用户名和邮箱"
  352. user = self.db.query(User).filter(
  353. User.username == username,
  354. User.is_deleted == 0
  355. ).first()
  356. if not user:
  357. logger.info(f"找回密码:用户不存在 {username}")
  358. return True, "若该用户名与邮箱匹配,我们将把新密码发送至您的邮箱,请查收。"
  359. if not user.email or user.email.strip().lower() != email.strip().lower():
  360. logger.info(f"找回密码:邮箱不匹配 username={username}")
  361. return True, "若该用户名与邮箱匹配,我们将把新密码发送至您的邮箱,请查收。"
  362. # 写入工单表,状态 pending
  363. req = PasswordResetRequest(
  364. user_id=user.id,
  365. username=user.username,
  366. email=user.email,
  367. status="pending"
  368. )
  369. self.db.add(req)
  370. self.db.commit()
  371. logger.info(f"找回密码工单已创建: user_id={user.id}, username={username}")
  372. return True, "若该用户名与邮箱匹配,我们将把新密码发送至您的邮箱,请查收。"
  373. except Exception as e:
  374. self.db.rollback()
  375. logger.exception(f"找回密码申请失败: {e}")
  376. return False, "系统异常,请稍后重试;若持续发生请联系客服。"
  377. def login(self, username: str, password: str, machine_code: str,
  378. software_code: str = None) -> Tuple[bool, str, dict]:
  379. """
  380. 用户登录。按 software_code 校验该软件下的订阅到期时间。
  381. Args:
  382. username: 用户名
  383. password: 密码
  384. machine_code: 机器码
  385. software_code: 软件特征码(可选,默认 buff-xp-steam-sh)
  386. Returns:
  387. (success, message, data)
  388. """
  389. try:
  390. # 1. 检查账号是否被锁定
  391. is_locked, lock_message = self._check_account_locked(username)
  392. if is_locked:
  393. logger.warning(f"登录被拒绝,账号已锁定: {username}")
  394. return False, lock_message, {}
  395. # 2. 查找用户
  396. user = self.db.query(User).filter(
  397. User.username == username,
  398. User.is_deleted == 0
  399. ).first()
  400. if not user:
  401. self._record_login_attempt(username, success=False, machine_code=machine_code)
  402. logger.warning(f"登录失败,用户不存在: {username}")
  403. return False, "用户名或密码错误", {}
  404. # 3. 验证密码
  405. if not verify_password(password, user.password):
  406. self._record_login_attempt(username, success=False, machine_code=machine_code)
  407. recent_failures = self._get_recent_failure_count(username)
  408. remaining_attempts = MAX_LOGIN_ATTEMPTS - recent_failures
  409. if remaining_attempts <= 0:
  410. return False, "账号已锁定,请稍后重试", {}
  411. logger.warning(f"密码错误: {username}, 剩余尝试次数: {remaining_attempts}")
  412. return False, f"用户名或密码错误,还能尝试{remaining_attempts}次", {}
  413. # 4. 验证账户状态
  414. if user.status != 1:
  415. self._record_login_attempt(username, success=False, machine_code=machine_code)
  416. return False, "账号已被禁用", {}
  417. # 5. 机器码处理:首次登录绑定;已绑定时仅校验,不覆盖(换机必须先解绑)
  418. if user.machine_code is None:
  419. user.machine_code = machine_code
  420. elif user.machine_code != machine_code:
  421. self._record_login_attempt(username, success=False, machine_code=machine_code)
  422. return False, "该账号已绑定其他设备。如需换机请先在原设备解绑或联系客服", {}
  423. # 6. 按软件检查卡密有效期(优先 user_software_subscription,兼容 user.expire_time)
  424. expire_time, _ = self._get_subscription_expire(user.id, software_code or "")
  425. if expire_time and expire_time < beijing_now_naive():
  426. self._record_login_attempt(username, success=False, machine_code=machine_code)
  427. return False, "您的卡密已到期,请前往续费或联系客户支持", {}
  428. # 7. 创建会话
  429. session_id = generate_session_id()
  430. expires_at = beijing_now_naive() + timedelta(seconds=DEFAULT_SESSION_EXPIRY)
  431. session = SessionModel(
  432. session_id=session_id,
  433. user_id=user.id,
  434. machine_code=machine_code,
  435. is_active=True,
  436. expires_at=expires_at
  437. )
  438. self.db.add(session)
  439. self.db.commit() # 先提交会话,确保会话落库;避免 _clear_login_attempts 失败时 rollback 导致会话丢失
  440. self._clear_login_attempts(username) # 清除登录失败记录(失败不影响已提交的会话)
  441. days_remaining = 0
  442. if expire_time:
  443. days_remaining = (expire_time - beijing_now_naive()).days
  444. expire_str = expire_time.isoformat() if expire_time else None
  445. logger.info(f"用户登录成功: {username}")
  446. return True, "登录成功", {
  447. "user_id": user.id,
  448. "username": user.username,
  449. "session_id": session_id,
  450. "expire_time": expire_str,
  451. "days_remaining": days_remaining
  452. }
  453. except Exception as e:
  454. self.db.rollback()
  455. logger.exception(f"用户登录失败: {e}")
  456. return False, f"登录失败: {str(e)}", {}
  457. def _get_recent_failure_count(self, username: str) -> int:
  458. """获取最近失败次数"""
  459. lockout_time = beijing_now_naive() - timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
  460. return self.db.query(LoginAttempt).filter(
  461. LoginAttempt.username == username,
  462. LoginAttempt.attempted_at > lockout_time,
  463. LoginAttempt.success == 0
  464. ).count()
  465. def logout(self, session_id: str) -> Tuple[bool, str]:
  466. """
  467. 用户登出
  468. Args:
  469. session_id: 会话ID
  470. Returns:
  471. (success, message)
  472. """
  473. try:
  474. session = self.db.query(SessionModel).filter(
  475. SessionModel.session_id == session_id
  476. ).first()
  477. if session:
  478. session.is_active = False
  479. self.db.commit()
  480. logger.info(f"用户登出成功: {session_id}")
  481. return True, "登出成功"
  482. except Exception as e:
  483. self.db.rollback()
  484. logger.exception(f"用户登出失败: {e}")
  485. return False, f"登出失败: {str(e)}"
  486. def unbind(self, username: str, password: str, machine_code: str) -> Tuple[bool, str]:
  487. """
  488. 机器解绑
  489. Args:
  490. username: 用户名
  491. password: 密码
  492. machine_code: 当前机器码
  493. Returns:
  494. (success, message)
  495. """
  496. try:
  497. # 查找用户
  498. user = self.db.query(User).filter(
  499. User.username == username,
  500. User.is_deleted == 0
  501. ).first()
  502. if not user:
  503. return False, "用户不存在"
  504. # 验证密码
  505. if not verify_password(password, user.password):
  506. return False, "密码错误"
  507. # 验证机器码
  508. if user.machine_code != machine_code:
  509. return False, "机器码不匹配"
  510. # 校验机器解绑冷却周期(单位:天)
  511. cooldown_days = self._get_int_config("machine_unbind_cooldown_days", 10)
  512. if cooldown_days > 0 and user.machine_code:
  513. bind_time = user.updated_at or user.created_at
  514. now = beijing_now_naive()
  515. allow_unbind_at = bind_time + timedelta(days=cooldown_days)
  516. if now < allow_unbind_at:
  517. return False, f"当前设备绑定未满 {cooldown_days} 天,暂不支持解绑。请在 {allow_unbind_at.strftime('%Y-%m-%d %H:%M:%S')} 之后再试。"
  518. # 清除机器码
  519. user.machine_code = None
  520. # 先写历史表,再改/删:将即将删除的会话写入 sessions_his
  521. from app.utils.history import create_session_history
  522. sessions_to_remove = self.db.query(SessionModel).filter(
  523. SessionModel.user_id == user.id
  524. ).all()
  525. for s in sessions_to_remove:
  526. create_session_history(self.db, s, "DELETE")
  527. # 清除用户的所有会话(强制下线)
  528. self.db.query(SessionModel).filter(
  529. SessionModel.user_id == user.id
  530. ).delete()
  531. self.db.commit()
  532. logger.info(f"机器解绑成功: {username}")
  533. return True, "解绑成功,可在其他机器登录"
  534. except Exception as e:
  535. self.db.rollback()
  536. logger.exception(f"机器解绑失败: {e}")
  537. return False, f"解绑失败: {str(e)}"
  538. def change_password(self, username: str, old_password: str, new_password: str) -> Tuple[bool, str]:
  539. """
  540. 修改密码(已登录用户通过当前密码修改为新密码)
  541. Args:
  542. username: 用户名
  543. old_password: 当前密码
  544. new_password: 新密码
  545. Returns:
  546. (success, message)
  547. """
  548. try:
  549. user = self.db.query(User).filter(
  550. User.username == username,
  551. User.is_deleted == 0
  552. ).first()
  553. if not user:
  554. return False, "用户不存在"
  555. if not verify_password(old_password, user.password):
  556. return False, "当前密码错误,请检查后重试"
  557. raw_new = new_password.encode("utf-8")
  558. if len(raw_new) < 6:
  559. return False, "新密码长度至少 6 位,请重新设置"
  560. if len(raw_new) > BCRYPT_MAX_PASSWORD_BYTES:
  561. return False, "新密码过长,请重新设置"
  562. user.password = hash_password(new_password)
  563. self.db.commit()
  564. logger.info(f"用户修改密码成功: {username}")
  565. return True, "密码修改成功,请使用新密码登录"
  566. except Exception as e:
  567. self.db.rollback()
  568. logger.exception(f"修改密码失败: {e}")
  569. return False, "系统异常,请稍后重试;若持续发生请联系客服"
  570. def verify_session(self, session_id: str) -> Optional[SessionModel]:
  571. """验证会话(仅校验会话存在且未过期,不校验机器码与用户卡密)"""
  572. session = self.db.query(SessionModel).filter(
  573. SessionModel.session_id == session_id,
  574. SessionModel.is_active == 1,
  575. SessionModel.expires_at > beijing_now_naive()
  576. ).first()
  577. return session
  578. def verify_session_with_machine_and_expiry(
  579. self,
  580. session_id: str,
  581. machine_code: str,
  582. software_code: Optional[str] = None,
  583. ) -> Tuple[Optional[SessionModel], Optional[int], Optional[str]]:
  584. """
  585. 校验会话 + 机器码一致 + 用户未禁用 + 用户卡密未过期。
  586. 与心跳逻辑一致,供所有需登录的接口统一使用。
  587. Returns:
  588. (session, None, None) 成功
  589. (None, error_code, error_message) 失败
  590. error_code 401: 未登录/会话过期;403: 机器码不匹配/账号禁用/卡密过期
  591. """
  592. session = self.verify_session(session_id)
  593. if not session:
  594. return None, 401, "会话已过期,请重新登录"
  595. mc = (machine_code or "").strip()
  596. if mc:
  597. # 与登录/心跳一致:session 存的是对 raw_machine_info 规范化后的 MD5;客户端可能传 raw(如 DISK=xxx)或已 MD5
  598. # 若已是 32 位小写 hex 则视为 MD5 直接比较,否则视为 raw 做规范化+MD5 再比较
  599. if len(mc) == 32 and all(c in "0123456789abcdef" for c in mc.lower()):
  600. mc_normalized = mc.lower()
  601. else:
  602. mc_normalized = raw_machine_info_to_machine_code(mc)
  603. if session.machine_code != mc_normalized:
  604. return None, 403, "账号已在其他设备登录,如非本人操作请联系客服"
  605. user = self.get_user_by_id(session.user_id)
  606. if not user:
  607. return None, 401, "用户不存在"
  608. if user.status != 1:
  609. return None, 403, "账号已被禁用"
  610. expire_time, _ = self._get_subscription_expire(user.id, software_code or "")
  611. if expire_time and expire_time < beijing_now_naive():
  612. return None, 403, "您的卡密已到期,请前往续费或联系客户支持"
  613. return session, None, None
  614. def get_user_by_id(self, user_id: int) -> Optional[User]:
  615. """根据ID获取用户"""
  616. return self.db.query(User).filter(
  617. User.id == user_id,
  618. User.is_deleted == 0
  619. ).first()
  620. def validate_session_or_token(self, username: str = None) -> Tuple[Optional[int], str, Optional[str]]:
  621. """
  622. 验证会话或Token
  623. Returns:
  624. (user_id, message, session_id)
  625. """
  626. return None, "验证失败", None