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=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
|
||||||
|
|
||||||
|
|||||||
@@ -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"]:
|
||||||
expiring_users += 1
|
exp_timestamp = result.get("expires_at")
|
||||||
except ValueError:
|
if exp_timestamp and current_timestamp < exp_timestamp < expiring_soon_timestamp:
|
||||||
# jwt_exp 格式不正确,跳过此用户
|
expiring_users += 1
|
||||||
logger.debug(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"users": {
|
"users": {
|
||||||
|
|||||||
+23
-8
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
+39
-60
@@ -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,74 +32,49 @@ 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 格式的认证(用于密码登录)
|
try:
|
||||||
if token.startswith("user_id:"):
|
# 验证 JWT token
|
||||||
user_id_str = token.replace("user_id:", "")
|
payload = JWTManager.verify_token(token)
|
||||||
try:
|
user_id = payload.get("user_id")
|
||||||
user_id = int(user_id_str)
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
|
|
||||||
if not user:
|
if not user_id:
|
||||||
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:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="无效的用户ID格式",
|
detail="Token 格式错误",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Token 认证(原有逻辑)
|
# 从数据库获取用户
|
||||||
# 从数据库查询用户
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
user = db.query(User).filter(User.authorization == token).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(
|
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":
|
raise HTTPException(
|
||||||
try:
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
exp_timestamp = int(user.jwt_exp)
|
detail="认证失败",
|
||||||
current_timestamp = int(datetime.now().timestamp())
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def require_approved_user(
|
async def require_approved_user(
|
||||||
|
|||||||
@@ -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,58 +306,134 @@ 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:
|
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 {
|
return {
|
||||||
"is_valid": False,
|
"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 是否过期
|
# 检查 Token 是否过期
|
||||||
if user.jwt_exp and user.jwt_exp != "0":
|
if not user.jwt_exp or user.jwt_exp == "0":
|
||||||
try:
|
return {
|
||||||
exp_timestamp = int(user.jwt_exp)
|
"is_valid": False,
|
||||||
current_timestamp = int(datetime.now().timestamp())
|
"message": "打卡凭证无效",
|
||||||
|
"reason": "invalid_expiry"
|
||||||
|
}
|
||||||
|
|
||||||
if current_timestamp > exp_timestamp:
|
try:
|
||||||
return {
|
exp_timestamp = int(user.jwt_exp)
|
||||||
"is_valid": False,
|
current_timestamp = int(datetime.now().timestamp())
|
||||||
"message": "Token 已过期",
|
|
||||||
"user_id": user.id
|
|
||||||
}
|
|
||||||
|
|
||||||
# 计算剩余天数
|
|
||||||
days_until_expiry = (exp_timestamp - current_timestamp) // 86400
|
|
||||||
|
|
||||||
|
if current_timestamp > exp_timestamp:
|
||||||
|
days_expired = (current_timestamp - exp_timestamp) // 86400
|
||||||
return {
|
return {
|
||||||
"is_valid": True,
|
"is_valid": False,
|
||||||
"message": "Token 有效",
|
"message": f"打卡凭证已过期 {days_expired} 天",
|
||||||
"user_id": user.id,
|
"reason": "expired",
|
||||||
"days_until_expiry": days_until_expiry
|
"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 {
|
# 判断是否即将过期(30分钟内)
|
||||||
"is_valid": True,
|
expiring_soon = minutes_remaining <= 30
|
||||||
"message": "Token 有效",
|
|
||||||
"user_id": user.id
|
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
|
@staticmethod
|
||||||
def alias_login(alias: str, password: str, db: Session) -> Dict[str, Any]:
|
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}) 别名登录成功")
|
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": {
|
||||||
"alias": user.alias,
|
"id": user.id,
|
||||||
"role": user.role,
|
"alias": user.alias,
|
||||||
"is_approved": user.is_approved
|
"role": user.role,
|
||||||
|
"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
|
||||||
|
|
||||||
|
|||||||
@@ -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,35 +147,31 @@ 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 current_timestamp > exp_timestamp:
|
|
||||||
error_msg = f"Token 已过期"
|
|
||||||
logger.warning(f"⏰ {error_msg} - Task ID: {task.id}")
|
|
||||||
|
|
||||||
record = CheckInRecord(
|
if not token_result["is_valid"]:
|
||||||
task_id=task.id,
|
error_msg = token_result["message"]
|
||||||
status="failure",
|
logger.warning(f"⏰ {error_msg} - Task ID: {task.id}")
|
||||||
response_text="",
|
|
||||||
error_message=f"{error_msg},请重新扫码登录",
|
|
||||||
location="{}",
|
|
||||||
trigger_type=trigger_type
|
|
||||||
)
|
|
||||||
db.add(record)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(record)
|
|
||||||
|
|
||||||
return {
|
record = CheckInRecord(
|
||||||
"record_id": record.id,
|
task_id=task.id,
|
||||||
"status": "failure",
|
status="failure",
|
||||||
"message": f"{error_msg},请重新扫码登录"
|
response_text="",
|
||||||
}
|
error_message=f"{error_msg},请重新扫码登录",
|
||||||
except ValueError as e:
|
location="{}",
|
||||||
# jwt_exp 格式不正确,记录警告后跳过 Token 过期验证
|
trigger_type=trigger_type
|
||||||
logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}")
|
)
|
||||||
|
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)
|
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,37 +233,32 @@ 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 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}")
|
|
||||||
|
|
||||||
# 记录失败
|
if not token_result["is_valid"]:
|
||||||
record = CheckInRecord(
|
error_msg = token_result["message"]
|
||||||
task_id=task.id,
|
logger.warning(f"⏰ {error_msg} - 用户: {user.alias}, 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,
|
record = CheckInRecord(
|
||||||
"message": f"{error_msg},请重新扫码登录",
|
task_id=task.id,
|
||||||
"record_id": record.id
|
status="failure",
|
||||||
}
|
response_text="",
|
||||||
except ValueError as e:
|
error_message=error_msg,
|
||||||
# jwt_exp 格式不正确,记录警告后跳过 Token 过期验证
|
location="{}",
|
||||||
logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}")
|
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)
|
# 执行打卡(传递 task 对象和用户 token)
|
||||||
logger.info(f"🤖 调用 Selenium Worker 执行打卡...")
|
logger.info(f"🤖 调用 Selenium Worker 执行打卡...")
|
||||||
|
|||||||
@@ -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,70 +162,69 @@ 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:
|
time_until_expiry = exp_timestamp - current_timestamp
|
||||||
exp_timestamp = int(user.jwt_exp)
|
|
||||||
|
|
||||||
# 检查 Token 状态并发送对应的提醒
|
# 情况1:Token 即将过期(过期前 30 分钟内,且还未过期)
|
||||||
time_until_expiry = exp_timestamp - current_timestamp
|
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秒
|
success = EmailService.notify_token_expiring(user, jwt_exp_str)
|
||||||
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"
|
|
||||||
|
|
||||||
# 发送"即将过期"邮件
|
if success:
|
||||||
success = EmailService.notify_token_expiring(user, jwt_exp_str)
|
user.token_expiring_notified = True
|
||||||
|
|
||||||
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
|
|
||||||
db.commit()
|
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:
|
# 情况2:Token 已过期(过期后 30 分钟内)
|
||||||
logger.warning(f"用户 {user.alias} 的 jwt_exp 格式不正确: {user.jwt_exp}")
|
elif -1800 < time_until_expiry <= 0: # 过期后 30 分钟内
|
||||||
continue
|
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:
|
finally:
|
||||||
db.close()
|
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;
|
const { status, data } = error.response;
|
||||||
|
|
||||||
if (status === 401) {
|
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') || '{}');
|
setTimeout(() => {
|
||||||
const hasPassword = user.has_password || false;
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login';
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
} else {
|
}, 100);
|
||||||
// 其他 401 错误(无效 Token 等):清除登录状态
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (window.location.pathname !== '/login') {
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回统一的错误对象
|
// 返回统一的错误对象
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}!`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳转到重定向页面或仪表盘
|
// 跳转到重定向页面或仪表盘
|
||||||
|
|||||||
Reference in New Issue
Block a user