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=postgresql://user:password@localhost/checkin
# 安全配置(鉴权 JWT 密钥,需修改以保证安全)
SECRET_KEY=CheckInSecretKey
# CORS 允许的前端域名(逗号分隔,生产环境必须修改)
CORS_ORIGINS=http://localhost:3000
+8 -8
View File
@@ -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:
# 使用统一的验证方法
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
except ValueError:
# jwt_exp 格式不正确,跳过此用户
logger.debug(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
continue
return {
"users": {
+23 -8
View File
@@ -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
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(
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)
}
+3
View File
@@ -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"
+26 -47
View File
@@ -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,11 +32,19 @@ 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)
# 验证 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()
if not user:
@@ -42,60 +54,27 @@ async def get_current_user(
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:
except pyjwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的用户ID格式",
detail="登录已过期,请重新登录",
headers={"WWW-Authenticate": "Bearer"},
)
# Token 认证(原有逻辑)
# 从数据库查询用户
user = db.query(User).filter(User.authorization == token).first()
if not user:
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 过期必须重新扫码登录
except Exception as e:
logger.error(f"认证失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 已过期,请重新扫码登录",
detail="认证失败",
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(
+121 -33
View File
@@ -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,57 +306,133 @@ 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_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": "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 {
"is_valid": True,
"message": "Token 有效",
"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:
logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
return {
"is_valid": True,
"message": "Token 有效",
"user_id": user.id
"is_valid": False,
"message": "打卡凭证格式错误",
"reason": "invalid_format"
}
@staticmethod
@@ -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,
"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
+17 -26
View File
@@ -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,13 +147,12 @@ 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 已过期"
# 使用统一的打卡 Token 验证方法
from backend.services.auth_service import AuthService
token_result = AuthService.verify_checkin_authorization(user)
if not token_result["is_valid"]:
error_msg = token_result["message"]
logger.warning(f"{error_msg} - Task ID: {task.id}")
record = CheckInRecord(
@@ -173,9 +172,6 @@ class CheckInService:
"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_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,15 +233,13 @@ 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)
if not token_result["is_valid"]:
error_msg = token_result["message"]
logger.warning(f"{error_msg} - 用户: {user.alias}, Task ID: {task.id}")
# 记录失败
record = CheckInRecord(
@@ -265,9 +259,6 @@ class CheckInService:
"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}")
# 执行打卡(传递 task 对象和用户 token)
logger.info(f"🤖 调用 Selenium Worker 执行打卡...")
+21 -22
View File
@@ -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,25 +162,28 @@ 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)
# 检查 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}...")
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"
@@ -192,26 +195,26 @@ def check_token_expiration():
user.token_expiring_notified = True
db.commit()
notified_count += 1
logger.info(f"用户 {user.alias} 的 Token 即将过期邮件已发送并标记")
logger.info(f"用户 {user.alias}打卡 Token 即将过期邮件已发送并标记")
else:
logger.warning(f"用户 {user.alias} 的 Token 即将过期邮件发送失败")
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}...")
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 已过期邮件已发送并标记")
logger.info(f"用户 {user.alias}打卡 Token 已过期邮件已发送并标记")
else:
logger.warning(f"用户 {user.alias} 的 Token 已过期邮件发送失败")
logger.warning(f"用户 {user.alias}打卡 Token 已过期邮件发送失败")
# 情况3Token 正常(剩余时间 > 30 分钟),重置提醒标志
elif time_until_expiry >= 1800:
@@ -219,13 +222,9 @@ def check_token_expiration():
user.token_expiring_notified = False
user.token_expired_notified = False
db.commit()
logger.info(f"用户 {user.alias} 的 Token 已刷新,重置所有提醒标志")
logger.info(f"用户 {user.alias}打卡 Token 已刷新,重置所有提醒标志")
except ValueError:
logger.warning(f"用户 {user.alias} 的 jwt_exp 格式不正确: {user.jwt_exp}")
continue
logger.info(f"Scheduler: Token 过期检查完成,共发送 {notified_count} 封提醒邮件")
logger.info(f"Scheduler: 打卡 Token 过期检查完成,共发送 {notified_count} 封提醒邮件")
finally:
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;
if (status === 401) {
const errorDetail = data.detail || data.message || '';
// 检查用户是否设置了密码
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 {
// 没有密码的用户:必须重新登录
// JWT token 过期或无效:需要重新登录
// 注意:打卡业务的 authorization token 过期不会影响网站登录状态
localStorage.removeItem('token');
localStorage.removeItem('user');
// 延迟跳转,避免阻塞当前异步请求的错误处理
// 延迟跳转到登录页
setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}, 100);
}
} else {
// 其他 401 错误(无效 Token 等):清除登录状态
localStorage.removeItem('token');
localStorage.removeItem('user');
setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}, 100);
}
}
// 返回统一的错误对象
return Promise.reject({
+1
View File
@@ -190,6 +190,7 @@
<!-- QR Code Modal for Token Refresh -->
<QRCodeModal
v-model:visible="qrcodeModalVisible"
:alias="authStore.user?.alias || ''"
@success="handleQRCodeSuccess"
@error="handleQRCodeError"
/>
+7 -33
View File
@@ -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}`);
}
// 跳转到重定向页面或仪表盘