mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+23
-8
@@ -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)
|
||||
|
||||
+11
-31
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+39
-60
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 执行打卡...")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 返回统一的错误对象
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
<!-- QR Code Modal for Token Refresh -->
|
||||
<QRCodeModal
|
||||
v-model:visible="qrcodeModalVisible"
|
||||
:alias="authStore.user?.alias || ''"
|
||||
@success="handleQRCodeSuccess"
|
||||
@error="handleQRCodeError"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -79,6 +81,7 @@
|
||||
v-model:value="passwordForm.password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handlePasswordLogin"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -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}!`);
|
||||
}
|
||||
|
||||
// 跳转到重定向页面或仪表盘
|
||||
|
||||
Reference in New Issue
Block a user