feat: implement JWT auth and optimize token validation

- Separate JWT login (21d) from check-in token
- Unify check-in token validation with verify_checkin_authorization()
- Update API docs for dual-token architecture
This commit is contained in:
2026-01-05 23:02:50 +08:00
parent b32b53853a
commit a9b141fc69
13 changed files with 464 additions and 336 deletions
+3
View File
@@ -6,6 +6,9 @@
# DATABASE_URL=sqlite:///./data/checkin.db # DATABASE_URL=sqlite:///./data/checkin.db
# DATABASE_URL=postgresql://user:password@localhost/checkin # DATABASE_URL=postgresql://user:password@localhost/checkin
# 安全配置(鉴权 JWT 密钥,需修改以保证安全)
SECRET_KEY=CheckInSecretKey
# CORS 允许的前端域名(逗号分隔,生产环境必须修改) # CORS 允许的前端域名(逗号分隔,生产环境必须修改)
CORS_ORIGINS=http://localhost:3000 CORS_ORIGINS=http://localhost:3000
+8 -8
View File
@@ -188,20 +188,20 @@ async def get_system_stats(
).count() ).count()
# Token 即将过期的用户数(7天内) # Token 即将过期的用户数(7天内)
from backend.services.auth_service import AuthService
current_timestamp = int(datetime.now().timestamp()) current_timestamp = int(datetime.now().timestamp())
expiring_soon_timestamp = current_timestamp + (7 * 24 * 60 * 60) # 7天后 expiring_soon_timestamp = current_timestamp + (7 * 24 * 60 * 60) # 7天后
expiring_users = 0 expiring_users = 0
for user in db.query(User).all(): for user in db.query(User).all():
if user.jwt_exp and user.jwt_exp != "0": # 使用统一的验证方法
try: result = AuthService.verify_checkin_authorization(user)
exp_timestamp = int(user.jwt_exp)
if current_timestamp < exp_timestamp < expiring_soon_timestamp: if result["is_valid"]:
exp_timestamp = result.get("expires_at")
if exp_timestamp and current_timestamp < exp_timestamp < expiring_soon_timestamp:
expiring_users += 1 expiring_users += 1
except ValueError:
# jwt_exp 格式不正确,跳过此用户
logger.debug(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
continue
return { return {
"users": { "users": {
+23 -8
View File
@@ -89,8 +89,13 @@ async def get_qrcode_status(
状态说明: 状态说明:
- pending: 正在初始化 - pending: 正在初始化
- waiting_scan: 等待扫描(包含二维码图片 Base64) - waiting_scan: 等待扫描(包含二维码图片 Base64)
- success: 扫描成功(包含 user_id 和 authorization - success: 扫描成功(包含 JWT token 和 user 信息
- error: 发生错误 - error: 发生错误
认证架构说明:
- 扫码成功后返回 JWT token(用于网站登录,21天有效期)
- 同时更新数据库中的 authorization token(用于打卡业务)
- 两种 token 分别管理,互不影响
""" """
try: try:
result = AuthService.get_qrcode_status(session_id, db) result = AuthService.get_qrcode_status(session_id, db)
@@ -123,17 +128,22 @@ async def cancel_qrcode_session(
) )
@router.post("/verify_token", response_model=dict, summary="验证 Token 有效性") @router.post("/verify_token", response_model=dict, summary="验证 JWT Token 有效性")
async def verify_token( async def verify_token(
request: TokenVerifyRequest, request: TokenVerifyRequest,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
验证 Token 有效性 验证 JWT Token 有效性(网站登录认证)
- **authorization**: Token(可带或不带 "Bearer " 前缀) - **authorization**: JWT Token(可带或不带 "Bearer " 前缀)
返回 Token 是否有效以及相关信息 返回 Token 是否有效以及用户信息
注意:
- 此接口验证的是 JWT token(用于网站登录,21天有效期)
- 不验证打卡业务的 authorization token(存储在数据库中)
- JWT token 过期需要重新登录,但打卡 token 过期不影响网站使用
""" """
try: try:
result = AuthService.verify_token(request.authorization, db) result = AuthService.verify_token(request.authorization, db)
@@ -156,12 +166,17 @@ async def alias_login(
- **alias**: 用户别名 - **alias**: 用户别名
- **password**: 密码 - **password**: 密码
返回登录结果,成功时包含 user_id 和 authorization 返回登录结果,成功时包含 JWT token 和 user 信息
认证架构说明:
- 登录成功后返回 JWT token(用于网站登录,21天有效期)
- 如果数据库中的打卡 authorization token 过期,会返回警告信息
- 打卡 token 过期不影响网站登录,但无法自动打卡,建议扫码更新
注意: 注意:
- 用户必须已设置密码才能使用此方式登录 - 用户必须已设置密码才能使用此方式登录
- Token 必须仍然有效(未过期) - 即使打卡 token 已过期,仍然可以使用密码登录网站
- 如果 Token 已过期,请使用扫码登录重新获取 - 如需更新打卡 token,请使用扫码登录
""" """
try: try:
result = AuthService.alias_login(request.alias, request.password, db) result = AuthService.alias_login(request.alias, request.password, db)
+11 -31
View File
@@ -104,46 +104,26 @@ async def update_current_user_profile(
) )
@router.get("/me/token_status", response_model=TokenStatus, summary="获取当前用户 Token 状态") @router.get("/me/token_status", response_model=TokenStatus, summary="获取当前用户打卡 Token 状态")
async def get_current_user_token_status( async def get_current_user_token_status(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取当前用户的 Token 状态 获取当前用户的打卡 Token 状态authorization token,非 JWT
注意:此接口检查的是打卡业务 token,不是网站登录 JWT token
""" """
from datetime import datetime from backend.services.auth_service import AuthService
is_valid = True # 使用统一的验证方法
days_until_expiry = None result = AuthService.verify_checkin_authorization(current_user)
expires_at = None
expiring_soon = False
if current_user.jwt_exp and current_user.jwt_exp != "0":
try:
exp_timestamp = int(current_user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
expires_at = exp_timestamp
if current_timestamp > exp_timestamp:
is_valid = False
else:
days_until_expiry = (exp_timestamp - current_timestamp) // 86400
# 检查是否在30分钟内过期
minutes_until_expiry = (exp_timestamp - current_timestamp) // 60
expiring_soon = minutes_until_expiry <= 30
except ValueError as e:
# jwt_exp 格式不正确,记录警告
import logging
logger = logging.getLogger(__name__)
logger.warning(f"用户 {current_user.id} ({current_user.alias}) 的 jwt_exp 格式不正确: {current_user.jwt_exp}, 错误: {e}")
return { return {
"is_valid": is_valid, "is_valid": result["is_valid"],
"jwt_exp": current_user.jwt_exp, "jwt_exp": current_user.jwt_exp,
"expires_at": expires_at, "expires_at": result.get("expires_at"),
"days_until_expiry": days_until_expiry, "days_until_expiry": result.get("days_remaining"),
"expiring_soon": expiring_soon "expiring_soon": result.get("expiring_soon", False)
} }
+3
View File
@@ -25,6 +25,9 @@ class Settings(BaseSettings):
VERSION: str = "2.0.0" VERSION: str = "2.0.0"
API_PREFIX: str = "/api" API_PREFIX: str = "/api"
# 安全配置(登录)
SECRET_KEY: str = "CheckInSecretKey"
# 数据库配置 # 数据库配置
DATABASE_URL: str = f"sqlite:///{BASE_DIR}/data/checkin.db" DATABASE_URL: str = f"sqlite:///{BASE_DIR}/data/checkin.db"
+26 -47
View File
@@ -1,9 +1,11 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
import logging import logging
import jwt as pyjwt
from fastapi import Depends, HTTPException, Header, status from fastapi import Depends, HTTPException, Header, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.models import get_db, User from backend.models import get_db, User
from backend.utils.jwt import JWTManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -13,10 +15,12 @@ async def get_current_user(
db: Session = Depends(get_db) db: Session = Depends(get_db)
) -> User: ) -> User:
""" """
获取当前用户 获取当前用户(使用 JWT 认证)
支持两种认证方式:
1. Token 认证(QQ 扫码登录) 认证说明:
2. User ID 认证(密码登录,格式:user_id:xxx 1. 网站登录使用 JWT token(存储在前端,21天过期
2. 打卡业务使用 authorization token(存储在数据库 User.authorization
3. JWT 过期后需要重新登录,但打卡 token 过期不影响网站使用
""" """
if not authorization: if not authorization:
raise HTTPException( raise HTTPException(
@@ -28,11 +32,19 @@ async def get_current_user(
# 移除 "Bearer " 前缀(如果存在) # 移除 "Bearer " 前缀(如果存在)
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
# 检查是否为 user_id 格式的认证(用于密码登录)
if token.startswith("user_id:"):
user_id_str = token.replace("user_id:", "")
try: try:
user_id = int(user_id_str) # 验证 JWT token
payload = JWTManager.verify_token(token)
user_id = payload.get("user_id")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 格式错误",
headers={"WWW-Authenticate": "Bearer"},
)
# 从数据库获取用户
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
@@ -42,60 +54,27 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# 用户ID认证成功,检查是否设置了密码
has_password = bool(user.password_hash)
if not has_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="该账户未设置密码,请使用扫码登录",
headers={"WWW-Authenticate": "Bearer"},
)
# 密码登录的用户可以访问,无需检查 Token
return user return user
except ValueError: except pyjwt.ExpiredSignatureError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的用户ID格式", detail="登录已过期,请重新登录",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
except pyjwt.InvalidTokenError:
# Token 认证(原有逻辑)
# 从数据库查询用户
user = db.query(User).filter(User.authorization == token).first()
if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证信息", detail="无效的认证信息",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
except Exception as e:
# 检查 Token 是否过期 logger.error(f"认证失败: {str(e)}")
if user.jwt_exp and user.jwt_exp != "0":
try:
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
if current_timestamp > exp_timestamp:
# 如果用户设置了密码,允许继续使用(Token 过期但不强制退出)
has_password = bool(user.password_hash)
if has_password:
# Token 过期但有密码,允许访问,但在响应头中添加警告
# 注意:这里不抛出异常,让用户继续使用
pass
else:
# 没有密码的用户,Token 过期必须重新扫码登录
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 已过期,请重新扫码登录", detail="认证失败",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
except ValueError as e:
# jwt_exp 格式不正确,记录警告后跳过 Token 过期验证
logger.warning(f"用户 {user.id} ({user.alias}) 的 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}")
return user
async def require_approved_user( async def require_approved_user(
+121 -33
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from backend.models import User from backend.models import User
from backend.workers.token_refresher import get_token_headless, get_session_data from backend.workers.token_refresher import get_token_headless, get_session_data
from backend.config import settings from backend.config import settings
from backend.utils.jwt import JWTManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -208,10 +209,13 @@ class AuthService:
logger.info(f"更新老用户 {user.alias} 的 Token") logger.info(f"更新老用户 {user.alias} 的 Token")
# 生成 JWT access token(用于网站登录)
access_token = JWTManager.create_access_token(user.id, user.alias)
return { return {
"status": "success", "status": "success",
"message": "登录成功", "message": "登录成功",
"token": pure_token, # 返回清理后的 token "token": access_token, # 返回 JWT token(用于网站登录)
"user": { "user": {
"id": user.id, "id": user.id,
"alias": user.alias, "alias": user.alias,
@@ -270,10 +274,13 @@ class AuthService:
except Exception as e: except Exception as e:
logger.error(f"发送注册通知邮件失败: {e}") logger.error(f"发送注册通知邮件失败: {e}")
# 生成 JWT access token(用于网站登录)
access_token = JWTManager.create_access_token(new_user.id, new_user.alias)
return { return {
"status": "success", "status": "success",
"message": "注册成功,请等待管理员审批(24小时内)", "message": "注册成功,请等待管理员审批(24小时内)",
"token": pure_token, # 返回清理后的 token "token": access_token, # 返回 JWT token(用于网站登录)
"user": { "user": {
"id": new_user.id, "id": new_user.id,
"alias": new_user.alias, "alias": new_user.alias,
@@ -299,57 +306,133 @@ class AuthService:
@staticmethod @staticmethod
def verify_token(authorization: str, db: Session) -> Dict[str, Any]: def verify_token(authorization: str, db: Session) -> Dict[str, Any]:
""" """
验证 Token 有效性 验证 JWT Token 有效性
Args: Args:
authorization: Token authorization: JWT Token(可带或不带 "Bearer " 前缀)
db: 数据库会话 db: 数据库会话
Returns: Returns:
包含验证结果的字典 包含验证结果的字典
""" """
from backend.utils.jwt import JWTManager
# 移除 "Bearer " 前缀 # 移除 "Bearer " 前缀
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
# 从数据库查询用户 try:
user = db.query(User).filter(User.authorization == token).first() # 验证 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: if not user:
return { return {
"is_valid": False, "is_valid": False,
"message": "Token 无效" "message": "用户不存在"
} }
# 检查 Token 是否过期
if user.jwt_exp and user.jwt_exp != "0":
try:
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
if current_timestamp > exp_timestamp:
return {
"is_valid": False,
"message": "Token 已过期",
"user_id": user.id
}
# 计算剩余天数
days_until_expiry = (exp_timestamp - current_timestamp) // 86400
return { return {
"is_valid": True, "is_valid": True,
"message": "Token 有效", "message": "Token 有效",
"user_id": user.id, "user_id": user.id,
"days_until_expiry": days_until_expiry "alias": user.alias,
"role": user.role,
"is_approved": user.is_approved
}
except pyjwt.ExpiredSignatureError:
return {
"is_valid": False,
"message": "JWT Token 已过期"
}
except pyjwt.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 验证结果的字典
"""
# 检查是否有 authorization token
if not user.authorization or user.authorization == "":
return {
"is_valid": False,
"message": "未设置打卡凭证",
"reason": "no_token"
}
# 检查 Token 是否过期
if not user.jwt_exp or user.jwt_exp == "0":
return {
"is_valid": False,
"message": "打卡凭证无效",
"reason": "invalid_expiry"
}
try:
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
if current_timestamp > exp_timestamp:
days_expired = (current_timestamp - exp_timestamp) // 86400
return {
"is_valid": False,
"message": f"打卡凭证已过期 {days_expired}",
"reason": "expired",
"days_expired": days_expired
}
# 计算剩余时间
seconds_remaining = exp_timestamp - current_timestamp
days_remaining = seconds_remaining // 86400
minutes_remaining = seconds_remaining // 60
# 判断是否即将过期(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
} }
except ValueError: except ValueError:
logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}") logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
return { return {
"is_valid": True, "is_valid": False,
"message": "Token 有效", "message": "打卡凭证格式错误",
"user_id": user.id "reason": "invalid_format"
} }
@staticmethod @staticmethod
@@ -422,23 +505,28 @@ class AuthService:
# 登录成功 # 登录成功
logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功") logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功")
# 生成 JWT access token(用于网站登录)
access_token = JWTManager.create_access_token(user.id, user.alias)
result = { result = {
"success": True, "success": True,
"message": "登录成功", "message": "登录成功",
"user_id": user.id, "token": access_token, # 返回 JWT token(用于网站登录)
"authorization": user.authorization, "user": {
"id": user.id,
"alias": user.alias, "alias": user.alias,
"role": user.role, "role": user.role,
"is_approved": user.is_approved "is_approved": user.is_approved
} }
}
# 如果 Token 有问题,添加警告信息 # 如果打卡 Token 有问题,添加警告信息(不影响网站使用)
if token_warning: if token_warning:
result["token_warning"] = token_warning result["token_warning"] = token_warning
if token_warning == "token_invalid": if token_warning == "token_invalid":
result["warning_message"] = "登录成功,但检测到登录凭证无效,部分功能可能受限,建议扫码更新" result["warning_message"] = "登录成功,但检测到打卡凭证无效,无法自动打卡,建议扫码更新"
elif token_warning == "token_expired": elif token_warning == "token_expired":
result["warning_message"] = "登录成功,但检测到登录凭证已过期,部分功能可能受限,建议扫码更新" result["warning_message"] = "登录成功,但检测到打卡凭证已过期,无法自动打卡,建议扫码更新"
return result return result
+17 -26
View File
@@ -122,10 +122,10 @@ class CheckInService:
""" """
logger.info(f"🚀 启动异步打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})") logger.info(f"🚀 启动异步打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})")
# 获取用户的 Token # 获取用户的打卡 Token
user = task.user user = task.user
if not user or not user.authorization: if not user or not user.authorization:
error_msg = f"用户没有有效的 Token" error_msg = f"用户没有有效的打卡 Token"
logger.error(f"{error_msg} - Task ID: {task.id}") logger.error(f"{error_msg} - Task ID: {task.id}")
# 创建失败记录 # 创建失败记录
@@ -147,13 +147,12 @@ class CheckInService:
"message": error_msg "message": error_msg
} }
# 检查 Token 是否过期 # 使用统一的打卡 Token 验证方法
if user.jwt_exp and user.jwt_exp != "0": from backend.services.auth_service import AuthService
try: token_result = AuthService.verify_checkin_authorization(user)
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp()) if not token_result["is_valid"]:
if current_timestamp > exp_timestamp: error_msg = token_result["message"]
error_msg = f"Token 已过期"
logger.warning(f"{error_msg} - Task ID: {task.id}") logger.warning(f"{error_msg} - Task ID: {task.id}")
record = CheckInRecord( record = CheckInRecord(
@@ -173,9 +172,6 @@ class CheckInService:
"status": "failure", "status": "failure",
"message": f"{error_msg},请重新扫码登录" "message": f"{error_msg},请重新扫码登录"
} }
except ValueError as e:
# jwt_exp 格式不正确,记录警告后跳过 Token 过期验证
logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}")
# 创建待处理记录 # 创建待处理记录
record_id = CheckInService.create_pending_check_in_record(task, trigger_type, db) record_id = CheckInService.create_pending_check_in_record(task, trigger_type, db)
@@ -212,10 +208,10 @@ class CheckInService:
""" """
logger.info(f"🎯 开始打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id}), 触发: {trigger_type}") logger.info(f"🎯 开始打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id}), 触发: {trigger_type}")
# 获取用户的 Token # 获取用户的打卡 Token
user = task.user user = task.user
if not user or not user.authorization: if not user or not user.authorization:
error_msg = f"用户没有有效的 Token" error_msg = f"用户没有有效的打卡 Token"
logger.error(f"{error_msg} - Task ID: {task.id}, User ID: {user.id if user else 'None'}") logger.error(f"{error_msg} - Task ID: {task.id}, User ID: {user.id if user else 'None'}")
# 记录失败 # 记录失败
@@ -237,15 +233,13 @@ class CheckInService:
"record_id": record.id "record_id": record.id
} }
# 检查 Token 是否过期 # 使用统一的打卡 Token 验证方法
if user.jwt_exp and user.jwt_exp != "0": from backend.services.auth_service import AuthService
try: token_result = AuthService.verify_checkin_authorization(user)
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp()) if not token_result["is_valid"]:
if current_timestamp > exp_timestamp: error_msg = token_result["message"]
error_msg = f"Token 已过期" logger.warning(f"{error_msg} - 用户: {user.alias}, Task ID: {task.id}")
expires_at = datetime.fromtimestamp(exp_timestamp)
logger.warning(f"{error_msg} - 过期时间: {expires_at}, 用户: {user.alias}, Task ID: {task.id}")
# 记录失败 # 记录失败
record = CheckInRecord( record = CheckInRecord(
@@ -265,9 +259,6 @@ class CheckInService:
"message": f"{error_msg},请重新扫码登录", "message": f"{error_msg},请重新扫码登录",
"record_id": record.id "record_id": record.id
} }
except ValueError as e:
# jwt_exp 格式不正确,记录警告后跳过 Token 过期验证
logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}")
# 执行打卡(传递 task 对象和用户 token) # 执行打卡(传递 task 对象和用户 token)
logger.info(f"🤖 调用 Selenium Worker 执行打卡...") logger.info(f"🤖 调用 Selenium Worker 执行打卡...")
+21 -22
View File
@@ -149,12 +149,12 @@ def cleanup_expired_pending_users():
def check_token_expiration(): def check_token_expiration():
""" """
检查 Token 是否即将过期,并发送邮件提醒 检查打卡 Token 是否即将过期,并发送邮件提醒
检查所有用户的 Token,如果在 30 分钟内过期,发送提醒邮件 检查所有用户的打卡 authorization token,如果在 30 分钟内过期,发送提醒邮件
注意:现在需要检查用户的任务,因为邮箱地址在任务中 注意:检查的是打卡业务 token,不是网站登录 JWT token
""" """
logger.info("Scheduler: 正在执行 Token 过期检查...") logger.info("Scheduler: 正在执行打卡 Token 过期检查...")
try: try:
# 创建数据库会话 # 创建数据库会话
@@ -162,25 +162,28 @@ def check_token_expiration():
try: try:
# 获取所有用户 # 获取所有用户
from backend.services.auth_service import AuthService
users = db.query(User).all() users = db.query(User).all()
current_timestamp = int(datetime.now().timestamp()) current_timestamp = int(datetime.now().timestamp())
notified_count = 0 notified_count = 0
for user in users: for user in users:
if not user.jwt_exp or user.jwt_exp == "0": # 使用统一的验证方法
result = AuthService.verify_checkin_authorization(user)
# 获取过期时间戳和剩余时间
exp_timestamp = result.get("expires_at")
if not exp_timestamp:
continue continue
try:
exp_timestamp = int(user.jwt_exp)
# 检查 Token 状态并发送对应的提醒
time_until_expiry = exp_timestamp - current_timestamp time_until_expiry = exp_timestamp - current_timestamp
# 情况1:Token 即将过期(过期前 30 分钟内,且还未过期) # 情况1:Token 即将过期(过期前 30 分钟内,且还未过期)
if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒 if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒
if user.email and not user.token_expiring_notified: if user.email and not user.token_expiring_notified:
logger.info(f"用户 {user.alias} 的 Token 即将过期,发送邮件提醒到 {user.email}...") logger.info(f"用户 {user.alias}打卡 Token 即将过期,发送邮件提醒到 {user.email}...")
from backend.services.email_service import EmailService from backend.services.email_service import EmailService
jwt_exp_value = user.jwt_exp jwt_exp_value = user.jwt_exp
jwt_exp_str = str(jwt_exp_value) if jwt_exp_value is not None else "0" jwt_exp_str = str(jwt_exp_value) if jwt_exp_value is not None else "0"
@@ -192,26 +195,26 @@ def check_token_expiration():
user.token_expiring_notified = True user.token_expiring_notified = True
db.commit() db.commit()
notified_count += 1 notified_count += 1
logger.info(f"用户 {user.alias} 的 Token 即将过期邮件已发送并标记") logger.info(f"用户 {user.alias}打卡 Token 即将过期邮件已发送并标记")
else: else:
logger.warning(f"用户 {user.alias} 的 Token 即将过期邮件发送失败") logger.warning(f"用户 {user.alias}打卡 Token 即将过期邮件发送失败")
# 情况2:Token 已过期(过期后 30 分钟内) # 情况2:Token 已过期(过期后 30 分钟内)
elif -1800 < time_until_expiry <= 0: # 过期后 30 分钟内 elif -1800 < time_until_expiry <= 0: # 过期后 30 分钟内
if user.email and not user.token_expired_notified: if user.email and not user.token_expired_notified:
logger.info(f"用户 {user.alias} 的 Token 已过期,发送邮件提醒到 {user.email}...") logger.info(f"用户 {user.alias}打卡 Token 已过期,发送邮件提醒到 {user.email}...")
from backend.services.email_service import EmailService from backend.services.email_service import EmailService
# 发送"已过期"邮件(可以使用不同的邮件模板或内容) # 发送"已过期"邮件
success = EmailService.notify_token_expired(user) success = EmailService.notify_token_expired(user)
if success: if success:
user.token_expired_notified = True user.token_expired_notified = True
db.commit() db.commit()
notified_count += 1 notified_count += 1
logger.info(f"用户 {user.alias} 的 Token 已过期邮件已发送并标记") logger.info(f"用户 {user.alias}打卡 Token 已过期邮件已发送并标记")
else: else:
logger.warning(f"用户 {user.alias} 的 Token 已过期邮件发送失败") logger.warning(f"用户 {user.alias}打卡 Token 已过期邮件发送失败")
# 情况3Token 正常(剩余时间 > 30 分钟),重置提醒标志 # 情况3Token 正常(剩余时间 > 30 分钟),重置提醒标志
elif time_until_expiry >= 1800: elif time_until_expiry >= 1800:
@@ -219,13 +222,9 @@ def check_token_expiration():
user.token_expiring_notified = False user.token_expiring_notified = False
user.token_expired_notified = False user.token_expired_notified = False
db.commit() db.commit()
logger.info(f"用户 {user.alias} 的 Token 已刷新,重置所有提醒标志") logger.info(f"用户 {user.alias}打卡 Token 已刷新,重置所有提醒标志")
except ValueError: logger.info(f"Scheduler: 打卡 Token 过期检查完成,共发送 {notified_count} 封提醒邮件")
logger.warning(f"用户 {user.alias} 的 jwt_exp 格式不正确: {user.jwt_exp}")
continue
logger.info(f"Scheduler: Token 过期检查完成,共发送 {notified_count} 封提醒邮件")
finally: finally:
db.close() db.close()
+127
View File
@@ -0,0 +1,127 @@
"""
JWT 认证工具模块
用于生成和验证网站登录的 JWT Token
注意:这与打卡业务的 authorization token 是分开的
"""
import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from backend.config import settings
import logging
logger = logging.getLogger(__name__)
# JWT 配置
JWT_SECRET_KEY = settings.SECRET_KEY # 使用现有的 SECRET_KEY
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_DAYS = 21 # JWT 有效期:21天
class JWTManager:
"""JWT 管理器"""
@staticmethod
def create_access_token(user_id: int, user_alias: str) -> str:
"""
创建访问令牌
Args:
user_id: 用户 ID
user_alias: 用户别名
Returns:
JWT token 字符串
"""
now = datetime.utcnow()
exp = now + timedelta(days=JWT_EXPIRATION_DAYS)
payload = {
"user_id": user_id,
"alias": user_alias,
"iat": now, # Issued At - 签发时间
"exp": exp, # Expiration Time - 过期时间
"type": "access" # Token 类型
}
token = jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
logger.info(f"为用户 {user_alias}(ID: {user_id}) 创建 JWT,过期时间: {exp}")
return token
@staticmethod
def verify_token(token: str) -> Dict[str, Any]:
"""
验证并解码 JWT token
Args:
token: JWT token 字符串
Returns:
解码后的 payload 字典
Raises:
jwt.ExpiredSignatureError: Token 已过期
jwt.InvalidTokenError: Token 无效
"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
# 验证 token 类型
if payload.get("type") != "access":
raise jwt.InvalidTokenError("Token 类型不正确")
return payload
except jwt.ExpiredSignatureError:
logger.warning("JWT Token 已过期")
raise
except jwt.InvalidTokenError as e:
logger.warning(f"JWT Token 无效: {str(e)}")
raise
except Exception as e:
logger.error(f"验证 JWT Token 时发生错误: {str(e)}")
raise jwt.InvalidTokenError(f"Token 验证失败: {str(e)}")
@staticmethod
def get_user_id_from_token(token: str) -> Optional[int]:
"""
从 JWT token 中提取用户 ID(不验证过期)
Args:
token: JWT token 字符串
Returns:
用户 ID 或 None
"""
try:
# decode 时设置 verify=False 跳过过期验证
payload = jwt.decode(
token,
JWT_SECRET_KEY,
algorithms=[JWT_ALGORITHM],
options={"verify_exp": False}
)
return payload.get("user_id")
except Exception as e:
logger.error(f"从 Token 提取用户 ID 失败: {str(e)}")
return None
@staticmethod
def is_token_expired(token: str) -> bool:
"""
检查 token 是否过期(不抛出异常)
Args:
token: JWT token 字符串
Returns:
True 表示已过期,False 表示未过期
"""
try:
JWTManager.verify_token(token)
return False
except jwt.ExpiredSignatureError:
return True
except jwt.InvalidTokenError:
return True
+3 -35
View File
@@ -34,50 +34,18 @@ client.interceptors.response.use(
const { status, data } = error.response; const { status, data } = error.response;
if (status === 401) { if (status === 401) {
const errorDetail = data.detail || data.message || ''; // JWT token 过期或无效:需要重新登录
// 注意:打卡业务的 authorization token 过期不会影响网站登录状态
// 检查用户是否设置了密码
const user = JSON.parse(localStorage.getItem('user') || '{}');
const hasPassword = user.has_password || false;
// Token 过期的情况
if (errorDetail.includes('过期')) {
if (hasPassword) {
// 有密码的用户:不强制退出,只显示警告
// 不清除 localStorage,让用户继续使用
console.warn('Token 已过期,但用户设置了密码,允许继续使用');
// 返回错误但不跳转登录页
return Promise.reject({
status,
message: '登录凭证已过期,部分功能可能受限,建议刷新凭证',
data,
tokenExpired: true,
});
} else {
// 没有密码的用户:必须重新登录
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
// 延迟跳转,避免阻塞当前异步请求的错误处理 // 延迟跳转到登录页
setTimeout(() => { setTimeout(() => {
if (window.location.pathname !== '/login') { if (window.location.pathname !== '/login') {
window.location.href = '/login'; window.location.href = '/login';
} }
}, 100); }, 100);
} }
} else {
// 其他 401 错误(无效 Token 等):清除登录状态
localStorage.removeItem('token');
localStorage.removeItem('user');
setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}, 100);
}
}
// 返回统一的错误对象 // 返回统一的错误对象
return Promise.reject({ return Promise.reject({
+1
View File
@@ -190,6 +190,7 @@
<!-- QR Code Modal for Token Refresh --> <!-- QR Code Modal for Token Refresh -->
<QRCodeModal <QRCodeModal
v-model:visible="qrcodeModalVisible" v-model:visible="qrcodeModalVisible"
:alias="authStore.user?.alias || ''"
@success="handleQRCodeSuccess" @success="handleQRCodeSuccess"
@error="handleQRCodeError" @error="handleQRCodeError"
/> />
+7 -33
View File
@@ -31,6 +31,7 @@
v-model:value="qrcodeForm.alias" v-model:value="qrcodeForm.alias"
placeholder="请输入您的用户名" placeholder="请输入您的用户名"
size="large" size="large"
autocomplete="username"
allow-clear allow-clear
@keyup.enter="handleQRCodeLogin" @keyup.enter="handleQRCodeLogin"
> >
@@ -66,6 +67,7 @@
v-model:value="passwordForm.alias" v-model:value="passwordForm.alias"
placeholder="请输入您的用户名" placeholder="请输入您的用户名"
size="large" size="large"
autocomplete="username"
allow-clear allow-clear
> >
<template #prefix> <template #prefix>
@@ -79,6 +81,7 @@
v-model:value="passwordForm.password" v-model:value="passwordForm.password"
placeholder="请输入密码" placeholder="请输入密码"
size="large" size="large"
autocomplete="current-password"
@keyup.enter="handlePasswordLogin" @keyup.enter="handlePasswordLogin"
> >
<template #prefix> <template #prefix>
@@ -235,46 +238,17 @@ const handlePasswordLogin = async () => {
); );
if (response.success) { if (response.success) {
// 使用 authStore 保存认证信息 // 保存 JWT token 和用户信息
const user = { authStore.setAuth(response.token, response.user);
id: response.user_id,
alias: response.alias,
role: response.role || 'user',
is_approved: response.is_approved !== false,
};
// 如果没有 authorization(测试账号),使用 user_id 作为认证凭据 // 如果有打卡 Token 警告,显示提示(不影响网站登录)
const authToken = response.authorization || `user_id:${response.user_id}`;
authStore.setAuth(authToken, user);
// 只有当有真实 authorization 时才获取完整用户信息
if (response.authorization) {
try {
await authStore.fetchCurrentUser();
} catch (err) {
console.warn('获取完整用户信息失败,使用基本信息:', err);
// 即使失败也继续登录流程
}
} else {
// 没有 authorization 的测试账号,提示用户需要扫码绑定
message.info({
content: '您正在使用密码登录模式。如需使用打卡功能,请先扫码绑定 QQ。',
duration: 5,
});
}
// 如果有 Token 警告,显示提示
if (response.token_warning && response.warning_message) { if (response.token_warning && response.warning_message) {
message.warning({ message.warning({
content: response.warning_message, content: response.warning_message,
duration: 5, duration: 5,
}); });
} else if (response.authorization) {
// 只有有 token 的用户才显示"欢迎回来"
message.success(`欢迎回来,${response.alias}`);
} else { } else {
// 测试账号登录成功提示 message.success(`欢迎回来,${response.user.alias}`);
message.success(`登录成功,${response.alias}`);
} }
// 跳转到重定向页面或仪表盘 // 跳转到重定向页面或仪表盘