Files
CheckInApp/apps/backend/services/auth_service.py
T

575 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import uuid
import logging
import threading
import jwt
import bcrypt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from urllib.parse import unquote
from sqlalchemy.orm import Session
from backend.models import User
from backend.workers.token_refresher import get_token_headless, get_session_data
from backend.config import settings
from backend.utils.jwt import JWTManager
logger = logging.getLogger(__name__)
class AuthService:
"""认证服务"""
@staticmethod
def request_qrcode(alias: str, client_ip: str, db: Session) -> Dict[str, Any]:
"""
请求 QQ 扫码二维码(支持新用户注册)
Args:
alias: 用户别名
client_ip: 客户端 IP 地址(用于会话标识)
db: 数据库会话
Returns:
包含 session_id 和 qrcode_base64 的字典
"""
from backend.services.registration_manager import registration_manager
import time
# 检查用户名是否已在数据库中存在
existing_user = db.query(User).filter(User.alias == alias).first()
# 生成唯一的会话 ID
session_id = str(uuid.uuid4())
if existing_user:
# 检查是否为空 jwt_sub(测试账号)
if not existing_user.jwt_sub:
logger.warning(f"用户 {alias} 是测试账号(未绑定 QQ),禁止扫码登录")
return {"status": "error", "message": "此账户为测试账号,暂未绑定 QQ,无法扫码登录"}
# 老用户:刷新 Token
logger.info(f"老用户 {alias} 请求刷新 Token,会话: {session_id}")
# 在后台线程启动 Playwright,传入 jwt_sub
thread = threading.Thread(
target=get_token_headless,
args=(session_id, existing_user.jwt_sub, alias, client_ip),
daemon=True,
)
thread.start()
else:
# 新用户:预占用户名
if not registration_manager.reserve_alias(alias, session_id, timeout_seconds=120):
logger.warning(f"用户名 {alias} 已被预占")
return {
"status": "error",
"message": "该用户名正在被其他人注册,请稍后再试或更换用户名",
}
logger.info(f"新用户 {alias} 请求注册,会话: {session_id},已预占用户名")
# 在后台线程启动 Playwright,不传入 jwt_sub(新用户)
thread = threading.Thread(
target=get_token_headless, args=(session_id, None, alias, client_ip), daemon=True
)
thread.start()
# 等待二维码生成(最多等待 30 秒)
logger.info(f"等待会话 {session_id} 的二维码生成...")
max_wait_time = 30
start_time = time.time()
while time.time() - start_time < max_wait_time:
session_data = get_session_data(session_id)
if session_data:
status = session_data.get("status")
# 二维码已生成
if status == "waiting_scan":
qr_image_data = session_data.get("qr_image_data")
if qr_image_data:
logger.info(f"会话 {session_id} 的二维码已生成")
return {"session_id": session_id, "qrcode_base64": qr_image_data}
# 如果已经失败,直接返回错误
elif status == "failed":
error_msg = session_data.get("message", "生成二维码失败")
logger.error(f"会话 {session_id} 生成二维码失败: {error_msg}")
return {"status": "error", "message": error_msg}
# 每 0.5 秒检查一次
time.sleep(0.5)
# 超时
logger.error(f"会话 {session_id} 等待二维码生成超时({max_wait_time}秒)")
return {"status": "error", "message": f"生成二维码超时,请重试"}
@staticmethod
def get_qrcode_status(session_id: str, db: Session) -> Dict[str, Any]:
"""
检查二维码扫描状态
Args:
session_id: 会话 ID
db: 数据库会话
Returns:
包含状态信息的字典
"""
session_data = get_session_data(session_id)
if not session_data:
return {"status": "pending", "message": "会话不存在或正在初始化"}
status = session_data.get("status")
jwt_sub = session_data.get("jwt_sub") # 使用 jwt_sub 而非 signature
if status == "waiting_scan":
return {
"status": "waiting_scan",
"message": "请使用手机 QQ 扫描二维码",
"qrcode_image": session_data.get("qr_image_data"),
}
elif status == "success":
token = session_data.get("token")
alias = session_data.get("alias") # 新增:从 session 中获取 alias
# 解析 JWT Token 获取 jwt_exp 和 jwt_sub
jwt_exp = "0"
jwt_sub = ""
if not token:
logger.error("Token 为空")
return {"status": "error", "message": "Token 为空"}
try:
# 清洗 TokenURL 解码 + 去除 Bearer 前缀(参考 v1 实现)
pure_token = unquote(token) # URL 解码
if pure_token.lower().startswith("bearer "):
pure_token = pure_token[7:] # 去除 "Bearer " 前缀
decoded = jwt.decode(pure_token, options={"verify_signature": False})
jwt_exp = str(decoded.get("exp", 0))
jwt_sub = decoded.get("sub", "")
logger.info(f"成功解析 JWT for sub={jwt_sub}, exp={jwt_exp}")
except Exception as e:
logger.error(f"解析 JWT Token 失败: {e}")
return {"status": "error", "message": f"Token 解析失败: {str(e)}"}
# 查找用户(通过 jwt_sub
user = db.query(User).filter(User.jwt_sub == jwt_sub).first()
if user:
# 已注册用户:更新 Token(存储清理后的 token
# 注意:如果通过别名登录,需要验证 jwt_sub 是否匹配
if alias and alias == user.alias:
# 用户使用别名登录,验证 jwt_sub 是否一致
# 如果用户之前的 jwt_sub 不为空且与当前不一致,说明QQ号被换绑了
existing_jwt_sub = getattr(user, "jwt_sub", "")
if (
isinstance(existing_jwt_sub, str)
and existing_jwt_sub.strip()
and existing_jwt_sub != jwt_sub
):
logger.warning(
f"⚠️ 用户 {user.alias} 的 jwt_sub 不匹配!数据库: {existing_jwt_sub}, 当前: {jwt_sub}"
)
return {
"status": "error",
"message": "QQ账号不匹配,请使用正确的QQ号扫码登录",
}
user.authorization = pure_token # 存储清理后的 token
user.jwt_exp = jwt_exp
user.token_expiring_notified = False # 重置"即将过期"提醒标志
user.token_expired_notified = False # 重置"已过期"提醒标志
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
logger.info(f"更新已注册用户 {user.alias} 的 Token")
# 生成 JWT access token(用于网站登录)
access_token = JWTManager.create_access_token(user.id, user.alias)
return {
"status": "success",
"message": "登录成功",
"token": access_token, # 返回 JWT token(用于网站登录)
"user": {
"id": user.id,
"alias": user.alias,
"role": user.role,
"is_approved": user.is_approved,
"jwt_sub": user.jwt_sub,
},
"is_new_user": False,
}
else:
# 新用户:创建账户
from backend.services.registration_manager import registration_manager
# 验证用户名是否被预占
if not alias or not registration_manager.is_alias_reserved(alias):
logger.error(f"新用户注册失败:用户名 {alias} 未预占或已过期")
return {"status": "error", "message": "注册失败:会话已过期,请重新扫码"}
# 检查用户名是否已被其他人注册(防止竞态)
existing_user_by_alias = db.query(User).filter(User.alias == alias).first()
if existing_user_by_alias:
registration_manager.release_alias(alias)
logger.error(f"新用户注册失败:用户名 {alias} 已被占用")
return {"status": "error", "message": "注册失败:用户名已被占用,请更换用户名"}
# 创建新用户(待审批状态)
from backend.services.email_settings_service import EmailSettingsService
requires_approval = EmailSettingsService.is_registration_approval_required()
new_user = User(
jwt_sub=jwt_sub,
alias=alias,
authorization=pure_token, # 存储清理后的 token
jwt_exp=jwt_exp,
role="user",
is_approved=not requires_approval,
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# 释放用户名预占
registration_manager.release_alias(alias)
logger.info(f"✅ 新用户 {alias} 注册成功(待审批),ID: {new_user.id}")
# 发送邮件通知管理员
try:
from backend.services.email_service import EmailService
EmailService.notify_new_user_registration(new_user, db)
except Exception as e:
logger.error(f"发送注册通知邮件失败: {e}")
# 生成 JWT access token(用于网站登录)
access_token = JWTManager.create_access_token(new_user.id, new_user.alias)
return {
"status": "success",
"message": "注册成功,请等待管理员审批(24小时内)",
"token": access_token, # 返回 JWT token(用于网站登录)
"user": {
"id": new_user.id,
"alias": new_user.alias,
"role": new_user.role,
"is_approved": new_user.is_approved,
"jwt_sub": new_user.jwt_sub,
},
"is_new_user": True,
}
elif status == "error":
return {"status": "error", "message": session_data.get("message", "未知错误")}
else:
return {"status": "pending", "message": "正在初始化..."}
@staticmethod
def verify_token(authorization: str, db: Session) -> Dict[str, Any]:
"""
验证 JWT Token 有效性
Args:
authorization: JWT Token(可带或不带 "Bearer " 前缀)
db: 数据库会话
Returns:
包含验证结果的字典
"""
from backend.utils.jwt import JWTManager
# 移除 "Bearer " 前缀
token = (
authorization.replace("Bearer ", "")
if authorization.startswith("Bearer ")
else authorization
)
try:
# 验证 JWT token
payload = JWTManager.verify_token(token)
user_id = payload.get("user_id")
if not user_id:
return {"is_valid": False, "message": "Token 格式错误"}
# 从数据库获取用户
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"is_valid": False, "message": "用户不存在"}
return {
"is_valid": True,
"message": "Token 有效",
"user_id": user.id,
"alias": user.alias,
"role": user.role,
"is_approved": user.is_approved,
}
except jwt.ExpiredSignatureError:
return {"is_valid": False, "message": "JWT Token 已过期"}
except jwt.InvalidTokenError:
return {"is_valid": False, "message": "JWT Token 无效"}
except Exception as e:
logger.error(f"验证 JWT Token 失败: {str(e)}")
return {"is_valid": False, "message": "Token 验证失败"}
@staticmethod
def verify_checkin_authorization(user: User) -> Dict[str, Any]:
"""
验证打卡业务 authorization token 的有效性
注意:这与 JWT token 验证不同
- JWT token 用于网站登录认证
- authorization token 用于打卡业务操作(存储在 User.authorization
Args:
user: 用户对象
Returns:
包含打卡 token 验证结果的字典
"""
from backend.utils.time_helpers import (
parse_jwt_exp,
is_timestamp_expired,
days_until_expiry,
minutes_until_expiry,
seconds_until_expiry,
)
# 检查是否有 authorization token
if not user.authorization or user.authorization == "":
return {"is_valid": False, "message": "未设置打卡凭证", "reason": "no_token"}
# 解析 jwt_exp
exp_timestamp = parse_jwt_exp(user.jwt_exp)
if not exp_timestamp:
return {"is_valid": False, "message": "打卡凭证无效", "reason": "invalid_expiry"}
# 检查是否过期
if is_timestamp_expired(exp_timestamp):
days_expired = abs(days_until_expiry(exp_timestamp))
return {
"is_valid": False,
"message": f"打卡凭证已过期 {days_expired}",
"reason": "expired",
"days_expired": days_expired,
}
# Token 有效,计算剩余时间
seconds_remaining = seconds_until_expiry(exp_timestamp)
days_remaining = days_until_expiry(exp_timestamp)
minutes_remaining = minutes_until_expiry(exp_timestamp)
# 判断是否即将过期(30分钟内)
expiring_soon = minutes_remaining <= 30
return {
"is_valid": True,
"message": "打卡凭证有效",
"days_remaining": days_remaining,
"minutes_remaining": minutes_remaining,
"expiring_soon": expiring_soon,
"expires_at": exp_timestamp,
}
@staticmethod
def alias_login(alias: str, password: str, db: Session) -> Dict[str, Any]:
"""
别名+密码登录
Args:
alias: 用户别名
password: 密码
db: 数据库会话
Returns:
包含登录结果的字典
"""
# 查找用户
user = db.query(User).filter(User.alias == alias).first()
if not user:
logger.warning(f"别名登录失败:用户 {alias} 不存在")
return {"success": False, "message": "用户名或密码错误"}
# 检查账户是否被锁定
if user.locked_until:
# 如果锁定时间还未到期
if datetime.now() < user.locked_until:
remaining_seconds = (user.locked_until - datetime.now()).total_seconds()
remaining_minutes = int(remaining_seconds / 60) + 1
logger.warning(
f"别名登录失败:用户 {alias} 账户已锁定,剩余 {remaining_minutes} 分钟"
)
return {
"success": False,
"message": f"账户已锁定,请 {remaining_minutes} 分钟后再试",
}
else:
# 锁定时间已过,重置锁定状态
user.locked_until = None
user.failed_login_attempts = 0
db.commit()
logger.info(f"用户 {alias} 的账户锁定已自动解除")
# 检查用户是否设置了密码
if not user.password_hash:
logger.warning(f"别名登录失败:用户 {alias} 未设置密码")
return {"success": False, "message": "该用户未设置密码,请使用扫码登录"}
# 验证密码
try:
password_bytes = password.encode("utf-8")
hash_bytes = user.password_hash.encode("utf-8")
if not bcrypt.checkpw(password_bytes, hash_bytes):
# 密码错误,增加失败次数
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
user.last_failed_login = datetime.now()
# 如果失败次数达到5次,锁定账户15分钟
if user.failed_login_attempts >= 5:
user.locked_until = datetime.now() + timedelta(minutes=15)
db.commit()
logger.warning(f"别名登录失败:用户 {alias} 密码错误次数过多,账户已锁定15分钟")
return {"success": False, "message": "密码错误次数过多,账户已锁定15分钟"}
db.commit()
remaining_attempts = 5 - user.failed_login_attempts
logger.warning(
f"别名登录失败:用户 {alias} 密码错误,剩余尝试次数: {remaining_attempts}"
)
return {
"success": False,
"message": f"用户名或密码错误,剩余尝试次数: {remaining_attempts}",
}
except Exception as e:
logger.error(f"密码验证异常:{e}")
return {"success": False, "message": "登录失败,请稍后重试"}
# 密码正确,重置失败次数
user.failed_login_attempts = 0
user.locked_until = None
user.last_failed_login = None
db.commit()
# 检查 Token 状态(仅作提示,不阻止登录)
token_warning = None
if not user.authorization or user.jwt_exp == "0":
logger.info(f"用户 {alias} Token 无效,允许密码登录但需提示用户更新")
token_warning = "token_invalid"
else:
# 检查 Token 是否过期
from backend.utils.time_helpers import parse_jwt_exp, is_timestamp_expired
exp_timestamp = parse_jwt_exp(user.jwt_exp)
if exp_timestamp and is_timestamp_expired(exp_timestamp):
logger.info(f"用户 {alias} Token 已过期,允许密码登录但需提示用户更新")
token_warning = "token_expired"
# 登录成功
logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功")
# 生成 JWT access token(用于网站登录)
access_token = JWTManager.create_access_token(user.id, user.alias)
result = {
"success": True,
"message": "登录成功",
"token": access_token, # 返回 JWT token(用于网站登录)
"user": {
"id": user.id,
"alias": user.alias,
"role": user.role,
"is_approved": user.is_approved,
},
}
# 如果打卡 Token 有问题,添加警告信息(不影响网站使用)
if token_warning:
result["token_warning"] = token_warning
if token_warning == "token_invalid":
result["warning_message"] = (
"登录成功,但检测到打卡凭证无效,无法自动打卡,建议扫码更新"
)
elif token_warning == "token_expired":
result["warning_message"] = (
"登录成功,但检测到打卡凭证已过期,无法自动打卡,建议扫码更新"
)
return result
@staticmethod
def hash_password(password: str) -> str:
"""
使用 bcrypt 加密密码
Args:
password: 明文密码
Returns:
加密后的密码哈希
"""
password_bytes = password.encode("utf-8")
salt = bcrypt.gensalt()
hash_bytes = bcrypt.hashpw(password_bytes, salt)
return hash_bytes.decode("utf-8")
@staticmethod
def verify_password(password: str, password_hash: str) -> bool:
"""
验证密码
Args:
password: 明文密码
password_hash: 密码哈希
Returns:
密码是否正确
"""
try:
password_bytes = password.encode("utf-8")
hash_bytes = password_hash.encode("utf-8")
return bcrypt.checkpw(password_bytes, hash_bytes)
except Exception as e:
logger.error(f"密码验证异常:{e}")
return False
@staticmethod
def cancel_qrcode_session(session_id: str) -> Dict[str, Any]:
"""
取消二维码登录会话
Args:
session_id: 会话 ID
Returns:
包含取消结果的字典
"""
from backend.workers.token_refresher import cancel_session
success = cancel_session(session_id)
if success:
return {"success": True, "message": "会话已取消"}
else:
return {"success": False, "message": "取消失败或会话不存在"}