diff --git a/.env.example b/.env.example
index dced552..9da157c 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,9 @@
# DATABASE_URL=sqlite:///./data/checkin.db
# DATABASE_URL=postgresql://user:password@localhost/checkin
+# 安全配置(鉴权 JWT 密钥,需修改以保证安全)
+SECRET_KEY=CheckInSecretKey
+
# CORS 允许的前端域名(逗号分隔,生产环境必须修改)
CORS_ORIGINS=http://localhost:3000
diff --git a/backend/api/admin.py b/backend/api/admin.py
index 8d263af..5e66667 100644
--- a/backend/api/admin.py
+++ b/backend/api/admin.py
@@ -188,20 +188,20 @@ async def get_system_stats(
).count()
# Token 即将过期的用户数(7天内)
+ from backend.services.auth_service import AuthService
+
current_timestamp = int(datetime.now().timestamp())
expiring_soon_timestamp = current_timestamp + (7 * 24 * 60 * 60) # 7天后
expiring_users = 0
for user in db.query(User).all():
- if user.jwt_exp and user.jwt_exp != "0":
- try:
- exp_timestamp = int(user.jwt_exp)
- if current_timestamp < exp_timestamp < expiring_soon_timestamp:
- expiring_users += 1
- except ValueError:
- # jwt_exp 格式不正确,跳过此用户
- logger.debug(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
- continue
+ # 使用统一的验证方法
+ result = AuthService.verify_checkin_authorization(user)
+
+ if result["is_valid"]:
+ exp_timestamp = result.get("expires_at")
+ if exp_timestamp and current_timestamp < exp_timestamp < expiring_soon_timestamp:
+ expiring_users += 1
return {
"users": {
diff --git a/backend/api/auth.py b/backend/api/auth.py
index 73c010d..5e5a0f9 100644
--- a/backend/api/auth.py
+++ b/backend/api/auth.py
@@ -89,8 +89,13 @@ async def get_qrcode_status(
状态说明:
- pending: 正在初始化
- waiting_scan: 等待扫描(包含二维码图片 Base64)
- - success: 扫描成功(包含 user_id 和 authorization)
+ - success: 扫描成功(包含 JWT token 和 user 信息)
- error: 发生错误
+
+ 认证架构说明:
+ - 扫码成功后返回 JWT token(用于网站登录,21天有效期)
+ - 同时更新数据库中的 authorization token(用于打卡业务)
+ - 两种 token 分别管理,互不影响
"""
try:
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(
request: TokenVerifyRequest,
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:
result = AuthService.verify_token(request.authorization, db)
@@ -156,12 +166,17 @@ async def alias_login(
- **alias**: 用户别名
- **password**: 密码
- 返回登录结果,成功时包含 user_id 和 authorization
+ 返回登录结果,成功时包含 JWT token 和 user 信息
+
+ 认证架构说明:
+ - 登录成功后返回 JWT token(用于网站登录,21天有效期)
+ - 如果数据库中的打卡 authorization token 过期,会返回警告信息
+ - 打卡 token 过期不影响网站登录,但无法自动打卡,建议扫码更新
注意:
- 用户必须已设置密码才能使用此方式登录
- - Token 必须仍然有效(未过期)
- - 如果 Token 已过期,请使用扫码登录重新获取
+ - 即使打卡 token 已过期,仍然可以使用密码登录网站
+ - 如需更新打卡 token,请使用扫码登录
"""
try:
result = AuthService.alias_login(request.alias, request.password, db)
diff --git a/backend/api/users.py b/backend/api/users.py
index bd5423b..8746d4e 100644
--- a/backend/api/users.py
+++ b/backend/api/users.py
@@ -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(
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
- 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}")
+ # 使用统一的验证方法
+ result = AuthService.verify_checkin_authorization(current_user)
return {
- "is_valid": is_valid,
+ "is_valid": result["is_valid"],
"jwt_exp": current_user.jwt_exp,
- "expires_at": expires_at,
- "days_until_expiry": days_until_expiry,
- "expiring_soon": expiring_soon
+ "expires_at": result.get("expires_at"),
+ "days_until_expiry": result.get("days_remaining"),
+ "expiring_soon": result.get("expiring_soon", False)
}
diff --git a/backend/config.py b/backend/config.py
index d33d15e..0d91616 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -25,6 +25,9 @@ class Settings(BaseSettings):
VERSION: str = "2.0.0"
API_PREFIX: str = "/api"
+ # 安全配置(登录)
+ SECRET_KEY: str = "CheckInSecretKey"
+
# 数据库配置
DATABASE_URL: str = f"sqlite:///{BASE_DIR}/data/checkin.db"
diff --git a/backend/dependencies.py b/backend/dependencies.py
index 7faf7b0..d055687 100644
--- a/backend/dependencies.py
+++ b/backend/dependencies.py
@@ -1,9 +1,11 @@
from datetime import datetime
from typing import Optional
import logging
+import jwt as pyjwt
from fastapi import Depends, HTTPException, Header, status
from sqlalchemy.orm import Session
from backend.models import get_db, User
+from backend.utils.jwt import JWTManager
logger = logging.getLogger(__name__)
@@ -13,10 +15,12 @@ async def get_current_user(
db: Session = Depends(get_db)
) -> User:
"""
- 获取当前用户
- 支持两种认证方式:
- 1. Token 认证(QQ 扫码登录)
- 2. User ID 认证(密码登录,格式:user_id:xxx)
+ 获取当前用户(使用 JWT 认证)
+
+ 认证说明:
+ 1. 网站登录使用 JWT token(存储在前端,21天过期)
+ 2. 打卡业务使用 authorization token(存储在数据库 User.authorization)
+ 3. JWT 过期后需要重新登录,但打卡 token 过期不影响网站使用
"""
if not authorization:
raise HTTPException(
@@ -28,74 +32,49 @@ async def get_current_user(
# 移除 "Bearer " 前缀(如果存在)
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:
- user_id = int(user_id_str)
- user = db.query(User).filter(User.id == user_id).first()
+ try:
+ # 验证 JWT token
+ payload = JWTManager.verify_token(token)
+ user_id = payload.get("user_id")
- if not user:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="用户不存在",
- 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
-
- except ValueError:
+ if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail="无效的用户ID格式",
+ detail="Token 格式错误",
headers={"WWW-Authenticate": "Bearer"},
)
- # Token 认证(原有逻辑)
- # 从数据库查询用户
- user = db.query(User).filter(User.authorization == token).first()
+ # 从数据库获取用户
+ user = db.query(User).filter(User.id == user_id).first()
- if not user:
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="用户不存在",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ return user
+
+ except pyjwt.ExpiredSignatureError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="登录已过期,请重新登录",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ except pyjwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证信息",
headers={"WWW-Authenticate": "Bearer"},
)
-
- # 检查 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:
- # 如果用户设置了密码,允许继续使用(Token 过期但不强制退出)
- has_password = bool(user.password_hash)
- if has_password:
- # Token 过期但有密码,允许访问,但在响应头中添加警告
- # 注意:这里不抛出异常,让用户继续使用
- pass
- else:
- # 没有密码的用户,Token 过期必须重新扫码登录
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Token 已过期,请重新扫码登录",
- 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
+ except Exception as e:
+ logger.error(f"认证失败: {str(e)}")
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="认证失败",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
async def require_approved_user(
diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py
index 0a74cbc..a33557e 100644
--- a/backend/services/auth_service.py
+++ b/backend/services/auth_service.py
@@ -11,6 +11,7 @@ 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__)
@@ -208,10 +209,13 @@ class AuthService:
logger.info(f"更新老用户 {user.alias} 的 Token")
+ # 生成 JWT access token(用于网站登录)
+ access_token = JWTManager.create_access_token(user.id, user.alias)
+
return {
"status": "success",
"message": "登录成功",
- "token": pure_token, # 返回清理后的 token
+ "token": access_token, # 返回 JWT token(用于网站登录)
"user": {
"id": user.id,
"alias": user.alias,
@@ -270,10 +274,13 @@ class AuthService:
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": pure_token, # 返回清理后的 token
+ "token": access_token, # 返回 JWT token(用于网站登录)
"user": {
"id": new_user.id,
"alias": new_user.alias,
@@ -299,58 +306,134 @@ class AuthService:
@staticmethod
def verify_token(authorization: str, db: Session) -> Dict[str, Any]:
"""
- 验证 Token 有效性
+ 验证 JWT Token 有效性
Args:
- authorization: Token
+ authorization: JWT Token(可带或不带 "Bearer " 前缀)
db: 数据库会话
Returns:
包含验证结果的字典
"""
+ from backend.utils.jwt import JWTManager
+
# 移除 "Bearer " 前缀
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
- # 从数据库查询用户
- user = db.query(User).filter(User.authorization == token).first()
+ try:
+ # 验证 JWT token
+ payload = JWTManager.verify_token(token)
+ user_id = payload.get("user_id")
- if not user:
+ 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 pyjwt.ExpiredSignatureError:
return {
"is_valid": False,
- "message": "Token 无效"
+ "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 user.jwt_exp and user.jwt_exp != "0":
- try:
- exp_timestamp = int(user.jwt_exp)
- current_timestamp = int(datetime.now().timestamp())
+ if not user.jwt_exp or user.jwt_exp == "0":
+ return {
+ "is_valid": False,
+ "message": "打卡凭证无效",
+ "reason": "invalid_expiry"
+ }
- if current_timestamp > exp_timestamp:
- return {
- "is_valid": False,
- "message": "Token 已过期",
- "user_id": user.id
- }
-
- # 计算剩余天数
- days_until_expiry = (exp_timestamp - current_timestamp) // 86400
+ 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": True,
- "message": "Token 有效",
- "user_id": user.id,
- "days_until_expiry": days_until_expiry
+ "is_valid": False,
+ "message": f"打卡凭证已过期 {days_expired} 天",
+ "reason": "expired",
+ "days_expired": days_expired
}
- except ValueError:
- logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
+ # 计算剩余时间
+ seconds_remaining = exp_timestamp - current_timestamp
+ days_remaining = seconds_remaining // 86400
+ minutes_remaining = seconds_remaining // 60
- return {
- "is_valid": True,
- "message": "Token 有效",
- "user_id": user.id
- }
+ # 判断是否即将过期(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:
+ logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
+ return {
+ "is_valid": False,
+ "message": "打卡凭证格式错误",
+ "reason": "invalid_format"
+ }
@staticmethod
def alias_login(alias: str, password: str, db: Session) -> Dict[str, Any]:
@@ -422,23 +505,28 @@ class AuthService:
# 登录成功
logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功")
+ # 生成 JWT access token(用于网站登录)
+ access_token = JWTManager.create_access_token(user.id, user.alias)
+
result = {
"success": True,
"message": "登录成功",
- "user_id": user.id,
- "authorization": user.authorization,
- "alias": user.alias,
- "role": user.role,
- "is_approved": user.is_approved
+ "token": access_token, # 返回 JWT token(用于网站登录)
+ "user": {
+ "id": user.id,
+ "alias": user.alias,
+ "role": user.role,
+ "is_approved": user.is_approved
+ }
}
- # 如果 Token 有问题,添加警告信息
+ # 如果打卡 Token 有问题,添加警告信息(不影响网站使用)
if token_warning:
result["token_warning"] = token_warning
if token_warning == "token_invalid":
- result["warning_message"] = "登录成功,但检测到登录凭证无效,部分功能可能受限,建议扫码更新"
+ result["warning_message"] = "登录成功,但检测到打卡凭证无效,无法自动打卡,建议扫码更新"
elif token_warning == "token_expired":
- result["warning_message"] = "登录成功,但检测到登录凭证已过期,部分功能可能受限,建议扫码更新"
+ result["warning_message"] = "登录成功,但检测到打卡凭证已过期,无法自动打卡,建议扫码更新"
return result
diff --git a/backend/services/check_in_service.py b/backend/services/check_in_service.py
index 07fc760..7eddbfa 100644
--- a/backend/services/check_in_service.py
+++ b/backend/services/check_in_service.py
@@ -122,10 +122,10 @@ class CheckInService:
"""
logger.info(f"🚀 启动异步打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})")
- # 获取用户的 Token
+ # 获取用户的打卡 Token
user = task.user
if not user or not user.authorization:
- error_msg = f"用户没有有效的 Token"
+ error_msg = f"用户没有有效的打卡 Token"
logger.error(f"❌ {error_msg} - Task ID: {task.id}")
# 创建失败记录
@@ -147,35 +147,31 @@ class CheckInService:
"message": error_msg
}
- # 检查 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:
- error_msg = f"Token 已过期"
- logger.warning(f"⏰ {error_msg} - Task ID: {task.id}")
+ # 使用统一的打卡 Token 验证方法
+ from backend.services.auth_service import AuthService
+ token_result = AuthService.verify_checkin_authorization(user)
- record = CheckInRecord(
- task_id=task.id,
- status="failure",
- response_text="",
- error_message=f"{error_msg},请重新扫码登录",
- location="{}",
- trigger_type=trigger_type
- )
- db.add(record)
- db.commit()
- db.refresh(record)
+ if not token_result["is_valid"]:
+ error_msg = token_result["message"]
+ logger.warning(f"⏰ {error_msg} - Task ID: {task.id}")
- return {
- "record_id": record.id,
- "status": "failure",
- "message": f"{error_msg},请重新扫码登录"
- }
- except ValueError as e:
- # jwt_exp 格式不正确,记录警告后跳过 Token 过期验证
- logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}")
+ record = CheckInRecord(
+ task_id=task.id,
+ status="failure",
+ response_text="",
+ error_message=f"{error_msg},请重新扫码登录",
+ location="{}",
+ trigger_type=trigger_type
+ )
+ db.add(record)
+ db.commit()
+ db.refresh(record)
+
+ return {
+ "record_id": record.id,
+ "status": "failure",
+ "message": f"{error_msg},请重新扫码登录"
+ }
# 创建待处理记录
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}")
- # 获取用户的 Token
+ # 获取用户的打卡 Token
user = task.user
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'}")
# 记录失败
@@ -237,37 +233,32 @@ class CheckInService:
"record_id": record.id
}
- # 检查 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:
- error_msg = f"Token 已过期"
- expires_at = datetime.fromtimestamp(exp_timestamp)
- logger.warning(f"⏰ {error_msg} - 过期时间: {expires_at}, 用户: {user.alias}, Task ID: {task.id}")
+ # 使用统一的打卡 Token 验证方法
+ from backend.services.auth_service import AuthService
+ token_result = AuthService.verify_checkin_authorization(user)
- # 记录失败
- record = CheckInRecord(
- task_id=task.id,
- status="failure",
- response_text="",
- error_message=error_msg,
- location="{}",
- trigger_type=trigger_type
- )
- db.add(record)
- db.commit()
- db.refresh(record)
+ if not token_result["is_valid"]:
+ error_msg = token_result["message"]
+ logger.warning(f"⏰ {error_msg} - 用户: {user.alias}, Task ID: {task.id}")
- return {
- "success": False,
- "message": f"{error_msg},请重新扫码登录",
- "record_id": record.id
- }
- except ValueError as e:
- # jwt_exp 格式不正确,记录警告后跳过 Token 过期验证
- logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}")
+ # 记录失败
+ record = CheckInRecord(
+ task_id=task.id,
+ status="failure",
+ response_text="",
+ error_message=error_msg,
+ location="{}",
+ trigger_type=trigger_type
+ )
+ db.add(record)
+ db.commit()
+ db.refresh(record)
+
+ return {
+ "success": False,
+ "message": f"{error_msg},请重新扫码登录",
+ "record_id": record.id
+ }
# 执行打卡(传递 task 对象和用户 token)
logger.info(f"🤖 调用 Selenium Worker 执行打卡...")
diff --git a/backend/services/scheduler_service.py b/backend/services/scheduler_service.py
index 8a9a534..5fc4914 100644
--- a/backend/services/scheduler_service.py
+++ b/backend/services/scheduler_service.py
@@ -149,12 +149,12 @@ def cleanup_expired_pending_users():
def check_token_expiration():
"""
- 检查 Token 是否即将过期,并发送邮件提醒
+ 检查打卡 Token 是否即将过期,并发送邮件提醒
- 检查所有用户的 Token,如果在 30 分钟内过期,发送提醒邮件
- 注意:现在需要检查用户的任务,因为邮箱地址在任务中
+ 检查所有用户的打卡 authorization token,如果在 30 分钟内过期,发送提醒邮件
+ 注意:检查的是打卡业务 token,不是网站登录 JWT token
"""
- logger.info("Scheduler: 正在执行 Token 过期检查...")
+ logger.info("Scheduler: 正在执行打卡 Token 过期检查...")
try:
# 创建数据库会话
@@ -162,70 +162,69 @@ def check_token_expiration():
try:
# 获取所有用户
+ from backend.services.auth_service import AuthService
+
users = db.query(User).all()
current_timestamp = int(datetime.now().timestamp())
notified_count = 0
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
- try:
- exp_timestamp = int(user.jwt_exp)
+ time_until_expiry = exp_timestamp - current_timestamp
- # 检查 Token 状态并发送对应的提醒
- time_until_expiry = exp_timestamp - current_timestamp
+ # 情况1:Token 即将过期(过期前 30 分钟内,且还未过期)
+ if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒
+ if user.email and not user.token_expiring_notified:
+ logger.info(f"用户 {user.alias} 的打卡 Token 即将过期,发送邮件提醒到 {user.email}...")
+ from backend.services.email_service import EmailService
+ jwt_exp_value = user.jwt_exp
+ jwt_exp_str = str(jwt_exp_value) if jwt_exp_value is not None else "0"
- # 情况1:Token 即将过期(过期前 30 分钟内,且还未过期)
- if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒
- if user.email and not user.token_expiring_notified:
- logger.info(f"用户 {user.alias} 的 Token 即将过期,发送邮件提醒到 {user.email}...")
- from backend.services.email_service import EmailService
- jwt_exp_value = user.jwt_exp
- jwt_exp_str = str(jwt_exp_value) if jwt_exp_value is not None else "0"
+ # 发送"即将过期"邮件
+ success = EmailService.notify_token_expiring(user, jwt_exp_str)
- # 发送"即将过期"邮件
- success = EmailService.notify_token_expiring(user, jwt_exp_str)
-
- if success:
- user.token_expiring_notified = True
- db.commit()
- notified_count += 1
- logger.info(f"用户 {user.alias} 的 Token 即将过期邮件已发送并标记")
- else:
- logger.warning(f"用户 {user.alias} 的 Token 即将过期邮件发送失败")
-
- # 情况2:Token 已过期(过期后 30 分钟内)
- elif -1800 < time_until_expiry <= 0: # 过期后 30 分钟内
- if user.email and not user.token_expired_notified:
- logger.info(f"用户 {user.alias} 的 Token 已过期,发送邮件提醒到 {user.email}...")
- from backend.services.email_service import EmailService
-
- # 发送"已过期"邮件(可以使用不同的邮件模板或内容)
- success = EmailService.notify_token_expired(user)
-
- if success:
- user.token_expired_notified = True
- db.commit()
- notified_count += 1
- logger.info(f"用户 {user.alias} 的 Token 已过期邮件已发送并标记")
- else:
- logger.warning(f"用户 {user.alias} 的 Token 已过期邮件发送失败")
-
- # 情况3:Token 正常(剩余时间 > 30 分钟),重置提醒标志
- elif time_until_expiry >= 1800:
- if user.token_expiring_notified or user.token_expired_notified:
- user.token_expiring_notified = False
- user.token_expired_notified = False
+ if success:
+ user.token_expiring_notified = True
db.commit()
- logger.info(f"用户 {user.alias} 的 Token 已刷新,重置所有提醒标志")
+ notified_count += 1
+ logger.info(f"用户 {user.alias} 的打卡 Token 即将过期邮件已发送并标记")
+ else:
+ logger.warning(f"用户 {user.alias} 的打卡 Token 即将过期邮件发送失败")
- except ValueError:
- logger.warning(f"用户 {user.alias} 的 jwt_exp 格式不正确: {user.jwt_exp}")
- continue
+ # 情况2:Token 已过期(过期后 30 分钟内)
+ elif -1800 < time_until_expiry <= 0: # 过期后 30 分钟内
+ if user.email and not user.token_expired_notified:
+ logger.info(f"用户 {user.alias} 的打卡 Token 已过期,发送邮件提醒到 {user.email}...")
+ from backend.services.email_service import EmailService
- logger.info(f"Scheduler: Token 过期检查完成,共发送 {notified_count} 封提醒邮件")
+ # 发送"已过期"邮件
+ success = EmailService.notify_token_expired(user)
+
+ if success:
+ user.token_expired_notified = True
+ db.commit()
+ notified_count += 1
+ logger.info(f"用户 {user.alias} 的打卡 Token 已过期邮件已发送并标记")
+ else:
+ logger.warning(f"用户 {user.alias} 的打卡 Token 已过期邮件发送失败")
+
+ # 情况3:Token 正常(剩余时间 > 30 分钟),重置提醒标志
+ elif time_until_expiry >= 1800:
+ if user.token_expiring_notified or user.token_expired_notified:
+ user.token_expiring_notified = False
+ user.token_expired_notified = False
+ db.commit()
+ logger.info(f"用户 {user.alias} 的打卡 Token 已刷新,重置所有提醒标志")
+
+ logger.info(f"Scheduler: 打卡 Token 过期检查完成,共发送 {notified_count} 封提醒邮件")
finally:
db.close()
diff --git a/backend/utils/jwt.py b/backend/utils/jwt.py
new file mode 100644
index 0000000..1cf6dbc
--- /dev/null
+++ b/backend/utils/jwt.py
@@ -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
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 18542d8..8dc25d5 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -34,49 +34,17 @@ client.interceptors.response.use(
const { status, data } = error.response;
if (status === 401) {
- const errorDetail = data.detail || data.message || '';
+ // JWT token 过期或无效:需要重新登录
+ // 注意:打卡业务的 authorization token 过期不会影响网站登录状态
+ localStorage.removeItem('token');
+ localStorage.removeItem('user');
- // 检查用户是否设置了密码
- 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('user');
-
- // 延迟跳转,避免阻塞当前异步请求的错误处理
- setTimeout(() => {
- if (window.location.pathname !== '/login') {
- window.location.href = '/login';
- }
- }, 100);
+ // 延迟跳转到登录页
+ setTimeout(() => {
+ if (window.location.pathname !== '/login') {
+ window.location.href = '/login';
}
- } else {
- // 其他 401 错误(无效 Token 等):清除登录状态
- localStorage.removeItem('token');
- localStorage.removeItem('user');
-
- setTimeout(() => {
- if (window.location.pathname !== '/login') {
- window.location.href = '/login';
- }
- }, 100);
- }
+ }, 100);
}
// 返回统一的错误对象
diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue
index 47797cf..015c374 100644
--- a/frontend/src/views/DashboardView.vue
+++ b/frontend/src/views/DashboardView.vue
@@ -190,6 +190,7 @@
diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue
index 447e30e..6d83dc2 100644
--- a/frontend/src/views/LoginView.vue
+++ b/frontend/src/views/LoginView.vue
@@ -31,6 +31,7 @@
v-model:value="qrcodeForm.alias"
placeholder="请输入您的用户名"
size="large"
+ autocomplete="username"
allow-clear
@keyup.enter="handleQRCodeLogin"
>
@@ -66,6 +67,7 @@
v-model:value="passwordForm.alias"
placeholder="请输入您的用户名"
size="large"
+ autocomplete="username"
allow-clear
>
@@ -79,6 +81,7 @@
v-model:value="passwordForm.password"
placeholder="请输入密码"
size="large"
+ autocomplete="current-password"
@keyup.enter="handlePasswordLogin"
>
@@ -235,46 +238,17 @@ const handlePasswordLogin = async () => {
);
if (response.success) {
- // 使用 authStore 保存认证信息
- const user = {
- id: response.user_id,
- alias: response.alias,
- role: response.role || 'user',
- is_approved: response.is_approved !== false,
- };
+ // 保存 JWT token 和用户信息
+ authStore.setAuth(response.token, response.user);
- // 如果没有 authorization(测试账号),使用 user_id 作为认证凭据
- 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 警告,显示提示
+ // 如果有打卡 Token 警告,显示提示(不影响网站登录)
if (response.token_warning && response.warning_message) {
message.warning({
content: response.warning_message,
duration: 5,
});
- } else if (response.authorization) {
- // 只有有 token 的用户才显示"欢迎回来"
- message.success(`欢迎回来,${response.alias}!`);
} else {
- // 测试账号登录成功提示
- message.success(`登录成功,${response.alias}!`);
+ message.success(`欢迎回来,${response.user.alias}!`);
}
// 跳转到重定向页面或仪表盘