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